feat: 新增设备绑定、扫码与联系人管理功能
This commit is contained in:
141
lib/features/device/presentation/device_bind_page.dart
Normal file
141
lib/features/device/presentation/device_bind_page.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'device_manage_controller.dart';
|
||||
|
||||
/// 绑定设备页(手动输入 SN)。
|
||||
///
|
||||
/// 提供 SN / 备注名输入、「确认绑定」与「扫码绑定」两个入口。
|
||||
/// 扫码入口跳转 `/device/scan`,扫描结果回填到 SN 输入框。
|
||||
class DeviceBindPage extends ConsumerStatefulWidget {
|
||||
const DeviceBindPage({super.key, this.initialSn});
|
||||
|
||||
/// 由扫码页回传的初始 SN(可选)。
|
||||
final String? initialSn;
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceBindPage> createState() => _DeviceBindPageState();
|
||||
}
|
||||
|
||||
class _DeviceBindPageState extends ConsumerState<DeviceBindPage> {
|
||||
late final TextEditingController _snController;
|
||||
final TextEditingController _remarkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snController = TextEditingController(text: widget.initialSn ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_snController.dispose();
|
||||
_remarkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final action = ref.watch(deviceManageControllerProvider);
|
||||
|
||||
// 一次性错误提示:展示后立即消费。
|
||||
ref.listen<DeviceManageState>(deviceManageControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(deviceManageControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(context, message: alert, confirmText: l10n.commonConfirm);
|
||||
});
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.deviceBindTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
||||
children: [
|
||||
CardBox(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
LabeledField(
|
||||
label: l10n.deviceBindSnLabel,
|
||||
controller: _snController,
|
||||
placeholder: l10n.deviceBindSnHint,
|
||||
keyboardType: TextInputType.text,
|
||||
),
|
||||
LabeledField(
|
||||
label: l10n.deviceBindRemarkLabel,
|
||||
controller: _remarkController,
|
||||
placeholder: l10n.deviceBindRemarkHint,
|
||||
maxLength: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 6, 22, 2),
|
||||
child: Text(
|
||||
l10n.deviceBindTip,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub, height: 1.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
PrimaryButton(
|
||||
text: l10n.deviceBindConfirm,
|
||||
loading: action.submitting,
|
||||
onPressed: () => _submit(l10n),
|
||||
),
|
||||
SecondaryButton(
|
||||
text: l10n.deviceBindScan,
|
||||
icon: CupertinoIcons.qrcode_viewfinder,
|
||||
onPressed: action.submitting ? null : () => _openScanner(l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 校验 SN 后提交绑定,成功则提示并返回上一页。
|
||||
Future<void> _submit(AppLocalizations l10n) async {
|
||||
final sn = _snController.text.trim();
|
||||
if (sn.isEmpty) {
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.deviceBindSnEmpty,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final ok = await ref.read(deviceManageControllerProvider.notifier).bind(
|
||||
sn,
|
||||
remark: _remarkController.text,
|
||||
);
|
||||
if (!ok || !mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.deviceBindSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
if (mounted) context.pop();
|
||||
}
|
||||
|
||||
/// 打开扫码页;扫描成功后把 SN 回填输入框。
|
||||
Future<void> _openScanner(AppLocalizations l10n) async {
|
||||
final code = await context.push<String>('/device/scan');
|
||||
if (code == null || code.isEmpty || !mounted) return;
|
||||
setState(() => _snController.text = code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:ttstd_family_care/core/network/error_message.dart';
|
||||
import 'package:ttstd_family_care/features/device/data/device_providers.dart';
|
||||
|
||||
/// 设备管理(绑定 / 解绑)操作状态。
|
||||
///
|
||||
/// [submitting] 表示正在请求中,用于禁用按钮并展示 loading;
|
||||
/// [alert] 为一次性提示文案,UI 展示后必须调用 [DeviceManageController.consumeAlert] 消费;
|
||||
/// [lastSuccess] 标记最近一次操作是否成功,供 UI 决定是否关闭页面。
|
||||
class DeviceManageState {
|
||||
const DeviceManageState({
|
||||
this.submitting = false,
|
||||
this.alert,
|
||||
this.lastSuccess = false,
|
||||
});
|
||||
|
||||
final bool submitting;
|
||||
final String? alert;
|
||||
final bool lastSuccess;
|
||||
|
||||
DeviceManageState copyWith({
|
||||
bool? submitting,
|
||||
String? alert,
|
||||
bool? lastSuccess,
|
||||
}) =>
|
||||
DeviceManageState(
|
||||
submitting: submitting ?? this.submitting,
|
||||
alert: alert,
|
||||
lastSuccess: lastSuccess ?? this.lastSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备绑定 / 解绑控制器。
|
||||
///
|
||||
/// 页面级控制器,**不使用** keepAlive,随页面销毁自动释放(见 AGENTS.md §4)。
|
||||
/// 操作成功后主动 `ref.invalidate(myDevicesProvider)` 刷新绑定列表。
|
||||
class DeviceManageController extends Notifier<DeviceManageState> {
|
||||
@override
|
||||
DeviceManageState build() => const DeviceManageState();
|
||||
|
||||
/// 绑定设备。[sn] 为设备序列号,[remark] 为可选备注名。
|
||||
///
|
||||
/// SN 为空等输入校验由 UI 层负责(便于使用本地化文案),此处只做兜底裁剪。
|
||||
/// 返回是否绑定成功。
|
||||
Future<bool> bind(String sn, {String? remark}) =>
|
||||
_run(() => ref.read(deviceRepositoryProvider).bindDevice(
|
||||
sn.trim(),
|
||||
remark: remark?.trim(),
|
||||
));
|
||||
|
||||
/// 解绑设备。返回是否解绑成功。
|
||||
Future<bool> unbind(String sn) =>
|
||||
_run(() => ref.read(deviceRepositoryProvider).unbindDevice(sn));
|
||||
|
||||
/// 消费一次性提示,避免重复弹窗。
|
||||
void consumeAlert() {
|
||||
if (state.alert != null) {
|
||||
state = state.copyWith(alert: null, lastSuccess: state.lastSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一执行绑定/解绑请求:处理 submitting 开关、错误文案与列表刷新。
|
||||
Future<bool> _run(Future<String?> Function() action) async {
|
||||
if (state.submitting) return false;
|
||||
state = state.copyWith(submitting: true, alert: null, lastSuccess: false);
|
||||
try {
|
||||
final error = await action();
|
||||
if (error != null) {
|
||||
state = state.copyWith(
|
||||
submitting: false,
|
||||
alert: error,
|
||||
lastSuccess: false,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// 绑定关系变化,刷新已绑定设备列表。
|
||||
ref.invalidate(myDevicesProvider);
|
||||
state = state.copyWith(submitting: false, lastSuccess: true);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// 统一文案映射,禁止直接 toString()(见 AGENTS.md §12)。
|
||||
state = state.copyWith(
|
||||
submitting: false,
|
||||
alert: userMessageOf(e),
|
||||
lastSuccess: false,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备管理控制器 Provider(页面级,不 keepAlive)。
|
||||
final deviceManageControllerProvider =
|
||||
NotifierProvider<DeviceManageController, DeviceManageState>(
|
||||
DeviceManageController.new,
|
||||
);
|
||||
252
lib/features/device/presentation/device_manage_page.dart
Normal file
252
lib/features/device/presentation/device_manage_page.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/widgets/feature_ui.dart';
|
||||
import '../../../core/widgets/settings_ui.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../data/device_providers.dart';
|
||||
import '../domain/device_models.dart';
|
||||
import 'device_manage_controller.dart';
|
||||
|
||||
/// 设备管理页(我的 → 绑定设备 → 管理)。
|
||||
///
|
||||
/// 列出当前账号已绑定的全部设备,每项尾部提供「解绑」按钮;
|
||||
/// 右上角提供「添加设备」入口跳转到绑定页。
|
||||
class DeviceManagePage extends ConsumerWidget {
|
||||
const DeviceManagePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final devicesAsync = ref.watch(myDevicesProvider);
|
||||
final action = ref.watch(deviceManageControllerProvider);
|
||||
|
||||
// 一次性提示:展示后立即消费,避免重建时重复弹窗。
|
||||
ref.listen<DeviceManageState>(deviceManageControllerProvider, (prev, next) {
|
||||
final alert = next.alert;
|
||||
if (alert == null) return;
|
||||
ref.read(deviceManageControllerProvider.notifier).consumeAlert();
|
||||
AppDialogs.alert(
|
||||
context,
|
||||
message: alert,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
});
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/profile');
|
||||
}
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(l10n.deviceManageTitle),
|
||||
leading: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
child: const Icon(CupertinoIcons.add, size: 26),
|
||||
onPressed: () => context.push('/device/bind'),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
SectionTitle(l10n.deviceBound),
|
||||
devicesAsync.when(
|
||||
loading: () => const CardBox(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(child: CupertinoActivityIndicator()),
|
||||
),
|
||||
),
|
||||
error: (e, _) => CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.deviceLoadFailed,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 局部错误:内联展示 + retry(见 AGENTS.md §12)。
|
||||
GestureDetector(
|
||||
onTap: () => ref.invalidate(myDevicesProvider),
|
||||
child: Text(
|
||||
l10n.commonRetry,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (devices) => devices.isEmpty
|
||||
? CardBox(
|
||||
child: Text(
|
||||
l10n.deviceNone,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.sub),
|
||||
),
|
||||
)
|
||||
: CardBox(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < devices.length; i++) ...[
|
||||
if (i > 0) const AppDivider(),
|
||||
_DeviceRow(
|
||||
device: devices[i],
|
||||
onUnbind: () =>
|
||||
_confirmUnbind(context, ref, devices[i], l10n),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SecondaryButton(
|
||||
text: l10n.deviceManageAdd,
|
||||
icon: CupertinoIcons.add_circled,
|
||||
onPressed: () => context.push('/device/bind'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 解绑请求进行中:全屏蒙层拦截重复点击。
|
||||
if (action.submitting)
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x33000000),
|
||||
child: Center(child: CupertinoActivityIndicator(radius: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 解绑二次确认,确认后调用控制器并提示结果。
|
||||
Future<void> _confirmUnbind(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
DeviceBrief device,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
final name = device.snName?.isNotEmpty == true ? device.snName! : device.serialno;
|
||||
final confirmed = await AppDialogs.confirm(
|
||||
context,
|
||||
title: l10n.deviceUnbindConfirmTitle,
|
||||
message: l10n.deviceUnbindConfirmMessage(name),
|
||||
confirmText: l10n.deviceUnbind,
|
||||
cancelText: l10n.commonCancel,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
final ok = await ref
|
||||
.read(deviceManageControllerProvider.notifier)
|
||||
.unbind(device.serialno);
|
||||
if (!ok || !context.mounted) return;
|
||||
await AppDialogs.alert(
|
||||
context,
|
||||
message: l10n.deviceUnbindSuccess,
|
||||
confirmText: l10n.commonConfirm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 设备列表项:左侧图标 + 名称/SN,右侧解绑按钮。
|
||||
class _DeviceRow extends StatelessWidget {
|
||||
const _DeviceRow({required this.device, required this.onUnbind});
|
||||
|
||||
final DeviceBrief device;
|
||||
final VoidCallback onUnbind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final title =
|
||||
device.snName?.isNotEmpty == true ? device.snName! : device.serialno;
|
||||
final subtitle = device.snModel?.isNotEmpty == true
|
||||
? '${device.serialno} · ${device.snModel}'
|
||||
: device.serialno;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: const Center(child: Text('📱', style: TextStyle(fontSize: 17))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onUnbind,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.red.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.deviceUnbind,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
228
lib/features/device/presentation/device_scan_page.dart
Normal file
228
lib/features/device/presentation/device_scan_page.dart
Normal file
@@ -0,0 +1,228 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user