## UI 配色 - 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼 - app_colors / app_design_tokens / app_theme: 配色体系微调 - 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等) ## 登录页品牌升级 - 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent - login_page 重构, 接入新品牌视觉 - app.dart 启动流程调整 ## iOS / Android 配置 - Info.plist: 权限描述调整 - AppIcon / LaunchImage: 资源更新 - AndroidManifest: 权限微调 ## 隐私文案修订 - privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述 - terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄 ## 通知中心 - notification_center_page: 重构通知项布局 ## 其他 - 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system) - 新增 native_navigation_test - 新增 HANDOFF 交接文档 - home_message_order_test 更新 - .gitignore 调整
582 lines
18 KiB
Dart
582 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Platform;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import '../../core/app_colors.dart';
|
|
import '../../core/app_design_tokens.dart';
|
|
import '../../core/app_theme.dart';
|
|
import '../../core/navigation_provider.dart';
|
|
import '../../models/ble_device.dart';
|
|
import '../../models/bp_reading.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/omron_device_provider.dart';
|
|
import '../../services/health_ble_service.dart';
|
|
import '../../widgets/app_toast.dart';
|
|
import '../../widgets/ble_sync_dialog.dart';
|
|
|
|
class DeviceScanPage extends ConsumerStatefulWidget {
|
|
const DeviceScanPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
|
}
|
|
|
|
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|
with SingleTickerProviderStateMixin {
|
|
static const _visibleDeviceWindow = Duration(seconds: 12);
|
|
|
|
final _results = <ScanResult>[];
|
|
StreamSubscription<List<ScanResult>>? _scanSub;
|
|
Timer? _scanStopTimer;
|
|
Timer? _resultPruneTimer;
|
|
String? _connectingId;
|
|
DateTime? _scanStartedAt;
|
|
bool _scanning = false;
|
|
|
|
late final AnimationController _pulseCtrl;
|
|
late final Animation<double> _pulseAnim;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_pulseCtrl = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1200),
|
|
)..repeat(reverse: true);
|
|
_pulseAnim = Tween(
|
|
begin: 0.82,
|
|
end: 1.36,
|
|
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
|
|
|
unawaited(_startScan());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pulseCtrl.dispose();
|
|
_scanStopTimer?.cancel();
|
|
_resultPruneTimer?.cancel();
|
|
_scanSub?.cancel();
|
|
unawaited(FlutterBluePlus.stopScan());
|
|
unawaited(ref.read(healthBleServiceProvider).disconnect());
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _startScan() async {
|
|
setState(() {
|
|
_scanning = true;
|
|
_connectingId = null;
|
|
_results.clear();
|
|
});
|
|
|
|
if (!await _ensureBlePermissions()) {
|
|
if (mounted) setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
|
|
if (Platform.isAndroid) {
|
|
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
|
AppToast.show(
|
|
context,
|
|
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
|
type: AppToastType.warning,
|
|
);
|
|
}
|
|
}
|
|
|
|
final adapterState = await FlutterBluePlus.adapterState.first;
|
|
if (adapterState != BluetoothAdapterState.on) {
|
|
try {
|
|
await FlutterBluePlus.turnOn();
|
|
} catch (_) {}
|
|
}
|
|
|
|
try {
|
|
await FlutterBluePlus.stopScan();
|
|
} catch (_) {}
|
|
_scanStartedAt = DateTime.now();
|
|
await _scanSub?.cancel();
|
|
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
|
await FlutterBluePlus.startScan(
|
|
timeout: const Duration(seconds: 30),
|
|
androidScanMode: AndroidScanMode.lowLatency,
|
|
oneByOne: true,
|
|
);
|
|
|
|
_scanStopTimer?.cancel();
|
|
_scanStopTimer = Timer(const Duration(seconds: 30), () {
|
|
if (mounted) setState(() => _scanning = false);
|
|
});
|
|
_resultPruneTimer?.cancel();
|
|
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (!mounted || _connectingId != null) return;
|
|
final pruned = _visibleResults(_results);
|
|
if (_sameResults(_results, pruned)) return;
|
|
setState(() {
|
|
_results
|
|
..clear()
|
|
..addAll(pruned);
|
|
});
|
|
});
|
|
}
|
|
|
|
void _onScanResults(List<ScanResult> list) {
|
|
if (!mounted || _connectingId != null) return;
|
|
final scanStartedAt = _scanStartedAt;
|
|
if (scanStartedAt == null) return;
|
|
final boundDevices = ref.read(omronDeviceProvider).devices;
|
|
final supported = list.where((result) {
|
|
if (result.timeStamp.isBefore(scanStartedAt)) return false;
|
|
if (!result.advertisementData.connectable) return false;
|
|
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
|
return false;
|
|
}
|
|
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
|
|
return boundDevices.every(
|
|
(device) => device.id != result.device.remoteId.toString(),
|
|
);
|
|
});
|
|
|
|
final next = [..._results];
|
|
for (final result in supported) {
|
|
final index = next.indexWhere(
|
|
(item) => item.device.remoteId == result.device.remoteId,
|
|
);
|
|
if (index >= 0) {
|
|
next[index] = result;
|
|
} else {
|
|
next.add(result);
|
|
}
|
|
}
|
|
final visible = _visibleResults(next);
|
|
if (_sameResults(_results, visible)) return;
|
|
setState(() {
|
|
_results
|
|
..clear()
|
|
..addAll(visible);
|
|
});
|
|
}
|
|
|
|
List<ScanResult> _visibleResults(List<ScanResult> results) {
|
|
final visible =
|
|
results
|
|
.where(
|
|
(result) =>
|
|
DateTime.now().difference(result.timeStamp) <=
|
|
_visibleDeviceWindow,
|
|
)
|
|
.toList()
|
|
..sort(
|
|
(a, b) => a.device.remoteId.toString().compareTo(
|
|
b.device.remoteId.toString(),
|
|
),
|
|
);
|
|
return visible;
|
|
}
|
|
|
|
Future<void> _connectBindAndSync(ScanResult result) async {
|
|
final remoteId = result.device.remoteId.toString();
|
|
if (_connectingId != null) return;
|
|
final scanStartedAt = _scanStartedAt;
|
|
if (scanStartedAt == null ||
|
|
result.timeStamp.isBefore(scanStartedAt) ||
|
|
!result.advertisementData.connectable ||
|
|
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
|
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
|
|
return;
|
|
}
|
|
if (ref.read(omronDeviceProvider).isBound &&
|
|
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
|
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
|
|
return;
|
|
}
|
|
setState(() => _connectingId = remoteId);
|
|
await FlutterBluePlus.stopScan();
|
|
|
|
final ble = ref.read(healthBleServiceProvider);
|
|
BoundBleDevice? boundDevice;
|
|
try {
|
|
boundDevice = await ble
|
|
.connectAndIdentify(
|
|
device: result.device,
|
|
name: _deviceNameOf(result),
|
|
)
|
|
.timeout(const Duration(seconds: 15));
|
|
if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
|
|
if (mounted) {
|
|
AppToast.show(
|
|
context,
|
|
'${boundDevice.type.label}数据同步暂未开通,暂不绑定',
|
|
type: AppToastType.info,
|
|
);
|
|
unawaited(_startScan());
|
|
}
|
|
return;
|
|
}
|
|
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
|
|
|
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
|
final syncResult = await ble.syncBoundDevice(
|
|
result.device,
|
|
boundDevice,
|
|
timeout: const Duration(seconds: 30),
|
|
);
|
|
if (syncResult is BloodPressureSyncResult) {
|
|
final saved = await _persistBloodPressure(
|
|
syncResult.device,
|
|
syncResult.reading,
|
|
);
|
|
if (mounted && saved) {
|
|
await _showBloodPressureDialog(syncResult.reading);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mounted) popRoute(ref);
|
|
} on TimeoutException {
|
|
if (mounted && boundDevice != null) {
|
|
AppToast.show(
|
|
context,
|
|
'${boundDevice.type.label}已绑定,但本次未收到数据',
|
|
type: AppToastType.warning,
|
|
);
|
|
popRoute(ref);
|
|
} else if (mounted) {
|
|
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
|
|
unawaited(_startScan());
|
|
}
|
|
} on UnsupportedBleDeviceException catch (e) {
|
|
if (mounted) {
|
|
AppToast.show(context, e.message, type: AppToastType.error);
|
|
unawaited(_startScan());
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
|
|
unawaited(_startScan());
|
|
}
|
|
} finally {
|
|
await ble.disconnect();
|
|
if (mounted) setState(() => _connectingId = null);
|
|
}
|
|
}
|
|
|
|
Future<bool> _persistBloodPressure(
|
|
BoundBleDevice device,
|
|
BpReading reading,
|
|
) async {
|
|
final notifier = ref.read(omronDeviceProvider.notifier);
|
|
if (await notifier.isDuplicateReading(device, reading)) return false;
|
|
|
|
final api = ref.read(apiClientProvider);
|
|
await api.post('/api/health-records', data: reading.toHealthRecord());
|
|
final heartRateRecord = reading.toHeartRateRecord();
|
|
if (heartRateRecord != null) {
|
|
await api.post('/api/health-records', data: heartRateRecord);
|
|
}
|
|
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
|
return true;
|
|
}
|
|
|
|
Future<void> _showBloodPressureDialog(BpReading reading) {
|
|
return showBleSyncDialog(context, reading: reading);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GradientScaffold(
|
|
appBar: AppBar(
|
|
title: const Text(
|
|
'新增设备',
|
|
style: TextStyle(color: AppColors.textPrimary),
|
|
),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
|
onPressed: () => popRoute(ref),
|
|
),
|
|
),
|
|
body: _results.isEmpty
|
|
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
|
|
: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: AppRadius.lgBorder,
|
|
child: ColoredBox(
|
|
color: Colors.white,
|
|
child: Column(
|
|
children: [
|
|
for (var index = 0; index < _results.length; index++)
|
|
_DeviceResultTile(
|
|
result: _results[index],
|
|
connecting:
|
|
_connectingId ==
|
|
_results[index].device.remoteId.toString(),
|
|
showDivider: index < _results.length - 1,
|
|
onConnect: () =>
|
|
_connectBindAndSync(_results[index]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _deviceNameOf(ScanResult result) {
|
|
final advName = result.advertisementData.advName.trim();
|
|
if (advName.isNotEmpty) return advName;
|
|
final platformName = result.device.platformName.trim();
|
|
if (platformName.isNotEmpty) return platformName;
|
|
return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
|
|
}
|
|
|
|
bool _sameResults(List<ScanResult> current, List<ScanResult> next) {
|
|
if (current.length != next.length) return false;
|
|
for (var i = 0; i < current.length; i++) {
|
|
if (current[i].device.remoteId != next[i].device.remoteId) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<bool> _ensureBlePermissions() async {
|
|
final statuses = await [
|
|
Permission.bluetoothScan,
|
|
Permission.bluetoothConnect,
|
|
].request();
|
|
final granted = statuses.values.every((status) => status.isGranted);
|
|
if (granted) return true;
|
|
if (!mounted) return false;
|
|
AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('需要蓝牙权限'),
|
|
content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('稍后再说'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(ctx);
|
|
openAppSettings();
|
|
},
|
|
child: const Text('去设置'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class _ScanningEmpty extends StatelessWidget {
|
|
final Animation<double> animation;
|
|
final bool scanning;
|
|
|
|
const _ScanningEmpty({required this.animation, required this.scanning});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(30),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_ScanPulse(animation: animation),
|
|
const SizedBox(height: 22),
|
|
Text(
|
|
scanning ? '正在搜索设备' : '暂未发现设备',
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
const Text(
|
|
'请让设备进入通信状态并靠近手机。',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
height: 1.45,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DeviceResultTile extends StatelessWidget {
|
|
final ScanResult result;
|
|
final bool connecting;
|
|
final bool showDivider;
|
|
final VoidCallback onConnect;
|
|
|
|
const _DeviceResultTile({
|
|
required this.result,
|
|
required this.connecting,
|
|
required this.showDivider,
|
|
required this.onConnect,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final type = HealthBleService.deviceTypeFromScan(result);
|
|
final name = _deviceNameOf(result, type);
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 14),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 42,
|
|
height: 42,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.deviceLight,
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
child: Icon(
|
|
type?.icon ?? Icons.bluetooth_rounded,
|
|
color: AppColors.device,
|
|
size: 25,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Container(
|
|
constraints: const BoxConstraints(minHeight: 70),
|
|
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
|
|
decoration: BoxDecoration(
|
|
border: showDivider
|
|
? const Border(
|
|
bottom: BorderSide(
|
|
color: AppColors.divider,
|
|
width: 0.7,
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
type?.label ?? '健康设备',
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textHint,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
SizedBox(
|
|
height: 36,
|
|
child: OutlinedButton(
|
|
onPressed: connecting ? null : onConnect,
|
|
style: OutlinedButton.styleFrom(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: AppColors.device,
|
|
disabledForegroundColor: AppColors.textHint,
|
|
side: BorderSide(
|
|
color: connecting
|
|
? AppColors.borderLight
|
|
: AppColors.device,
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 13),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: AppRadius.smBorder,
|
|
),
|
|
textStyle: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
child: Text(connecting ? '连接中' : '连接'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _deviceNameOf(ScanResult result, BleDeviceType? type) {
|
|
final advName = result.advertisementData.advName.trim();
|
|
if (advName.isNotEmpty) return advName;
|
|
final platformName = result.device.platformName.trim();
|
|
if (platformName.isNotEmpty) return platformName;
|
|
return type?.label ?? '健康设备';
|
|
}
|
|
}
|
|
|
|
class _ScanPulse extends StatelessWidget {
|
|
final Animation<double> animation;
|
|
|
|
const _ScanPulse({required this.animation});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) => Container(
|
|
width: 78 * animation.value,
|
|
height: 78 * animation.value,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: AppColors.device.withValues(alpha: 0.08),
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
width: 64,
|
|
height: 64,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: AppRadius.xlBorder,
|
|
border: Border.all(color: AppColors.borderLight),
|
|
),
|
|
child: const Icon(
|
|
Icons.bluetooth_searching_rounded,
|
|
size: 32,
|
|
color: AppColors.device,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|