229 lines
7.3 KiB
Dart
229 lines
7.3 KiB
Dart
import 'package:cupertino_ui/cupertino_ui.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
|
|
import '../../../core/widgets/feature_ui.dart';
|
|
import '../../../core/widgets/settings_ui.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
|
|
/// 二维码扫描页。
|
|
///
|
|
/// 支持两种识别方式:
|
|
/// 1. 相机实时扫码([MobileScanner] 的 `onDetect` 回调);
|
|
/// 2. 从相册选取图片后离线解析([MobileScannerController.analyzeImage])。
|
|
///
|
|
/// 识别成功后通过 `context.pop(code)` 把结果回传给调用方(绑定设备页)。
|
|
class DeviceScanPage extends StatefulWidget {
|
|
const DeviceScanPage({super.key});
|
|
|
|
@override
|
|
State<DeviceScanPage> createState() => _DeviceScanPageState();
|
|
}
|
|
|
|
class _DeviceScanPageState extends State<DeviceScanPage> {
|
|
final MobileScannerController _controller = MobileScannerController(
|
|
detectionSpeed: DetectionSpeed.noDuplicates,
|
|
formats: const [BarcodeFormat.qrCode],
|
|
);
|
|
|
|
/// 防止连续回调导致多次 pop。
|
|
bool _handled = false;
|
|
|
|
/// 相册解析中,避免重复选图。
|
|
bool _pickingImage = false;
|
|
|
|
bool _torchOn = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return CupertinoPageScaffold(
|
|
navigationBar: CupertinoNavigationBar(
|
|
middle: Text(l10n.scanTitle),
|
|
leading: CupertinoButton(
|
|
padding: EdgeInsets.zero,
|
|
child: const Icon(CupertinoIcons.back),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
trailing: CupertinoButton(
|
|
padding: EdgeInsets.zero,
|
|
onPressed: _toggleTorch,
|
|
child: Icon(
|
|
_torchOn ? CupertinoIcons.lightbulb_fill : CupertinoIcons.lightbulb,
|
|
size: 24,
|
|
),
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
MobileScanner(
|
|
controller: _controller,
|
|
onDetect: (capture) => _onDetect(capture, l10n),
|
|
errorBuilder: (context, error, child) => _PermissionHint(
|
|
message: l10n.scanPermissionDenied,
|
|
),
|
|
),
|
|
// 取景框 + 提示文案
|
|
IgnorePointer(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 230,
|
|
height: 230,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: AppColors.green,
|
|
width: 3,
|
|
),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
|
child: Text(
|
|
l10n.scanHint,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 13.5,
|
|
color: CupertinoColors.white,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (_pickingImage)
|
|
const Positioned.fill(
|
|
child: ColoredBox(
|
|
color: Color(0x66000000),
|
|
child: Center(child: CupertinoActivityIndicator(radius: 14)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
child: SecondaryButton(
|
|
text: l10n.scanFromGallery,
|
|
icon: CupertinoIcons.photo_on_rectangle,
|
|
onPressed: _pickingImage ? null : () => _pickFromGallery(l10n),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 相机识别回调:取首个非空条码值后回传并关闭页面。
|
|
void _onDetect(BarcodeCapture capture, AppLocalizations l10n) {
|
|
if (_handled) return;
|
|
final code = _firstValue(capture);
|
|
if (code == null) return;
|
|
_handled = true;
|
|
context.pop(code);
|
|
}
|
|
|
|
/// 从相册选取图片并离线解析二维码。
|
|
Future<void> _pickFromGallery(AppLocalizations l10n) async {
|
|
setState(() => _pickingImage = true);
|
|
try {
|
|
final picked = await ImagePicker().pickImage(source: ImageSource.gallery);
|
|
if (picked == null) return;
|
|
|
|
final capture = await _controller.analyzeImage(picked.path);
|
|
final code = capture == null ? null : _firstValue(capture);
|
|
if (!mounted) return;
|
|
|
|
if (code == null) {
|
|
await AppDialogs.alert(
|
|
context,
|
|
message: l10n.scanNoCodeFound,
|
|
confirmText: l10n.commonConfirm,
|
|
);
|
|
return;
|
|
}
|
|
if (!mounted) return;
|
|
_handled = true;
|
|
context.pop(code);
|
|
} finally {
|
|
if (mounted) setState(() => _pickingImage = false);
|
|
}
|
|
}
|
|
|
|
/// 切换闪光灯,失败时静默忽略(部分设备/模拟器不支持)。
|
|
Future<void> _toggleTorch() async {
|
|
try {
|
|
await _controller.toggleTorch();
|
|
if (mounted) setState(() => _torchOn = !_torchOn);
|
|
} catch (_) {
|
|
// 不支持闪光灯,忽略。
|
|
}
|
|
}
|
|
|
|
/// 提取扫描结果中第一个有效的二维码文本。
|
|
String? _firstValue(BarcodeCapture capture) {
|
|
for (final barcode in capture.barcodes) {
|
|
final value = barcode.rawValue;
|
|
if (value != null && value.trim().isNotEmpty) return value.trim();
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// 相机不可用(权限被拒或初始化失败)时的占位提示。
|
|
class _PermissionHint extends StatelessWidget {
|
|
const _PermissionHint({required this.message});
|
|
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ColoredBox(
|
|
color: const Color(0xFF111827),
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 40),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
CupertinoIcons.video_camera,
|
|
size: 46,
|
|
color: CupertinoColors.white,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: CupertinoColors.white,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|