| 1 | import 'dart:async'; |
| 2 | |
| 3 | import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart'; |
| 4 | import 'package:cake_wallet/src/screens/ur/widgets/qr_format_info_bottom_sheet.dart'; |
| 5 | import 'package:cake_wallet/src/screens/ur/widgets/qr_selection_dialog.dart'; |
| 6 | import 'package:cake_wallet/src/widgets/primary_button.dart'; |
| 7 | import 'package:cake_wallet/utils/feature_flag.dart'; |
| 8 | import 'package:cake_wallet/utils/show_pop_up.dart'; |
| 9 | import 'package:cw_core/wallet_info.dart'; |
| 10 | import 'package:cw_core/wallet_type.dart'; |
| 11 | import 'package:flutter/material.dart'; |
| 12 | import 'package:flutter/services.dart'; |
| 13 | import 'package:flutter_svg/svg.dart'; |
| 14 | |
| 15 | class URQR extends StatefulWidget { |
| 16 | const URQR({super.key, required this.urqr, required this.walletType, this.hardwareWalletType}); |
| 17 | |
| 18 | final Map<String, String> urqr; |
| 19 | final WalletType walletType; |
| 20 | final HardwareWalletType? hardwareWalletType; |
| 21 | |
| 22 | @override |
| 23 | _URQRState createState() => _URQRState(); |
| 24 | } |
| 25 | |
| 26 | const urFrameTime = 1000 ~/ 5; |
| 27 | |
| 28 | class _URQRState extends State<URQR> { |
| 29 | Timer? _timer; |
| 30 | int frame = 0; |
| 31 | int selectedInt = 0; |
| 32 | |
| 33 | @override |
| 34 | void initState() { |
| 35 | final keys = widget.urqr.keys.toList(); |
| 36 | if (widget.hardwareWalletType == HardwareWalletType.coldcard) { |
| 37 | selectedInt = 1; |
| 38 | selected = keys[(selectedInt) % keys.length]; |
| 39 | } else if (widget.hardwareWalletType == HardwareWalletType.seedsigner) { |
| 40 | selectedInt = 2; |
| 41 | selected = keys[(selectedInt) % keys.length]; |
| 42 | } |
| 43 | |
| 44 | _timer = Timer.periodic(const Duration(milliseconds: urFrameTime), (_) => _nextFrame()); |
| 45 | super.initState(); |
| 46 | } |
| 47 | |
| 48 | @override |
| 49 | void dispose() { |
| 50 | _timer?.cancel(); |
| 51 | super.dispose(); |
| 52 | } |
| 53 | |
| 54 | String get nextLabel => widget.urqr.keys.toList()[(selectedInt + 1) % widget.urqr.length]; |
| 55 | void next() => setState(() { |
| 56 | final keys = widget.urqr.keys.toList(); |
| 57 | |
| 58 | selectedInt++; |
| 59 | selected = keys[(selectedInt) % keys.length]; |
| 60 | }); |
| 61 | |
| 62 | late String selected = (widget.urqr.isEmpty) ? "unknown" : widget.urqr.keys.first; |
| 63 | |
| 64 | List<String> get frames => widget.urqr[selected]?.split("\n") ?? []; |
| 65 | void _nextFrame() => setState(() => frame++); |
| 66 | |
| 67 | @override |
| 68 | Widget build(BuildContext context) { |
| 69 | return Column( |
| 70 | mainAxisSize: MainAxisSize.min, |
| 71 | mainAxisAlignment: MainAxisAlignment.center, |
| 72 | children: [ |
| 73 | Center( |
| 74 | child: Padding( |
| 75 | padding: const EdgeInsets.all(24.0), |
| 76 | child: Container( |
| 77 | child: QrImage( |
| 78 | data: frames[frame % frames.length], |
| 79 | version: -1, |
| 80 | size: MediaQuery.of(context).size.width * 0.7, |
| 81 | ), |
| 82 | ), |
| 83 | ), |
| 84 | ), |
| 85 | if (widget.urqr.values.length > 1) |
| 86 | widget.walletType == WalletType.monero ? _legacySwitch(context) : _newSwitch(context), |
| 87 | if (FeatureFlag.hasDevOptions) ...{ |
| 88 | TextButton( |
| 89 | onPressed: () { |
| 90 | Clipboard.setData(ClipboardData( |
| 91 | text: |
| 92 | """Current frame (${frame % frames.length}): ${frames[frame % frames.length]}, |
| 93 | All frames: |
| 94 | - ${frames.join("\n - ")}""")); |
| 95 | }, |
| 96 | child: Text("[dev] copy debug info"), |
| 97 | ), |
| 98 | } |
| 99 | ], |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | Widget _legacySwitch(BuildContext context) => SizedBox( |
| 104 | width: double.maxFinite, |
| 105 | child: Padding( |
| 106 | padding: const EdgeInsets.symmetric(horizontal: 16.0), |
| 107 | child: SizedBox( |
| 108 | width: double.maxFinite, |
| 109 | child: PrimaryButton( |
| 110 | onPressed: next, |
| 111 | text: nextLabel, |
| 112 | color: Theme.of(context).colorScheme.primary, |
| 113 | textColor: Theme.of(context).colorScheme.onPrimary, |
| 114 | ), |
| 115 | ), |
| 116 | ), |
| 117 | ); |
| 118 | |
| 119 | Widget _newSwitch(BuildContext context) { |
| 120 | return Column( |
| 121 | children: [ |
| 122 | const SizedBox(height: 32), |
| 123 | Row( |
| 124 | mainAxisAlignment: MainAxisAlignment.center, |
| 125 | children: [ |
| 126 | Icon(Icons.qr_code, color: Theme.of(context).colorScheme.primary, size: 24), |
| 127 | const SizedBox(width: 4), |
| 128 | Text( |
| 129 | "QR Code Format", |
| 130 | style: TextStyle( |
| 131 | fontWeight: FontWeight.bold, |
| 132 | fontSize: 16, |
| 133 | color: Theme.of(context).colorScheme.onSurface, |
| 134 | ), |
| 135 | ), |
| 136 | ], |
| 137 | ), |
| 138 | const SizedBox(height: 16), |
| 139 | Container( |
| 140 | margin: const EdgeInsets.symmetric(horizontal: 16), |
| 141 | decoration: BoxDecoration( |
| 142 | color: Theme.of(context).colorScheme.surfaceContainer, |
| 143 | borderRadius: BorderRadius.circular(12), |
| 144 | ), |
| 145 | child: Material( |
| 146 | color: Colors.transparent, |
| 147 | child: InkWell( |
| 148 | borderRadius: BorderRadius.circular(12), |
| 149 | onTap: () => _showFormatSelectionDialog(context), |
| 150 | child: Padding( |
| 151 | padding: EdgeInsets.all(16), |
| 152 | child: Row( |
| 153 | children: [ |
| 154 | if (_getCurrentFormatName().startsWith('Cupcake')) |
| 155 | Container( |
| 156 | width: 40, |
| 157 | height: 40, |
| 158 | decoration: BoxDecoration( |
| 159 | borderRadius: BorderRadius.circular(8), |
| 160 | ), |
| 161 | child: ClipRRect( |
| 162 | borderRadius: BorderRadius.circular(8), |
| 163 | child: SvgPicture.asset( |
| 164 | 'assets/images/cupcake.svg', |
| 165 | width: 40, |
| 166 | height: 40, |
| 167 | fit: BoxFit.cover, |
| 168 | ), |
| 169 | ), |
| 170 | ), |
| 171 | const SizedBox(width: 16), |
| 172 | Expanded( |
| 173 | child: Column( |
| 174 | crossAxisAlignment: CrossAxisAlignment.start, |
| 175 | children: [ |
| 176 | Text( |
| 177 | _getCurrentFormatName(), |
| 178 | style: TextStyle( |
| 179 | fontSize: 16, |
| 180 | fontWeight: FontWeight.w600, |
| 181 | color: Theme.of(context).colorScheme.onSurface, |
| 182 | ), |
| 183 | ), |
| 184 | Text( |
| 185 | _getCurrentFormatSubtitle(), |
| 186 | style: TextStyle( |
| 187 | fontSize: 14, |
| 188 | color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), |
| 189 | ), |
| 190 | ), |
| 191 | ], |
| 192 | ), |
| 193 | ), |
| 194 | Icon( |
| 195 | Icons.chevron_right, |
| 196 | color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), |
| 197 | size: 24, |
| 198 | ), |
| 199 | ], |
| 200 | ), |
| 201 | ), |
| 202 | ), |
| 203 | ), |
| 204 | ), |
| 205 | const SizedBox(height: 16), |
| 206 | GestureDetector( |
| 207 | onTap: () => _showInfoDialog(context), |
| 208 | child: Row( |
| 209 | mainAxisAlignment: MainAxisAlignment.center, |
| 210 | children: [ |
| 211 | Text( |
| 212 | "What's this for?", |
| 213 | style: TextStyle( |
| 214 | fontSize: 14, |
| 215 | color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), |
| 216 | ), |
| 217 | ), |
| 218 | const SizedBox(width: 4), |
| 219 | Icon( |
| 220 | Icons.info_outline, |
| 221 | size: 16, |
| 222 | color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), |
| 223 | ), |
| 224 | ], |
| 225 | ), |
| 226 | ), |
| 227 | const SizedBox(height: 32), |
| 228 | ], |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | String _getCurrentFormatName() { |
| 233 | if (widget.urqr.isEmpty) return "Unknown"; |
| 234 | final currentKey = widget.urqr.keys.elementAt(selectedInt % widget.urqr.length); |
| 235 | return currentKey.split(' ')[0]; |
| 236 | } |
| 237 | |
| 238 | String _getCurrentFormatSubtitle() { |
| 239 | if (widget.urqr.isEmpty) return ""; |
| 240 | final currentKey = widget.urqr.keys.elementAt(selectedInt % widget.urqr.length); |
| 241 | return currentKey.substring(currentKey.indexOf(' ') + 1); |
| 242 | } |
| 243 | |
| 244 | Future<void> _showFormatSelectionDialog(BuildContext context) async { |
| 245 | if (widget.urqr.length <= 1) return; |
| 246 | |
| 247 | final keys = widget.urqr.keys.toList(); |
| 248 | |
| 249 | await showPopUp<void>( |
| 250 | context: context, |
| 251 | builder: (context) => QRFormatSelectionDialog( |
| 252 | formats: keys, |
| 253 | currentIndex: selectedInt, |
| 254 | onFormatSelected: (index) { |
| 255 | Navigator.pop(context); |
| 256 | setState(() { |
| 257 | selectedInt = index; |
| 258 | selected = keys[index]; |
| 259 | }); |
| 260 | }, |
| 261 | ), |
| 262 | ); |
| 263 | } |
| 264 | |
| 265 | Future<void> _showInfoDialog(BuildContext context) => showModalBottomSheet( |
| 266 | context: context, |
| 267 | isScrollControlled: true, |
| 268 | backgroundColor: Colors.transparent, |
| 269 | builder: (_) => QRFormatInfoBottomSheet(), |
| 270 | ); |
| 271 | } |