Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions cold-wallet-app/lib/components/animated_ur_qr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';

/// Renders a UR payload as a QR code. A multi-part UR is animated by cycling
/// through its fragments with a [Timer] (no post-frame callbacks).
/// through its fragments with a [Timer] (no post-frame callbacks). Reacts to
/// [fps], [paused] and [parts] changes so the animation can be tuned live.
///
/// Every frame's QR is encoded once up front: encoding a dense QR takes long
/// enough that doing it inside build caps the real frame rate well below the
/// requested one. Painting a precomputed module matrix keeps ticks cheap.
class AnimatedUrQr extends StatefulWidget {
final List<String> parts;
final Duration interval;
final int fps;
final bool paused;
final double size;

const AnimatedUrQr({
super.key,
required this.parts,
this.interval = const Duration(milliseconds: 200),
this.size = 280,
});
const AnimatedUrQr({super.key, required this.parts, required this.fps, this.paused = false, this.size = 280});

@override
State<AnimatedUrQr> createState() => _AnimatedUrQrState();
Expand All @@ -24,15 +25,46 @@ class AnimatedUrQr extends StatefulWidget {
class _AnimatedUrQrState extends State<AnimatedUrQr> {
Timer? _timer;
int _index = 0;
late List<QrPainter> _painters;

@override
void initState() {
super.initState();
if (widget.parts.length > 1) {
_timer = Timer.periodic(widget.interval, (_) {
setState(() => _index = (_index + 1) % widget.parts.length);
});
_painters = _buildPainters();
_restartTimer();
}

List<QrPainter> _buildPainters() => widget.parts
.map(
(part) => QrPainter.withQr(
qr: QrCode.fromData(data: part, errorCorrectLevel: QrErrorCorrectLevel.L),
gapless: true,
eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black),
dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black),
),
)
.toList();

@override
void didUpdateWidget(AnimatedUrQr oldWidget) {
super.didUpdateWidget(oldWidget);
final partsChanged = !identical(widget.parts, oldWidget.parts);
if (partsChanged) {
_painters = _buildPainters();
_index = 0;
}
if (partsChanged || widget.fps != oldWidget.fps || widget.paused != oldWidget.paused) {
_restartTimer();
}
}

void _restartTimer() {
_timer?.cancel();
_timer = null;
if (widget.paused || widget.parts.length <= 1) return;
_timer = Timer.periodic(Duration(milliseconds: (1000 / widget.fps).round()), (_) {
setState(() => _index = (_index + 1) % widget.parts.length);
});
}

@override
Expand All @@ -48,14 +80,7 @@ class _AnimatedUrQrState extends State<AnimatedUrQr> {
height: widget.size,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: QrImageView(
data: widget.parts[_index],
errorCorrectionLevel: QrErrorCorrectLevel.L,
version: QrVersions.auto,
backgroundColor: Colors.white,
eyeStyle: const QrEyeStyle(eyeShape: QrEyeShape.square, color: Colors.black),
dataModuleStyle: const QrDataModuleStyle(dataModuleShape: QrDataModuleShape.square, color: Colors.black),
),
child: CustomPaint(painter: _painters[_index]),
);
}
}
27 changes: 27 additions & 0 deletions cold-wallet-app/lib/components/confirm_dialog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:quantus_cold_wallet/theme/app_colors.dart';

Future<bool> showConfirmDialog(
BuildContext context, {
required String title,
required String message,
required String confirmLabel,
}) async {
final colors = context.colors;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.sheetBackground,
title: Text(title),
content: Text(message),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(confirmLabel, style: TextStyle(color: colors.error)),
),
],
),
);
return confirmed == true;
}
91 changes: 91 additions & 0 deletions cold-wallet-app/lib/components/qr_tuning_controls.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:quantus_cold_wallet/providers/settings_providers.dart';
import 'package:quantus_cold_wallet/theme/app_colors.dart';
import 'package:quantus_cold_wallet/theme/app_text_styles.dart';

/// FPS and bytes-per-frame sliders bound to [coldSettingsProvider], shared by
/// the settings screen and the signature view so tuning behaves identically.
class QrTuningControls extends ConsumerWidget {
const QrTuningControls({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(coldSettingsProvider);
final controller = ref.read(coldSettingsProvider.notifier);

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_TuningSlider(
label: 'Frame rate',
valueText: '${settings.qrFps} FPS',
value: settings.qrFps.toDouble(),
min: ColdSettings.minQrFps.toDouble(),
max: ColdSettings.maxQrFps.toDouble(),
divisions: ColdSettings.maxQrFps - ColdSettings.minQrFps,
onChanged: (v) => controller.setQrFps(v.round()),
),
const SizedBox(height: 8),
_TuningSlider(
label: 'Bytes per frame',
valueText: '${settings.qrBytes} bytes',
value: settings.qrBytes.toDouble(),
min: ColdSettings.minQrBytes.toDouble(),
max: ColdSettings.maxQrBytes.toDouble(),
divisions: (ColdSettings.maxQrBytes - ColdSettings.minQrBytes) ~/ 25,
onChanged: (v) => controller.setQrBytes(v.round()),
),
],
);
}
}

class _TuningSlider extends StatelessWidget {
final String label;
final String valueText;
final double value;
final double min;
final double max;
final int divisions;
final ValueChanged<double> onChanged;

const _TuningSlider({
required this.label,
required this.valueText,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.onChanged,
});

@override
Widget build(BuildContext context) {
final colors = context.colors;
final text = context.themeText;

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: text.detail?.copyWith(color: colors.textSecondary)),
Text(valueText, style: text.detail?.copyWith(color: colors.textPrimary)),
],
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: colors.accentOrange,
inactiveTrackColor: colors.borderButton,
thumbColor: colors.accentOrange,
overlayColor: colors.accentOrange.withValues(alpha: 0.15),
trackHeight: 2,
),
child: Slider(value: value, min: min, max: max, divisions: divisions, onChanged: onChanged),
),
],
);
}
}
85 changes: 85 additions & 0 deletions cold-wallet-app/lib/providers/settings_providers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

