From d0639febfca534bc6cfb0c5d576703eee13f513d Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:39:18 +0200 Subject: [PATCH 1/2] Show the new code right after an authenticator is added Whoever issued the QR code asks for a code straight away to confirm the device, so present the first one in a sheet before the add screen closes, and put new entries at the top of the vault instead of the bottom. --- lib/services/totp_vault.dart | 3 +- lib/ui/authenticator/add_totp_screen.dart | 5 + lib/ui/authenticator/totp_added_sheet.dart | 111 +++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 lib/ui/authenticator/totp_added_sheet.dart diff --git a/lib/services/totp_vault.dart b/lib/services/totp_vault.dart index 513dec1..512779b 100644 --- a/lib/services/totp_vault.dart +++ b/lib/services/totp_vault.dart @@ -72,7 +72,8 @@ class TotpVault { Future add(TotpEntry entry) async { final entries = await load(); entries.removeWhere((e) => e.id == entry.id); - entries.add(entry); + // Newest first: the entry someone just added is the one they are looking for. + entries.insert(0, entry); await _saveAll(entries); return entry; } diff --git a/lib/ui/authenticator/add_totp_screen.dart b/lib/ui/authenticator/add_totp_screen.dart index 515215c..b03182a 100644 --- a/lib/ui/authenticator/add_totp_screen.dart +++ b/lib/ui/authenticator/add_totp_screen.dart @@ -3,6 +3,7 @@ import 'package:forui/forui.dart'; import '../../services/totp_service.dart'; import '../../services/totp_vault.dart'; +import 'totp_added_sheet.dart'; import 'totp_scan_screen.dart'; class AddTotpScreen extends StatefulWidget { @@ -74,6 +75,10 @@ class _AddTotpScreenState extends State { }); try { await TotpVault.instance.add(entry); + if (!mounted) return; + // Whoever issued the QR code usually wants a code back immediately, so + // show it before this screen goes away. + await showTotpAddedSheet(context, entry); if (mounted) Navigator.of(context).pop(entry); } catch (e) { if (mounted) setState(() => _error = '$e'); diff --git a/lib/ui/authenticator/totp_added_sheet.dart b/lib/ui/authenticator/totp_added_sheet.dart new file mode 100644 index 0000000..4a481a4 --- /dev/null +++ b/lib/ui/authenticator/totp_added_sheet.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:forui/forui.dart'; + +import '../../services/totp_service.dart'; +import '../../services/totp_vault.dart'; + +/// Shown the moment an authenticator is added. Whoever issued the QR code +/// usually asks for a code straight away to confirm the new device, so the +/// first code is put in front of the user here rather than leaving them to +/// find the new entry in the list. +Future showTotpAddedSheet(BuildContext context, TotpEntry entry) { + final config = TotpConfig.tryParse(entry.secretOrUri); + if (config == null) return Future.value(); + return showFSheet( + context: context, + side: FLayout.btt, + builder: (context) => _TotpAddedSheet(entry: entry, config: config), + ); +} + +class _TotpAddedSheet extends StatefulWidget { + final TotpEntry entry; + final TotpConfig config; + + const _TotpAddedSheet({required this.entry, required this.config}); + + @override + State<_TotpAddedSheet> createState() => _TotpAddedSheetState(); +} + +class _TotpAddedSheetState extends State<_TotpAddedSheet> { + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + String _format(String code) => code.length == 6 ? '${code.substring(0, 3)} ${code.substring(3)}' : code; + + @override + Widget build(BuildContext context) { + final colors = context.theme.colors; + final typography = context.theme.typography; + final code = TotpService.generate(widget.config); + final remaining = code?.secondsRemaining ?? 0; + final expiring = remaining <= 5; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text(widget.entry.title, style: typography.lg.copyWith(fontWeight: FontWeight.w700)), + if ((widget.entry.subtitle ?? '').isNotEmpty) + Text(widget.entry.subtitle!, style: typography.sm.copyWith(color: colors.mutedForeground)), + Text( + 'Enter this code where you scanned the QR code to confirm the new device.', + style: typography.sm.copyWith(color: colors.mutedForeground), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + code == null ? '------' : _format(code.code), + style: typography.xl2.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 2, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + '${remaining}s', + style: typography.sm.copyWith(color: expiring ? colors.destructive : colors.mutedForeground), + ), + ], + ), + FButton( + prefix: const Icon(FIcons.copy), + onPress: code == null + ? null + : () async { + await Clipboard.setData(ClipboardData(text: code.code)); + if (context.mounted) showFToast(context: context, title: const Text('Code copied')); + }, + child: const Text('Copy code'), + ), + FButton( + style: FButtonStyle.outline(), + onPress: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + ), + ); + } +} From 39de30c051c703356be58af2d604f7c5f8af02ec Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:00:16 +0200 Subject: [PATCH 2/2] Give the added-code sheet its own surface --- lib/ui/authenticator/totp_added_sheet.dart | 117 +++++++++++++-------- 1 file changed, 71 insertions(+), 46 deletions(-) diff --git a/lib/ui/authenticator/totp_added_sheet.dart b/lib/ui/authenticator/totp_added_sheet.dart index 4a481a4..1142090 100644 --- a/lib/ui/authenticator/totp_added_sheet.dart +++ b/lib/ui/authenticator/totp_added_sheet.dart @@ -48,7 +48,8 @@ class _TotpAddedSheetState extends State<_TotpAddedSheet> { super.dispose(); } - String _format(String code) => code.length == 6 ? '${code.substring(0, 3)} ${code.substring(3)}' : code; + String _format(String code) => + code.length == 6 ? '${code.substring(0, 3)} ${code.substring(3)}' : code; @override Widget build(BuildContext context) { @@ -58,53 +59,77 @@ class _TotpAddedSheetState extends State<_TotpAddedSheet> { final remaining = code?.secondsRemaining ?? 0; final expiring = remaining <= 5; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [ - Text(widget.entry.title, style: typography.lg.copyWith(fontWeight: FontWeight.w700)), - if ((widget.entry.subtitle ?? '').isNotEmpty) - Text(widget.entry.subtitle!, style: typography.sm.copyWith(color: colors.mutedForeground)), - Text( - 'Enter this code where you scanned the QR code to confirm the new device.', - style: typography.sm.copyWith(color: colors.mutedForeground), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - code == null ? '------' : _format(code.code), - style: typography.xl2.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 2, - fontFeatures: const [FontFeature.tabularFigures()], - ), - ), + // The sheet has to paint its own surface, otherwise the screen underneath + // shows straight through it. + return DecoratedBox( + decoration: BoxDecoration( + color: colors.background, + border: Border(top: BorderSide(color: colors.border)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + widget.entry.title, + style: typography.lg.copyWith(fontWeight: FontWeight.w700), + ), + if ((widget.entry.subtitle ?? '').isNotEmpty) Text( - '${remaining}s', - style: typography.sm.copyWith(color: expiring ? colors.destructive : colors.mutedForeground), + widget.entry.subtitle!, + style: typography.sm.copyWith(color: colors.mutedForeground), ), - ], - ), - FButton( - prefix: const Icon(FIcons.copy), - onPress: code == null - ? null - : () async { - await Clipboard.setData(ClipboardData(text: code.code)); - if (context.mounted) showFToast(context: context, title: const Text('Code copied')); - }, - child: const Text('Copy code'), - ), - FButton( - style: FButtonStyle.outline(), - onPress: () => Navigator.of(context).pop(), - child: const Text('Done'), - ), - ], + Text( + 'Enter this code where you scanned the QR code to confirm the new device.', + style: typography.sm.copyWith(color: colors.mutedForeground), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + code == null ? '------' : _format(code.code), + style: typography.xl2.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 2, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + Text( + '${remaining}s', + style: typography.sm.copyWith( + color: expiring + ? colors.destructive + : colors.mutedForeground, + ), + ), + ], + ), + FButton( + prefix: const Icon(FIcons.copy), + onPress: code == null + ? null + : () async { + await Clipboard.setData(ClipboardData(text: code.code)); + if (context.mounted) { + showFToast( + context: context, + title: const Text('Code copied'), + ); + } + }, + child: const Text('Copy code'), + ), + FButton( + style: FButtonStyle.outline(), + onPress: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + ), ), ); }