feat: support smol:// QR sharing, deep links and in-dialog scanning

This commit is contained in:
randogoth 2026-09-30 17:14:47 +03:00
parent 7d2a147fec
commit aa74e12ee7
20 changed files with 591 additions and 101 deletions

View file

@ -1,9 +1,15 @@
import "dart:async";
import "package:app_links/app_links.dart";
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/theme/theme.dart";
import "package:smol_mail/presentation/routes/app_router.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/theme.dart";
import "package:smol_mail/smol/link.dart";
/// SnackBars need no context through this key, so the client's trust
/// warnings can be shown from anywhere in the protocol stack.
@ -17,6 +23,10 @@ class AppWidget extends ConsumerStatefulWidget {
}
class _AppWidgetState extends ConsumerState<AppWidget> {
late final AppRouter _router = AppRouter(ref);
final AppLinks _appLinks = AppLinks();
StreamSubscription<Uri>? _linkSub;
@override
void initState() {
super.initState();
@ -37,6 +47,33 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
),
);
};
_linkSub = _appLinks.uriLinkStream.listen(_handleLink);
}
// A smol:// link is the self-certifying form: the §8 strong path,
// confirmed in the import dialog route before its key is bound. The push
// waits a frame — the stream can deliver the cold-start link while the
// first frame is still being laid out.
void _handleLink(Uri uri) {
final link = parseSmolLink(uri.toString());
if (link == null) return;
if (ref.read(identityProvider) == null) {
rootScaffoldMessengerKey.currentState?.showSnackBar(
const SnackBar(
content: Text("finish onboarding before using smol:// links")),
);
return;
}
WidgetsBinding.instance.addPostFrameCallback(
(_) => _router.push(ImportContactDialogRoute(prefill: link.source)),
);
}
@override
void dispose() {
_linkSub?.cancel();
super.dispose();
}
@override
@ -48,7 +85,16 @@ class _AppWidgetState extends ConsumerState<AppWidget> {
themeMode: ThemeMode.system,
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: rootScaffoldMessengerKey,
routerConfig: AppRouter(ref).config(),
// smol:// URIs are for app_links, not the route table. The startup link
// must still resolve to a real route — auto_route throws "Can not
// resolve initial route" on an unmatched link and leaves a blank
// screen — so it lands on the normal entry route, whose guard
// redirects to home exactly like a launcher start. Every later
// platform link is ignored by the router; the stream handler reacts.
routerConfig: _router.config(
deepLinkBuilder: (link) =>
link.initial ? DeepLink.single(const OnboardingRoute()) : DeepLink.none,
),
);
}
}

View file

@ -1,10 +1,25 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/presentation/routes/home_guard.dart";
import "package:smol_mail/presentation/routes/identity_guard.dart";
import "app_router.gr.dart";
/// A DialogRoute is a PageRoute whose settings carry the AutoRoutePage, so
/// auto_route's setNewRoutePath — which pops pageless top routes before
/// resolving a platform link — leaves dialog pages standing.
Route<T> dialogRouteBuilder<T>(
BuildContext context,
Widget child,
AutoRoutePage<T> page,
) =>
DialogRoute<T>(
context: context,
settings: page,
builder: (ctx) => child,
);
@AutoRouterConfig(replaceInRouteName: "Screen,Route")
class AppRouter extends RootStackRouter {
final WidgetRef ref;
@ -44,5 +59,11 @@ class AppRouter extends RootStackRouter {
path: "/contact",
guards: [IdentityGuard(ref)],
),
CustomRoute(
page: ImportContactDialogRoute.page,
path: "/import",
customRouteBuilder: dialogRouteBuilder,
guards: [IdentityGuard(ref)],
),
];
}

View file

