feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端 - 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法 - 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展 - 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整 - 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景 - 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展 - AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理 - Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整 - BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑 - 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试 ## 前端 - UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新 - 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构 - 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取 - 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化 - 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化 - 趋势图: trend_page 大幅重构 - 登录: login_page 重构 - 个人资料: 新增 profile_edit_page; profile_page 优化 - 运动: 新增 exercise/ 目录 + care_plan_ui_logic - 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整 - 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化 - Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整 - AndroidManifest: 移除多余权限 - 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试 ## 文档 - 新增 ui-design-system.md 设计系统文档 - 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
@@ -7,7 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../models/ble_device.dart';
|
||||
@@ -17,6 +19,8 @@ import '../../providers/omron_device_provider.dart';
|
||||
import '../../services/health_ble_service.dart';
|
||||
import '../../widgets/ble_sync_dialog.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/app_empty_state.dart';
|
||||
import 'device_sync_ui_logic.dart';
|
||||
|
||||
const _deviceVisual = AppModuleVisuals.device;
|
||||
|
||||
@@ -45,6 +49,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
bool _scanning = false;
|
||||
bool _permissionBlocked = false;
|
||||
bool _disposed = false;
|
||||
String? _pageMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -58,15 +63,18 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
begin: 0.85,
|
||||
end: 1.35,
|
||||
).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_startForegroundScan());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_scanSub?.cancel();
|
||||
_scanCtrl.dispose();
|
||||
unawaited(FlutterBluePlus.stopScan());
|
||||
unawaited(_bleService.disconnect());
|
||||
_scanCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -94,7 +102,19 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
if (adapterState != BluetoothAdapterState.on) {
|
||||
try {
|
||||
await FlutterBluePlus.turnOn();
|
||||
} catch (_) {}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _pageMessage = '蓝牙未开启,无法查找已绑定设备');
|
||||
AppToast.show(
|
||||
context,
|
||||
deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.bluetooth),
|
||||
),
|
||||
type: AppToastType.warning,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -104,6 +124,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
final boundIds = ref
|
||||
.read(omronDeviceProvider)
|
||||
.devices
|
||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||
.map((device) => device.id)
|
||||
.toList();
|
||||
if (boundIds.isEmpty) {
|
||||
@@ -121,23 +142,44 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
oneByOne: true,
|
||||
);
|
||||
if (mounted) setState(() => _scanning = true);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _scanning = false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scanning = true;
|
||||
_pageMessage = null;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scanning = false;
|
||||
_pageMessage = '设备查找失败,请检查蓝牙状态后重试';
|
||||
});
|
||||
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureBlePermissions() async {
|
||||
final statuses = await [
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
].request();
|
||||
Map<Permission, PermissionStatus> statuses;
|
||||
try {
|
||||
statuses = await [
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
].request();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _pageMessage = '无法读取蓝牙权限状态');
|
||||
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final granted = statuses.values.every((status) => status.isGranted);
|
||||
if (granted) {
|
||||
_permissionBlocked = false;
|
||||
return true;
|
||||
}
|
||||
_permissionBlocked = true;
|
||||
if (mounted) setState(() => _pageMessage = '需要蓝牙权限才能同步设备');
|
||||
if (_disposed || !mounted) return false;
|
||||
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
|
||||
await showDialog<void>(
|
||||
@@ -224,6 +266,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
setState(() {
|
||||
_busyDeviceId = bound.id;
|
||||
_connectedDeviceId = null;
|
||||
_pageMessage = '正在连接 ${bound.name}';
|
||||
});
|
||||
|
||||
await FlutterBluePlus.stopScan();
|
||||
@@ -237,7 +280,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
timeout: const Duration(seconds: 35),
|
||||
onConnected: () {
|
||||
if (!_disposed && mounted) {
|
||||
setState(() => _connectedDeviceId = bound.id);
|
||||
setState(() {
|
||||
_connectedDeviceId = bound.id;
|
||||
_pageMessage = '已连接,等待设备测量数据';
|
||||
});
|
||||
}
|
||||
},
|
||||
onMeasurementReceived: () {
|
||||
@@ -246,21 +292,48 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
);
|
||||
if (syncResult is BloodPressureSyncResult) {
|
||||
if (_disposed || !mounted) return;
|
||||
setState(() => _pageMessage = '已读取数据,正在安全上传');
|
||||
final saved = await _persistBloodPressure(
|
||||
syncResult.device,
|
||||
syncResult.reading,
|
||||
);
|
||||
if (!_disposed && mounted && saved) {
|
||||
setState(() => _pageMessage = '同步完成');
|
||||
await _showBloodPressureDialog(syncResult.reading);
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (!_disposed && mounted && _connectedDeviceId == bound.id) {
|
||||
AppToast.show(context, '已连接设备,但本次未收到测量数据', type: AppToastType.warning);
|
||||
}
|
||||
} on Exception {
|
||||
if (!_disposed && mounted) {
|
||||
AppToast.show(context, '数据录入失败,请稍后重试', type: AppToastType.error);
|
||||
final message = _connectedDeviceId == bound.id
|
||||
? deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.measurement),
|
||||
)
|
||||
: deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.connection),
|
||||
);
|
||||
setState(() => _pageMessage = message);
|
||||
AppToast.show(context, message, type: AppToastType.warning);
|
||||
}
|
||||
} on UnsupportedBleDeviceException catch (error) {
|
||||
if (!_disposed && mounted) {
|
||||
setState(() => _pageMessage = error.message);
|
||||
AppToast.show(context, error.message, type: AppToastType.warning);
|
||||
}
|
||||
} on ApiException catch (error) {
|
||||
if (!_disposed && mounted) {
|
||||
final message = deviceSyncErrorMessage(
|
||||
DeviceSyncFailure(DeviceSyncFailureStage.upload, error.message),
|
||||
);
|
||||
setState(() => _pageMessage = message);
|
||||
AppToast.show(context, message, type: AppToastType.error);
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
if (!_disposed && mounted) {
|
||||
final message = deviceSyncErrorMessage(
|
||||
DeviceSyncFailure(DeviceSyncFailureStage.connection, '$error'),
|
||||
);
|
||||
setState(() => _pageMessage = message);
|
||||
AppToast.show(context, message, type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
await _bleService.disconnect();
|
||||
@@ -282,11 +355,12 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
if (await notifier.isDuplicateReading(device, reading)) return false;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/health-records', data: reading.toHealthRecord());
|
||||
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
|
||||
final heartRateRecord = reading.toHeartRateRecord();
|
||||
if (heartRateRecord != null) {
|
||||
await api.post('/api/health-records', data: heartRateRecord);
|
||||
records.add(heartRateRecord);
|
||||
}
|
||||
await api.post('/api/health-records/batch', data: records);
|
||||
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||
return true;
|
||||
}
|
||||
@@ -299,10 +373,14 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(omronDeviceProvider);
|
||||
final devices = state.devices;
|
||||
final connected = _connectedDeviceId != null;
|
||||
final boundKey = _boundIdsKey(devices.map((device) => device.id).toList());
|
||||
final syncableDevices = devices
|
||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||
.toList();
|
||||
final boundKey = _boundIdsKey(
|
||||
syncableDevices.map((device) => device.id).toList(),
|
||||
);
|
||||
|
||||
if (devices.isNotEmpty &&
|
||||
if (syncableDevices.isNotEmpty &&
|
||||
!_scanning &&
|
||||
_busyDeviceId == null &&
|
||||
!_permissionBlocked &&
|
||||
@@ -313,7 +391,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
}
|
||||
|
||||
ref.listen<DeviceBindState>(omronDeviceProvider, (prev, next) {
|
||||
final nextIds = next.devices.map((device) => device.id).toList();
|
||||
final nextIds = next.devices
|
||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||
.map((device) => device.id)
|
||||
.toList();
|
||||
final nextKey = _boundIdsKey(nextIds);
|
||||
if (nextIds.isEmpty) {
|
||||
_activeScanBoundKey = '';
|
||||
@@ -343,9 +424,11 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
tooltip: '新增设备',
|
||||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: _deviceVisual.lightColor,
|
||||
foregroundColor: _deviceVisual.color,
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.primaryDark,
|
||||
fixedSize: const Size(40, 40),
|
||||
side: const BorderSide(color: AppColors.primaryLight),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
@@ -358,6 +441,20 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 120),
|
||||
children: [
|
||||
_DevicesHeader(deviceCount: devices.length),
|
||||
if (devices.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_SyncStatusBar(
|
||||
message:
|
||||
_pageMessage ??
|
||||
(syncableDevices.isEmpty
|
||||
? '当前设备已绑定,暂不支持自动同步'
|
||||
: _scanning
|
||||
? '正在等待已绑定设备的测量数据'
|
||||
: '设备同步已暂停'),
|
||||
active: _scanning || _busyDeviceId != null,
|
||||
onRetry: _busyDeviceId == null ? _startForegroundScan : null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
if (devices.isEmpty)
|
||||
const _EmptyDevices()
|
||||
@@ -382,7 +479,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
child: _ScanIndicator(
|
||||
animation: _scanAnim,
|
||||
scanning: _scanning,
|
||||
syncing: connected,
|
||||
syncing: _connectedDeviceId != null || _busyDeviceId != null,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -394,7 +491,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
|
||||
title: const Text('解绑设备'),
|
||||
content: Text('确定解绑这台${device.type.label}吗?'),
|
||||
actions: [
|
||||
@@ -404,7 +501,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
|
||||
child: const Text('解绑'),
|
||||
),
|
||||
],
|
||||
@@ -416,309 +513,6 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
}
|
||||
}
|
||||
|
||||
class _DevicesHeader extends StatelessWidget {
|
||||
final int deviceCount;
|
||||
|
||||
const _DevicesHeader({required this.deviceCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _deviceVisual.gradient,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_audio_rounded,
|
||||
color: Colors.white,
|
||||
size: 27,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'已绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$deviceCount 台设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceSection extends StatelessWidget {
|
||||
final BleDeviceType type;
|
||||
final List<BoundBleDevice> devices;
|
||||
final String? connectedDeviceId;
|
||||
final String? busyDeviceId;
|
||||
final ValueChanged<BoundBleDevice> onUnbind;
|
||||
|
||||
const _DeviceSection({
|
||||
required this.type,
|
||||
required this.devices,
|
||||
required this.connectedDeviceId,
|
||||
required this.busyDeviceId,
|
||||
required this.onUnbind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: _deviceVisual.lightColor,
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
type.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${devices.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final device in devices)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _BoundDeviceTile(
|
||||
device: device,
|
||||
connected: connectedDeviceId == device.id,
|
||||
onUnbind: () => onUnbind(device),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoundDeviceTile extends StatelessWidget {
|
||||
final BoundBleDevice device;
|
||||
final bool connected;
|
||||
final VoidCallback onUnbind;
|
||||
|
||||
const _BoundDeviceTile({
|
||||
required this.device,
|
||||
required this.connected,
|
||||
required this.onUnbind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = connected ? AppColors.success : AppColors.textHint;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: connected ? AppColors.success : AppColors.border,
|
||||
width: connected ? 1.4 : 1.1,
|
||||
),
|
||||
boxShadow: connected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.success.withValues(alpha: 0.14),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? AppColors.successLight
|
||||
: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(device.type.icon, color: accent, size: 25),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'最近同步:${_formatLastSync(device.lastSyncAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (connected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successLight,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '解绑设备',
|
||||
onPressed: connected ? null : onUnbind,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
color: connected ? AppColors.textHint : AppColors.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLastSync(DateTime? value) {
|
||||
if (value == null) return '暂无';
|
||||
final now = DateTime.now();
|
||||
final dayLabel =
|
||||
value.year == now.year &&
|
||||
value.month == now.month &&
|
||||
value.day == now.day
|
||||
? '今天'
|
||||
: '${value.month}/${value.day}';
|
||||
final minute = value.minute.toString().padLeft(2, '0');
|
||||
return '$dayLabel ${value.hour}:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyDevices extends StatelessWidget {
|
||||
const _EmptyDevices();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(24, 46, 24, 46),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deviceLight,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_disabled_rounded,
|
||||
color: AppColors.device,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'还没有绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'右上角添加设备',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanIndicator extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final bool scanning;
|
||||
@@ -771,9 +565,9 @@ class _ScanIndicator extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.device.withValues(alpha: 0.24),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
color: AppColors.device.withValues(alpha: 0.22),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 7),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -790,3 +584,339 @@ class _ScanIndicator extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DevicesHeader extends StatelessWidget {
|
||||
final int deviceCount;
|
||||
|
||||
const _DevicesHeader({required this.deviceCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _deviceVisual.lightColor,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(_deviceVisual.icon, color: AppColors.device, size: 27),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'已绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$deviceCount 台设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceSection extends StatelessWidget {
|
||||
final BleDeviceType type;
|
||||
final List<BoundBleDevice> devices;
|
||||
final String? connectedDeviceId;
|
||||
final String? busyDeviceId;
|
||||
final ValueChanged<BoundBleDevice> onUnbind;
|
||||
|
||||
const _DeviceSection({
|
||||
required this.type,
|
||||
required this.devices,
|
||||
required this.connectedDeviceId,
|
||||
required this.busyDeviceId,
|
||||
required this.onUnbind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
child: ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: _deviceVisual.lightColor,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(
|
||||
type.icon,
|
||||
size: 19,
|
||||
color: _deviceVisual.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
type.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${devices.length} 台',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var index = 0; index < devices.length; index++)
|
||||
_BoundDeviceTile(
|
||||
device: devices[index],
|
||||
connected: connectedDeviceId == devices[index].id,
|
||||
busy: busyDeviceId == devices[index].id,
|
||||
showDivider: index < devices.length - 1,
|
||||
onUnbind: () => onUnbind(devices[index]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoundDeviceTile extends StatelessWidget {
|
||||
final BoundBleDevice device;
|
||||
final bool connected;
|
||||
final bool busy;
|
||||
final bool showDivider;
|
||||
final VoidCallback onUnbind;
|
||||
|
||||
const _BoundDeviceTile({
|
||||
required this.device,
|
||||
required this.connected,
|
||||
required this.busy,
|
||||
required this.showDivider,
|
||||
required this.onUnbind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = connected ? AppColors.successText : AppColors.textHint;
|
||||
final availability = deviceSyncAvailabilityLabel(device.type);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.only(left: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? AppColors.successLight.withValues(alpha: 0.45)
|
||||
: Colors.white,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: connected ? AppColors.successLight : AppColors.deviceLight,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(device.type.icon, color: accent, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 70),
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 6, 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,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
device.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
availability ??
|
||||
(busy
|
||||
? connected
|
||||
? '已连接,正在读取数据'
|
||||
: '正在连接设备'
|
||||
: '最近同步:${_formatLastSync(device.lastSyncAt)}'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: availability == null
|
||||
? AppColors.textHint
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (connected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 9,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successLight,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.successText,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '解绑设备',
|
||||
onPressed: busy ? null : onUnbind,
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 21),
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLastSync(DateTime? value) {
|
||||
if (value == null) return '暂无';
|
||||
final now = DateTime.now();
|
||||
final dayLabel =
|
||||
value.year == now.year &&
|
||||
value.month == now.month &&
|
||||
value.day == now.day
|
||||
? '今天'
|
||||
: '${value.month}-${value.day}';
|
||||
final minute = value.minute.toString().padLeft(2, '0');
|
||||
return '$dayLabel ${value.hour}:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyDevices extends StatelessWidget {
|
||||
const _EmptyDevices();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: AppEmptyState(
|
||||
icon: _deviceVisual.icon,
|
||||
iconColor: _deviceVisual.color,
|
||||
title: '还没有绑定设备',
|
||||
subtitle: '点击右上角添加设备',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncStatusBar extends StatelessWidget {
|
||||
final String message;
|
||||
final bool active;
|
||||
final Future<void> Function()? onRetry;
|
||||
|
||||
const _SyncStatusBar({
|
||||
required this.message,
|
||||
required this.active,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.deviceLight : Colors.white,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
active ? Icons.sync_rounded : Icons.info_outline_rounded,
|
||||
size: 20,
|
||||
color: active ? AppColors.device : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!active && onRetry != null)
|
||||
TextButton(onPressed: onRetry, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user