132 lines
4.3 KiB
Dart
132 lines
4.3 KiB
Dart
|
|
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"),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|