- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast - 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心 - 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles - 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
793 lines
24 KiB
Dart
793 lines
24 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_module_visuals.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/ble_sync_dialog.dart';
|
|
import '../../widgets/app_toast.dart';
|
|
|
|
const _deviceVisual = AppModuleVisuals.device;
|
|
|
|
class DeviceManagementPage extends ConsumerStatefulWidget {
|
|
const DeviceManagementPage({super.key});
|
|
|
|
@override
|
|
ConsumerState<DeviceManagementPage> createState() =>
|
|
_DeviceManagementPageState();
|
|
}
|
|
|
|
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|
with SingleTickerProviderStateMixin {
|
|
static const _freshScanWindow = Duration(seconds: 2);
|
|
static const _sameDeviceRetryWindow = Duration(seconds: 3);
|
|
|
|
StreamSubscription<List<ScanResult>>? _scanSub;
|
|
late final HealthBleService _bleService;
|
|
late final AnimationController _scanCtrl;
|
|
late final Animation<double> _scanAnim;
|
|
final _recentAttempts = <String, DateTime>{};
|
|
String? _busyDeviceId;
|
|
String? _connectedDeviceId;
|
|
DateTime? _scanStartedAt;
|
|
String _activeScanBoundKey = '';
|
|
bool _scanning = false;
|
|
bool _permissionBlocked = false;
|
|
bool _disposed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_bleService = ref.read(healthBleServiceProvider);
|
|
_scanCtrl = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1400),
|
|
)..repeat(reverse: true);
|
|
_scanAnim = Tween(
|
|
begin: 0.85,
|
|
end: 1.35,
|
|
).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_disposed = true;
|
|
_scanSub?.cancel();
|
|
_scanCtrl.dispose();
|
|
unawaited(FlutterBluePlus.stopScan());
|
|
unawaited(_bleService.disconnect());
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _startForegroundScan() async {
|
|
if (!await _ensureBlePermissions()) {
|
|
if (mounted) setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
if (_disposed || !mounted) return;
|
|
|
|
if (Platform.isAndroid) {
|
|
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
|
if (_disposed || !mounted) return;
|
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
|
AppToast.show(
|
|
context,
|
|
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
|
type: AppToastType.warning,
|
|
);
|
|
}
|
|
}
|
|
|
|
final adapterState = await FlutterBluePlus.adapterState.first;
|
|
if (_disposed || !mounted) return;
|
|
if (adapterState != BluetoothAdapterState.on) {
|
|
try {
|
|
await FlutterBluePlus.turnOn();
|
|
} catch (_) {}
|
|
}
|
|
|
|
try {
|
|
await FlutterBluePlus.stopScan();
|
|
} catch (_) {}
|
|
if (_disposed || !mounted) return;
|
|
final boundIds = ref
|
|
.read(omronDeviceProvider)
|
|
.devices
|
|
.map((device) => device.id)
|
|
.toList();
|
|
if (boundIds.isEmpty) {
|
|
_activeScanBoundKey = '';
|
|
if (mounted) setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
_activeScanBoundKey = _boundIdsKey(boundIds);
|
|
_scanStartedAt = DateTime.now();
|
|
await _scanSub?.cancel();
|
|
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
|
try {
|
|
await FlutterBluePlus.startScan(
|
|
withRemoteIds: boundIds,
|
|
androidScanMode: AndroidScanMode.lowLatency,
|
|
oneByOne: true,
|
|
);
|
|
if (mounted) setState(() => _scanning = true);
|
|
} catch (e) {
|
|
if (mounted) setState(() => _scanning = false);
|
|
}
|
|
}
|
|
|
|
Future<bool> _ensureBlePermissions() async {
|
|
final statuses = await [
|
|
Permission.bluetoothScan,
|
|
Permission.bluetoothConnect,
|
|
].request();
|
|
final granted = statuses.values.every((status) => status.isGranted);
|
|
if (granted) {
|
|
_permissionBlocked = false;
|
|
return true;
|
|
}
|
|
_permissionBlocked = true;
|
|
if (_disposed || !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;
|
|
}
|
|
|
|
void _onScanResults(List<ScanResult> results) {
|
|
if (!mounted || _busyDeviceId != null) return;
|
|
final scanStartedAt = _scanStartedAt;
|
|
if (scanStartedAt == null) return;
|
|
final boundDevices = ref.read(omronDeviceProvider).devices;
|
|
if (boundDevices.isEmpty) return;
|
|
|
|
final candidates = results.where((result) {
|
|
final id = result.device.remoteId.toString();
|
|
if (result.timeStamp.isBefore(scanStartedAt)) return false;
|
|
if (!result.advertisementData.connectable) return false;
|
|
if (DateTime.now().difference(result.timeStamp) > _freshScanWindow) {
|
|
return false;
|
|
}
|
|
final boundDevice = ref.read(omronDeviceProvider).findById(id);
|
|
if (boundDevice == null) return false;
|
|
if (!_isSameBoundDeviceName(result, boundDevice)) return false;
|
|
final scanType = HealthBleService.deviceTypeFromScan(result);
|
|
if (scanType != null && scanType != boundDevice.type) return false;
|
|
final lastAttempt = _recentAttempts[id];
|
|
if (lastAttempt == null) return true;
|
|
return DateTime.now().difference(lastAttempt) > _sameDeviceRetryWindow;
|
|
}).toList()..sort((a, b) => b.rssi.compareTo(a.rssi));
|
|
|
|
if (candidates.isEmpty) return;
|
|
final result = candidates.first;
|
|
final bound = ref
|
|
.read(omronDeviceProvider)
|
|
.findById(result.device.remoteId.toString());
|
|
if (bound == null) return;
|
|
unawaited(_syncDevice(result, bound));
|
|
}
|
|
|
|
bool _isSameBoundDeviceName(ScanResult result, BoundBleDevice bound) {
|
|
final scannedName = _scanDeviceName(result);
|
|
if (scannedName.isEmpty) return false;
|
|
return _normalizeDeviceName(scannedName) ==
|
|
_normalizeDeviceName(bound.name);
|
|
}
|
|
|
|
String _scanDeviceName(ScanResult result) {
|
|
final advName = result.advertisementData.advName.trim();
|
|
if (advName.isNotEmpty) return advName;
|
|
return result.device.platformName.trim();
|
|
}
|
|
|
|
String _normalizeDeviceName(String value) {
|
|
return value.trim().toUpperCase();
|
|
}
|
|
|
|
String _boundIdsKey(List<String> ids) {
|
|
final sorted = [...ids]..sort();
|
|
return sorted.join('|');
|
|
}
|
|
|
|
Future<void> _syncDevice(ScanResult result, BoundBleDevice bound) async {
|
|
if (_disposed || !mounted || _busyDeviceId != null) return;
|
|
_recentAttempts[bound.id] = DateTime.now();
|
|
setState(() {
|
|
_busyDeviceId = bound.id;
|
|
_connectedDeviceId = null;
|
|
});
|
|
|
|
await FlutterBluePlus.stopScan();
|
|
if (_disposed || !mounted) return;
|
|
setState(() => _scanning = false);
|
|
|
|
try {
|
|
final syncResult = await _bleService.syncBoundDevice(
|
|
result.device,
|
|
bound,
|
|
timeout: const Duration(seconds: 35),
|
|
onConnected: () {
|
|
if (!_disposed && mounted) {
|
|
setState(() => _connectedDeviceId = bound.id);
|
|
}
|
|
},
|
|
onMeasurementReceived: () {
|
|
// Measurement has arrived; parsing and persistence happen below.
|
|
},
|
|
);
|
|
if (syncResult is BloodPressureSyncResult) {
|
|
if (_disposed || !mounted) return;
|
|
final saved = await _persistBloodPressure(
|
|
syncResult.device,
|
|
syncResult.reading,
|
|
);
|
|
if (!_disposed && mounted && saved) {
|
|
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);
|
|
}
|
|
} finally {
|
|
await _bleService.disconnect();
|
|
if (!_disposed && mounted) {
|
|
setState(() {
|
|
_busyDeviceId = null;
|
|
_connectedDeviceId = null;
|
|
});
|
|
}
|
|
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
|
}
|
|
}
|
|
|
|
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) {
|
|
final state = ref.watch(omronDeviceProvider);
|
|
final devices = state.devices;
|
|
final connected = _connectedDeviceId != null;
|
|
final boundKey = _boundIdsKey(devices.map((device) => device.id).toList());
|
|
|
|
if (devices.isNotEmpty &&
|
|
!_scanning &&
|
|
_busyDeviceId == null &&
|
|
!_permissionBlocked &&
|
|
_activeScanBoundKey != boundKey) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
|
});
|
|
}
|
|
|
|
ref.listen<DeviceBindState>(omronDeviceProvider, (prev, next) {
|
|
final nextIds = next.devices.map((device) => device.id).toList();
|
|
final nextKey = _boundIdsKey(nextIds);
|
|
if (nextIds.isEmpty) {
|
|
_activeScanBoundKey = '';
|
|
unawaited(FlutterBluePlus.stopScan());
|
|
if (!_disposed && mounted) setState(() => _scanning = false);
|
|
return;
|
|
}
|
|
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
|
|
if (_permissionBlocked) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
|
});
|
|
}
|
|
});
|
|
|
|
return GradientScaffold(
|
|
appBar: AppBar(
|
|
title: const Text('蓝牙设备'),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => popRoute(ref),
|
|
),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 10),
|
|
child: IconButton(
|
|
tooltip: '新增设备',
|
|
onPressed: () => pushRoute(ref, 'deviceScan'),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: _deviceVisual.lightColor,
|
|
foregroundColor: _deviceVisual.color,
|
|
fixedSize: const Size(40, 40),
|
|
),
|
|
icon: const Icon(Icons.add_rounded),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
ListView(
|
|
padding: const EdgeInsets.fromLTRB(18, 10, 18, 120),
|
|
children: [
|
|
_DevicesHeader(deviceCount: devices.length),
|
|
const SizedBox(height: 18),
|
|
if (devices.isEmpty)
|
|
const _EmptyDevices()
|
|
else
|
|
for (final type in BleDeviceType.values)
|
|
if (state.devicesOfType(type).isNotEmpty) ...[
|
|
_DeviceSection(
|
|
type: type,
|
|
devices: state.devicesOfType(type),
|
|
connectedDeviceId: _connectedDeviceId,
|
|
busyDeviceId: _busyDeviceId,
|
|
onUnbind: _confirmUnbind,
|
|
),
|
|
const SizedBox(height: 18),
|
|
],
|
|
],
|
|
),
|
|
if (devices.isNotEmpty)
|
|
Positioned(
|
|
right: 18,
|
|
bottom: 20,
|
|
child: _ScanIndicator(
|
|
animation: _scanAnim,
|
|
scanning: _scanning,
|
|
syncing: connected,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmUnbind(BoundBleDevice device) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
title: const Text('解绑设备'),
|
|
content: Text('确定解绑这台${device.type.label}吗?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
|
child: const Text('解绑'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (ok == true) {
|
|
await ref.read(omronDeviceProvider.notifier).unbind(device.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
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: const Color(0xFFEFF6FF),
|
|
borderRadius: BorderRadius.circular(22),
|
|
),
|
|
child: const Icon(
|
|
Icons.bluetooth_disabled_rounded,
|
|
color: Color(0xFF2563EB),
|
|
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;
|
|
final bool syncing;
|
|
|
|
const _ScanIndicator({
|
|
required this.animation,
|
|
required this.scanning,
|
|
required this.syncing,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final active = scanning || syncing;
|
|
return SizedBox(
|
|
width: 88,
|
|
height: 88,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
if (active) ...[
|
|
AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) => Container(
|
|
width: 78 * animation.value,
|
|
height: 78 * animation.value,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
|
|
),
|
|
),
|
|
),
|
|
AnimatedBuilder(
|
|
animation: animation,
|
|
builder: (context, child) => Container(
|
|
width: 64 * animation.value,
|
|
height: 64 * animation.value,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.18),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
Container(
|
|
width: 58,
|
|
height: 58,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFF2563EB),
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF2563EB).withValues(alpha: 0.24),
|
|
blurRadius: 18,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Icon(
|
|
syncing
|
|
? Icons.bluetooth_connected_rounded
|
|
: Icons.bluetooth_searching_rounded,
|
|
color: Colors.white,
|
|
size: 28,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|