Files
AI-Health/health_app/lib/pages/device/device_scan_page.dart
MingNian c70d5d4be6 fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
2026-06-16 15:16:44 +08:00

542 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart';
import '../../services/omron_ble_service.dart';
class DeviceScanPage extends ConsumerStatefulWidget {
const DeviceScanPage({super.key});
@override
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
final _results = <ScanResult>[];
bool _scanning = false;
String? _connectingId;
StreamSubscription? _scanSub;
bool _connected = false;
String _deviceName = '';
bool _readingReceived = false;
int? _systolic, _diastolic, _pulse;
StreamSubscription? _readingSub;
StreamSubscription? _bleConnSub;
late AnimationController _pulseCtrl;
late Animation<double> _pulseAnim;
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_pulseAnim = Tween(
begin: 0.8,
end: 1.4,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
_pulseCtrl.repeat(reverse: true);
_scanSub = FlutterBluePlus.scanResults.listen((list) {
if (!mounted) return;
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
setState(() {
_results.clear();
_results.addAll(bpList);
});
});
_start();
}
@override
void dispose() {
_pulseCtrl.dispose();
_scanSub?.cancel();
_readingSub?.cancel();
_bleConnSub?.cancel();
FlutterBluePlus.stopScan();
super.dispose();
}
Future<void> _start() async {
setState(() {
_scanning = true;
_connected = false;
});
_results.clear();
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
if (Platform.isAndroid) {
final locStatus = await Permission.locationWhenInUse.serviceStatus;
if (locStatus != ServiceStatus.enabled && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开GPS定位否则无法扫描蓝牙'),
backgroundColor: AppColors.warning,
),
);
}
}
final adapterState = await FlutterBluePlus.adapterState.first;
if (adapterState != BluetoothAdapterState.on) {
try {
await FlutterBluePlus.turnOn();
} catch (_) {}
}
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency,
);
Future.delayed(const Duration(seconds: 30), () {
FlutterBluePlus.stopScan();
if (mounted) setState(() => _scanning = false);
});
}
Future<void> _connect(ScanResult r) async {
setState(() => _connectingId = r.device.remoteId.toString());
FlutterBluePlus.stopScan();
_scanSub?.cancel();
try {
final ble = ref.read(omronBleServiceProvider);
await ble.connect(r.device);
final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
await ref
.read(omronDeviceProvider.notifier)
.bind(r.device.remoteId.toString(), name);
_bleConnSub = r.device.connectionState.listen((s) {
if (s == BluetoothConnectionState.disconnected && mounted) {
_readingSub?.cancel();
ref.read(omronBleServiceProvider).disconnect();
if (_readingReceived) {
popRoute(ref);
} else {
setState(() {
_connected = false;
});
_start();
}
}
});
_readingSub = ble.readings.listen((reading) async {
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
if (mounted) {
setState(() {
_readingReceived = true;
_systolic = reading.systolic;
_diastolic = reading.diastolic;
_pulse = reading.pulse;
});
try {
await ref
.read(apiClientProvider)
.post('/api/health-records', data: reading.toHealthRecord());
await ref.read(omronDeviceProvider.notifier).onReading(reading);
} catch (e) {
debugPrint('[PAGE] 上报失败: $e');
}
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) popRoute(ref);
});
}
});
if (mounted) {
setState(() {
_connected = true;
_deviceName = name;
_connectingId = null;
_readingReceived = false;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error),
);
_start();
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: Text(
_connected ? _deviceName : '添加设备',
style: const TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () {
if (_connected) ref.read(omronBleServiceProvider).disconnect();
popRoute(ref);
},
),
),
body: _connected ? _buildConnected() : _buildScan(),
);
}
// ── 扫描视图 ──
Widget _buildScan() => Column(
children: [
if (_scanning && _results.isEmpty)
Expanded(child: _buildScanningCenter()),
if (!_scanning && _results.isEmpty)
Expanded(child: _buildScanningCenter()),
if (_results.isNotEmpty)
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _results.length,
itemBuilder: (_, i) => _buildTile(_results[i]),
),
),
if (!_scanning)
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _start,
icon: const Icon(Icons.refresh),
label: const Text('重新扫描'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
),
],
);
Widget _buildScanningCenter() => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) => Container(
width: 80 * _pulseAnim.value,
height: 80 * _pulseAnim.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withValues(alpha: 0.08),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.avatarBg,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.bluetooth_searching,
size: 32,
color: AppColors.primary,
),
),
],
),
const SizedBox(height: 20),
Text(
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
),
const SizedBox(height: 8),
Text(
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
),
],
),
);
// ── 已连接视图 ──
Widget _buildConnected() => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 500),
width: 100,
height: 100,
decoration: BoxDecoration(
color: _readingReceived
? AppColors.successLight
: AppColors.avatarBg,
borderRadius: BorderRadius.circular(32),
),
child: Icon(
_readingReceived
? Icons.check_rounded
: Icons.bluetooth_connected,
size: 48,
color: _readingReceived
? const Color(0xFF10B981)
: AppColors.primary,
),
),
const SizedBox(height: 24),
Text(
_readingReceived ? '测量完成' : '设备已连接',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
if (_readingReceived) ...[
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$_systolic',
style: const TextStyle(
fontSize: 56,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
'/',
style: TextStyle(
fontSize: 24,
color: AppColors.textHint.withValues(alpha: 0.5),
),
),
),
Text(
'$_diastolic',
style: const TextStyle(
fontSize: 56,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1,
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'mmHg',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
),
],
),
if (_pulse != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.infoLight,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'脉搏 $_pulse bpm',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
),
),
const SizedBox(height: 16),
const Text(
'数据已自动同步',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
const SizedBox(height: 8),
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
] else ...[
const Text(
'请按下血压计的开始键进行测量',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(height: 32),
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, __) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Transform.scale(
scale: _pulseAnim.value,
child: Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary,
),
),
),
),
),
),
],
],
),
),
);
// ── 设备列表项 ──
Widget _buildTile(ScanResult r) {
final isConnecting = _connectingId == r.device.remoteId.toString();
final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.avatarBg,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.bluetooth,
size: 22,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
),
],
),
),
if (isConnecting)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
GestureDetector(
onTap: () => _connect(r),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'连接',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
],
),
);
}
String _rssiLabel(int r) {
if (r > -55) return '';
if (r > -70) return '';
return '';
}
}