@immutable
class ColdSettings {
static const minQrFps = 5;
static const maxQrFps = 50;
static const defaultQrFps = 15;
static const minQrBytes = 300;

// 1400 payload bytes encode to ~2870 UR chars, under the 2953-char capacity
// of a version-40 byte-mode QR at error correction L; encodeUrForQr measures
// the final strings and shrinks fragments if they would still overflow.
static const maxQrBytes = 1400;
static const defaultQrBytes = 1100;

final bool wifiOverrideEnabled;
final int qrFps;
final int qrBytes;

const ColdSettings({this.wifiOverrideEnabled = false, this.qrFps = defaultQrFps, this.qrBytes = defaultQrBytes});

ColdSettings copyWith({bool? wifiOverrideEnabled, int? qrFps, int? qrBytes}) => ColdSettings(
wifiOverrideEnabled: wifiOverrideEnabled ?? this.wifiOverrideEnabled,
qrFps: qrFps ?? this.qrFps,
qrBytes: qrBytes ?? this.qrBytes,
);
}

class ColdSettingsController extends Notifier<ColdSettings> {
static const _wifiOverrideKey = 'wifi_lock_override';
static const _qrFpsKey = 'qr_fps';
static const _qrBytesKey = 'qr_bytes_per_frame';

@override
ColdSettings build() {
_load();
return const ColdSettings();
}

Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
state = ColdSettings(
wifiOverrideEnabled: prefs.getBool(_wifiOverrideKey) ?? false,
qrFps: (prefs.getInt(_qrFpsKey) ?? ColdSettings.defaultQrFps).clamp(ColdSettings.minQrFps, ColdSettings.maxQrFps),
qrBytes: (prefs.getInt(_qrBytesKey) ?? ColdSettings.defaultQrBytes).clamp(
ColdSettings.minQrBytes,
ColdSettings.maxQrBytes,
),
);
}

Future<void> setWifiOverrideEnabled(bool enabled) async {
state = state.copyWith(wifiOverrideEnabled: enabled);
if (!enabled) ref.read(wifiLockOverriddenProvider.notifier).set(false);
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_wifiOverrideKey, enabled);
}

Future<void> setQrFps(int fps) async {
state = state.copyWith(qrFps: fps);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_qrFpsKey, fps);
}

Future<void> setQrBytes(int bytes) async {
state = state.copyWith(qrBytes: bytes);
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_qrBytesKey, bytes);
}
}

final coldSettingsProvider = NotifierProvider<ColdSettingsController, ColdSettings>(ColdSettingsController.new);

/// Session-only: armed from the connectivity guard after the user confirms the
/// override warning; disarmed when the persistent toggle is switched off.
class WifiLockOverridden extends Notifier<bool> {
@override
bool build() => false;

void set(bool value) => state = value;
}

final wifiLockOverriddenProvider = NotifierProvider<WifiLockOverridden, bool>(WifiLockOverridden.new);
27 changes: 3 additions & 24 deletions cold-wallet-app/lib/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,14 @@ import 'package:quantus_cold_wallet/components/scaffold_base.dart';
import 'package:quantus_cold_wallet/components/v2_app_bar.dart';
import 'package:quantus_cold_wallet/providers/wallet_providers.dart';
import 'package:quantus_cold_wallet/screens/scan_transaction_screen.dart';
import 'package:quantus_cold_wallet/screens/settings_screen.dart';
import 'package:quantus_cold_wallet/screens/show_key_screen.dart';
import 'package:quantus_cold_wallet/theme/app_colors.dart';
import 'package:quantus_cold_wallet/theme/app_text_styles.dart';

class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});

Future<void> _confirmReset(BuildContext context, WidgetRef ref) async {
final colors = context.colors;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: colors.sheetBackground,
title: const Text('Reset wallet?'),
content: const Text(
'This erases the encrypted key from this device. You can only restore it with your recovery phrase.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('Reset', style: TextStyle(color: colors.error)),
),
],
),
);
if (confirmed == true) await ref.read(walletControllerProvider.notifier).wipe();
}

@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.colors;
Expand All @@ -46,8 +25,8 @@ class HomeScreen extends ConsumerWidget {
child: Icon(Icons.lock_outline, color: colors.textPrimary, size: 22),
),
trailing: GestureDetector(
onTap: () => _confirmReset(context, ref),
child: Icon(Icons.more_horiz, color: colors.textPrimary, size: 22),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
child: Icon(Icons.settings_outlined, color: colors.textPrimary, size: 22),
),
),
mainContent: Column(
Expand Down
8 changes: 7 additions & 1 deletion cold-wallet-app/lib/screens/scan_transaction_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ class ScanTransactionScreen extends StatefulWidget {
}

class _ScanTransactionScreenState extends State<ScanTransactionScreen> {
final MobileScannerController _controller = MobileScannerController();
// Unrestricted: the default DetectionSpeed.normal enforces a 250ms timeout
// between detections, capping an animated QR at ~4 frames/second. Duplicate
// deliveries are cheap — parts dedupe through the set below.
final MobileScannerController _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.unrestricted,
formats: const [BarcodeFormat.qrCode],
);
final Set<String> _parts = {};
final Set<int> _seenSeq = {};
final RegExp _seqPattern = RegExp(r'/(\d+)-(\d+)/');
Expand Down
Loading
Loading