feat: support smol:// QR sharing, deep links and in-dialog scanning
This commit is contained in:
parent
7d2a147fec
commit
aa74e12ee7
20 changed files with 591 additions and 101 deletions
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
131
lib/presentation/screens/import_contact_dialog.dart
Normal file
131
lib/presentation/screens/import_contact_dialog.dart
Normal 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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue