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 createState() => ImportContactDialogScreenState(); } class ImportContactDialogScreenState extends ConsumerState { 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()!.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"), ), ], ); } }