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:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

@@ -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('重试')),
],
),
);
}