feat: 新增设备绑定、扫码与联系人管理功能

This commit is contained in:
TongTongStudio
2026-08-21 17:12:17 +08:00
parent 06cdc77172
commit 98de1e1132
49 changed files with 5699 additions and 204 deletions

View 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);
}
}

View File

@@ -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,
);

View 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,
),
),
),
),
],
),
);
}
}

View 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,
),
),
],
),
),
),
);
}