@ -10,27 +10,29 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:auto_route/auto_route.dart' as _i9;
import 'package:flutter/material.dart' as _i10;
import 'package:auto_route/auto_route.dart' as _i10;
import 'package:flutter/material.dart' as _i11;
import 'package:smol_mail/presentation/screens/compose_screen.dart' as _i1;
import 'package:smol_mail/presentation/screens/contact_detail_screen.dart'
as _i2;
import 'package:smol_mail/presentation/screens/contacts_screen.dart' as _i3;
import 'package:smol_mail/presentation/screens/home_screen.dart' as _i4;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i5;
import 'package:smol_mail/presentation/screens/import_contact_dialog.dart'
as _i5;
import 'package:smol_mail/presentation/screens/inbox_screen.dart' as _i6;
import 'package:smol_mail/presentation/screens/message_detail_screen.dart'
as _i6;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i7;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i8;
as _i7;
import 'package:smol_mail/presentation/screens/onboarding_screen.dart' as _i8;
import 'package:smol_mail/presentation/screens/settings_screen.dart' as _i9;
/// generated route for
/// [_i1.ComposeScreen]
class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
class ComposeRoute extends _i10.PageRouteInfo<ComposeRouteArgs> {
ComposeRoute({
_i10.Key? key,
_i11.Key? key,
String? to,
String? subject,
List<_i9.PageRouteInfo>? children,
List<_i10.PageRouteInfo>? children,
}) : super(
ComposeRoute.name,
args: ComposeRouteArgs(key: key, to: to, subject: subject),
@ -39,7 +41,7 @@ class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
static const String name = 'ComposeRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ComposeRouteArgs>(
@ -57,7 +59,7 @@ class ComposeRoute extends _i9.PageRouteInfo<ComposeRouteArgs> {
class ComposeRouteArgs {
const ComposeRouteArgs({this.key, this.to, this.subject});
final _i10.Key? key;
final _i11.Key? key;
final String? to;
@ -81,11 +83,11 @@ class ComposeRouteArgs {
/// generated route for
/// [_i2.ContactDetailScreen]
class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
class ContactDetailRoute extends _i10.PageRouteInfo<ContactDetailRouteArgs> {
ContactDetailRoute({
_i10.Key? key,
_i11.Key? key,
required String address,
List<_i9.PageRouteInfo>? children,
List<_i10.PageRouteInfo>? children,
}) : super(
ContactDetailRoute.name,
args: ContactDetailRouteArgs(key: key, address: address),
@ -94,7 +96,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
static const String name = 'ContactDetailRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ContactDetailRouteArgs>();
@ -106,7 +108,7 @@ class ContactDetailRoute extends _i9.PageRouteInfo<ContactDetailRouteArgs> {
class ContactDetailRouteArgs {
const ContactDetailRouteArgs({this.key, required this.address});
final _i10.Key? key;
final _i11.Key? key;
final String address;
@ -128,13 +130,13 @@ class ContactDetailRouteArgs {
/// generated route for
/// [_i3.ContactsScreen]
class ContactsRoute extends _i9.PageRouteInfo<void> {
const ContactsRoute({List<_i9.PageRouteInfo>? children})
class ContactsRoute extends _i10.PageRouteInfo<void> {
const ContactsRoute({List<_i10.PageRouteInfo>? children})
: super(ContactsRoute.name, initialChildren: children);
static const String name = 'ContactsRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i3.ContactsScreen();
@ -144,13 +146,13 @@ class ContactsRoute extends _i9.PageRouteInfo<void> {
/// generated route for
/// [_i4.HomeScreen]
class HomeRoute extends _i9.PageRouteInfo<void> {
const HomeRoute({List<_i9.PageRouteInfo>? children})
class HomeRoute extends _i10.PageRouteInfo<void> {
const HomeRoute({List<_i10.PageRouteInfo>? children})
: super(HomeRoute.name, initialChildren: children);
static const String name = 'HomeRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i4.HomeScreen();
@ -159,29 +161,82 @@ class HomeRoute extends _i9.PageRouteInfo<void> {
}
/// generated route for
/// [_i5.InboxScreen]
class InboxRoute extends _i9.PageRouteInfo<void> {
const InboxRoute({List<_i9.PageRouteInfo>? children})
/// [_i5.ImportContactDialogScreen]
class ImportContactDialogRoute
extends _i10.PageRouteInfo<ImportContactDialogRouteArgs> {
ImportContactDialogRoute({
_i11.Key? key,
String? prefill,
List<_i10.PageRouteInfo>? children,
}) : super(
ImportContactDialogRoute.name,
args: ImportContactDialogRouteArgs(key: key, prefill: prefill),
initialChildren: children,
);
static const String name = 'ImportContactDialogRoute';
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
final args = data.argsAs<ImportContactDialogRouteArgs>(
orElse: () => const ImportContactDialogRouteArgs(),
);
return _i5.ImportContactDialogScreen(
key: args.key,
prefill: args.prefill,
);
},
);
}
class ImportContactDialogRouteArgs {
const ImportContactDialogRouteArgs({this.key, this.prefill});
final _i11.Key? key;
final String? prefill;
@override
String toString() {
return 'ImportContactDialogRouteArgs{key: $key, prefill: $prefill}';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! ImportContactDialogRouteArgs) return false;
return key == other.key && prefill == other.prefill;
}
@override
int get hashCode => key.hashCode ^ prefill.hashCode;
}
/// generated route for
/// [_i6.InboxScreen]
class InboxRoute extends _i10.PageRouteInfo<void> {
const InboxRoute({List<_i10.PageRouteInfo>? children})
: super(InboxRoute.name, initialChildren: children);
static const String name = 'InboxRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i5.InboxScreen();
return const _i6.InboxScreen();
},
);
}
/// generated route for
/// [_i6.MessageDetailScreen]
class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
/// [_i7.MessageDetailScreen]
class MessageDetailRoute extends _i10.PageRouteInfo<MessageDetailRouteArgs> {
MessageDetailRoute({
_i10.Key? key,
_i11.Key? key,
required String folder,
required String id,
List<_i9.PageRouteInfo>? children,
List<_i10.PageRouteInfo>? children,
}) : super(
MessageDetailRoute.name,
args: MessageDetailRouteArgs(key: key, folder: folder, id: id),
@ -190,11 +245,11 @@ class MessageDetailRoute extends _i9.PageRouteInfo<MessageDetailRouteArgs> {
static const String name = 'MessageDetailRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
final args = data.argsAs<MessageDetailRouteArgs>();
return _i6.MessageDetailScreen(
return _i7.MessageDetailScreen(
key: args.key,
folder: args.folder,
id: args.id,
@ -210,7 +265,7 @@ class MessageDetailRouteArgs {
required this.id,
});
final _i10.Key? key;
final _i11.Key? key;
final String folder;
@ -233,33 +288,33 @@ class MessageDetailRouteArgs {
}
/// generated route for
/// [_i7.OnboardingScreen]
class OnboardingRoute extends _i9.PageRouteInfo<void> {
const OnboardingRoute({List<_i9.PageRouteInfo>? children})
/// [_i8.OnboardingScreen]
class OnboardingRoute extends _i10.PageRouteInfo<void> {
const OnboardingRoute({List<_i10.PageRouteInfo>? children})
: super(OnboardingRoute.name, initialChildren: children);
static const String name = 'OnboardingRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i7.OnboardingScreen();
return const _i8.OnboardingScreen();
},
);
}
/// generated route for
/// [_i8.SettingsScreen]
class SettingsRoute extends _i9.PageRouteInfo<void> {
const SettingsRoute({List<_i9.PageRouteInfo>? children})
/// [_i9.SettingsScreen]
class SettingsRoute extends _i10.PageRouteInfo<void> {
const SettingsRoute({List<_i10.PageRouteInfo>? children})
: super(SettingsRoute.name, initialChildren: children);
static const String name = 'SettingsRoute';
static _i9.PageInfo page = _i9.PageInfo(
static _i10.PageInfo page = _i10.PageInfo(
name,
builder: (data) {
return const _i8.SettingsScreen();
return const _i9.SettingsScreen();
},
);
}

View file

@ -6,6 +6,7 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/shared/utils/format.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/ui.dart";
@ -88,6 +89,10 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
final contact = store.contact(widget.address);
final accepted = store.accepted(widget.address);
final isAccepted = accepted != null && accepted.active;
// Computed once per build; the branch below only renders it when a
// contact is bound to the address, which is when the parse succeeds.
final uri =
contact == null ? null : parseAddress(widget.address).uri(contact.key);
return Scaffold(
appBar: AppBar(title: Text(widget.address)),
@ -130,17 +135,16 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
Row(
children: [
Expanded(
child: _field(
context,
"address",
parseAddress(widget.address)
.uri(contact.key)),
child: _field(context, "address", uri!),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () => copyText(
context,
parseAddress(widget.address).uri(contact.key)),
onPressed: () => copyText(context, uri),
),
IconButton(
tooltip: "Show QR code",
icon: const Icon(Icons.qr_code, size: 18),
onPressed: () => showQrCodeSheet(context, uri),
),
],
),

View file

@ -5,8 +5,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/ui.dart";
/// Contacts: the addresses bound to keys, with the trust badge and the number
@ -15,45 +13,8 @@ import "package:smol_mail/smol/ui.dart";
class ContactsScreen extends ConsumerWidget {
const ContactsScreen({super.key});
Future<void> _import(BuildContext context, WidgetRef ref) async {
final client = ref.read(clientProvider);
final controller = TextEditingController();
final text = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Import contact"),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: "smol://user@host/key",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(controller.text),
child: const Text("Import"),
),
],
),
);
if (text == null || text.trim().isEmpty) return;
try {
client.importContact(text);
ref.read(revisionProvider.notifier).bump();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("contact imported (verified key)")),
);
}
} on SmolError catch (err) {
if (context.mounted) {
showErrorSnackBar(context, err.message);
}
}
}
void _import(BuildContext context) =>
AutoRouter.of(context).push(ImportContactDialogRoute());
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -65,7 +26,7 @@ class ContactsScreen extends ConsumerWidget {
actions: [
IconButton(
tooltip: "Import smol:// address",
onPressed: () => _import(context, ref),
onPressed: () => _import(context),
icon: const Icon(Icons.person_add),
),
],

View file

@ -0,0 +1,131 @@
import "package:auto_route/auto_route.dart";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:mobile_scanner/mobile_scanner.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/app_widget.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
import "package:smol_mail/smol/link.dart";
/// The §8 strong path: a self-certifying smol:// address is pasted, scanned
/// or deep-linked in, and the user confirms before its key is bound. A
/// routed dialog page rather than showDialog, so auto_route's platform-link
/// handling — which pops pageless top routes — leaves it standing. The QR
/// icon swaps the text field for a camera view; a scanned URI only fills
/// the field, the Import tap is still what binds the key.
@RoutePage()
class ImportContactDialogScreen extends ConsumerStatefulWidget {
final String? prefill;
const ImportContactDialogScreen({super.key, this.prefill});
@override
ConsumerState<ImportContactDialogScreen> createState() =>
ImportContactDialogScreenState();
}
class ImportContactDialogScreenState
extends ConsumerState<ImportContactDialogScreen> {
late final controller = TextEditingController(text: widget.prefill ?? "");
bool scanning = false;
MobileScannerController? _scanner;
@override
void dispose() {
_scanner?.dispose();
controller.dispose();
super.dispose();
}
void _toggleScan() {
setState(() {
_scanner?.dispose();
_scanner =
scanning ? null : MobileScannerController(formats: [BarcodeFormat.qrCode]);
scanning = !scanning;
});
}
// Only a URI fumi accepts fills the field; anything else the camera sees
// is not for this dialog, so scanning simply continues.
void _onDetect(BarcodeCapture capture) {
if (!scanning) return;
for (final barcode in capture.barcodes) {
final raw = barcode.rawValue?.trim();
if (raw != null && parseSmolLink(raw) != null) {
controller.text = raw;
_toggleScan();
return;
}
}
}
void _import() {
final text = controller.text;
if (text.trim().isEmpty) return;
try {
ref.read(clientProvider).importContact(text);
ref.read(revisionProvider.notifier).bump();
rootScaffoldMessengerKey.currentState?.showSnackBar(
const SnackBar(content: Text("contact imported (verified key)")),
);
AutoRouter.of(context).pop();
} on SmolError catch (err) {
showErrorSnackBar(context, err.message);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: Theme.of(context).extension<AppColors>()!.cardFill,
title: const Text("Import contact"),
content: scanning
? SizedBox(
height: 220,
width: double.maxFinite,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: MobileScanner(
controller: _scanner!,
onDetect: _onDetect,
errorBuilder: (context, error) => Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
error.errorCode.name,
style: Theme.of(context).textTheme.labelSmall,
textAlign: TextAlign.center,
),
),
),
),
),
)
: TextField(
controller: controller,
autofocus: true,
onSubmitted: (_) => _import(),
decoration: InputDecoration(
hintText: "smol://user@host/key",
filled: true,
fillColor: Theme.of(context).colorScheme.surface,
),
),
actions: [
IconButton(
tooltip: scanning ? "Type the address" : "Scan a QR code",
onPressed: _toggleScan,
icon: Icon(scanning ? Icons.keyboard : Icons.qr_code_scanner),
),
TextButton(
onPressed: _import,
child: const Text("Import"),
),
],
);
}
}

View file

@ -6,6 +6,7 @@ import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/widgets/image_banner.dart";
import "package:smol_mail/presentation/widgets/message/message_list.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/errors.dart";
@ -90,11 +91,24 @@ class _InboxScreenState extends ConsumerState<InboxScreen>
final unread = ref.watch(unreadProvider);
final requestsUnread = ref.watch(requestsUnreadProvider);
final canFetch = _folders[_tabController.index] != "sent";
// The same self-certifying URI the settings share row shows, one tap
// closer: what a second device scans to import this identity.
final me = ref.watch(identityProvider);
final account = ref.watch(accountProvider);
final share = (me == null || account == null)
? null
: account.uri(me.publicKey);
return Scaffold(
appBar: AppBar(
title: const Text("kirakira"),
actions: [
if (share != null)
IconButton(
tooltip: "Show QR code",
onPressed: () => showQrCodeSheet(context, share),
icon: const Icon(Icons.qr_code),
),
if (canFetch)
IconButton(
tooltip: "Fetch",

View file

@ -11,6 +11,7 @@ import "package:share_plus/share_plus.dart";
import "package:smol_mail/data/providers/providers.dart";
import "package:smol_mail/presentation/routes/app_router.gr.dart";
import "package:smol_mail/presentation/theme/app_colors.dart";
import "package:smol_mail/presentation/widgets/qr_code_sheet.dart";
import "package:smol_mail/shared/utils/snackbar.dart";
import "package:smol_mail/smol/address.dart";
import "package:smol_mail/smol/errors.dart";
@ -236,12 +237,18 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
overflow: TextOverflow.ellipsis,
),
),
if (share != null)
if (share != null) ...[
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () =>
Clipboard.setData(ClipboardData(text: share)),
),
IconButton(
tooltip: "Show QR code",
icon: const Icon(Icons.qr_code, size: 18),
onPressed: () => showQrCodeSheet(context, share),
),
],
],
),
],

View file

@ -0,0 +1,52 @@
import "package:flutter/material.dart";
import "package:qr_flutter/qr_flutter.dart";
import "package:smol_mail/shared/utils/format.dart";
/// Shows [payload] as a scannable QR code in a bottom sheet. Fixed dark
/// modules on white regardless of theme: contrast is what a scanner needs,
/// not the palette. Scrolled, because the sheet is height-capped on small
/// screens.
Future<void> showQrCodeSheet(BuildContext context, String payload) {
return showModalBottomSheet<void>(
context: context,
builder: (ctx) => SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
color: Colors.white,
child: QrImageView(
data: payload,
version: QrVersions.auto,
size: 260,
backgroundColor: Colors.white,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Color.fromRGBO(1, 17, 29, 1),
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Color.fromRGBO(1, 17, 29, 1),
),
),
),
const SizedBox(height: 15),
SelectableText(
payload,
style: Theme.of(ctx).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
TextButton.icon(
onPressed: () => copyText(ctx, payload),
icon: const Icon(Icons.copy, size: 18),
label: const Text("Copy"),
),
],
),
),
);
}