feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线

- 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 适配; 主页智能体栏间距优化
This commit is contained in:
MingNian
2026-07-08 21:25:07 +08:00
parent 7a93237069
commit 1c020b8ae5
45 changed files with 3480 additions and 1000 deletions

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart' show adminServiceProvider;
import '../../widgets/app_toast.dart';
class AdminAddDoctorPage extends ConsumerStatefulWidget {
const AdminAddDoctorPage({super.key});
@@ -30,15 +31,11 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
Future<void> _save() async {
if (_phoneCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('手机号不能为空')));
AppToast.show(context, '手机号不能为空', type: AppToastType.warning);
return;
}
if (_nameCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('姓名不能为空')));
AppToast.show(context, '姓名不能为空', type: AppToastType.warning);
return;
}
setState(() => _saving = true);
@@ -51,19 +48,12 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
'professionalDirection': _directionCtrl.text.trim(),
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('添加成功'),
backgroundColor: AppColors.success,
),
);
AppToast.show(context, '添加成功', type: AppToastType.success);
popRoute(ref);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('添加失败: $e'), backgroundColor: AppColors.error),
);
AppToast.show(context, '添加失败: $e', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _saving = false);

View File

@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -5,6 +7,7 @@ import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
import '../../providers/data_providers.dart';
/// 健康概览趋势页 — 五大指标合到一个页面
@@ -21,6 +24,37 @@ class _TrendPageState extends ConsumerState<TrendPage> {
List<Map<String, dynamic>> _allRecords = [];
List<Map<String, dynamic>> _filtered = [];
bool _loading = true;
int? _selectedIdx; // 当前选中的数据点_filtered 索引null = 未选中
/// 计算"好看"的坐标轴步长,最小为 1保证标签都是整数
/// 目标 3 段 = 4 个标签;若吸附后超过 3 段,自动放大步长。
double _niceStep(double range) {
if (range <= 0) return 1;
final target = range / 3;
if (target < 1) return 1;
final magnitude = pow(10, (log(target) / log(10)).floor()).toDouble();
final normalized = target / magnitude;
double nice;
if (normalized <= 1) {
nice = 1;
} else if (normalized <= 2) {
nice = 2;
} else if (normalized <= 5) {
nice = 5;
} else {
nice = 10;
}
return nice * magnitude;
}
/// 步长过大时,跳到下一个"好看"的步长1→2→5→10→20→50…
double _bumpStep(double step) {
final magnitude = pow(10, (log(step) / log(10)).floor()).toDouble();
final normalized = step / magnitude;
if (normalized < 1.5) return 2 * magnitude;
if (normalized < 4) return 5 * magnitude;
return 10 * magnitude;
}
static const _metrics = [
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
@@ -127,7 +161,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}
void _switchMetric(String key) {
setState(() => _selected = key);
setState(() {
_selected = key;
_selectedIdx = null;
});
_filter();
}
@@ -231,9 +268,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('录入失败')));
AppToast.show(context, '录入失败', type: AppToastType.error);
}
},
style: ElevatedButton.styleFrom(
@@ -385,6 +420,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
maxV += padding;
}
// Y 轴:吸附到"好看"的整数刻度,标签全部为整数,最多 4 个3 段)
var yStep = _niceStep(maxV - minV);
minV = (minV / yStep).floor() * yStep;
maxV = (maxV / yStep).ceil() * yStep;
while ((maxV - minV) / yStep > 3) {
yStep = _bumpStep(yStep);
minV = (minV / yStep).floor() * yStep;
maxV = (maxV / yStep).ceil() * yStep;
}
// X 轴刻度间隔:日标签短,可以排密一点
final xInterval =
spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0;
return Container(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
decoration: BoxDecoration(
@@ -424,34 +473,62 @@ class _TrendPageState extends ConsumerState<TrendPage> {
const SizedBox(height: 10),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: range > 50
? 10
: range > 20
? 5
: range > 5
? 2
: 1,
),
child: LayoutBuilder(
builder: (context, constraints) {
final chartWidth = constraints.maxWidth;
final paintWidth = chartWidth - 40.0; // 减左侧轴
final paintHeight = 200.0 - 36.0; // 减底部轴
// 计算选中点的像素位置
Offset? tooltipPos;
String? tooltipText1;
String? tooltipText2;
if (_selectedIdx != null &&
_selectedIdx! >= 0 &&
_selectedIdx! < _filtered.length) {
final r = _filtered[_selectedIdx!];
final v = _isBP
? (r['systolic'] as num?)?.toDouble()
: (r['value'] as num?)?.toDouble();
if (v != null && spots.length > 1) {
final px = 40.0 +
(_selectedIdx! / (spots.length - 1)) * paintWidth;
final py =
paintHeight * (1 - (v - minV) / (maxV - minV));
final d = r['date'] as DateTime;
tooltipPos = Offset(px, py);
tooltipText1 =
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
tooltipText2 = _isBP
? '${r['systolic']}/${r['diastolic']} $_unit'
: '$v $_unit';
}
}
return Stack(
clipBehavior: Clip.none,
children: [
LineChart(
LineChartData(
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (v, meta) => Text(
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
interval: yStep,
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
v.toStringAsFixed(0),
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
),
),
@@ -459,24 +536,43 @@ class _TrendPageState extends ConsumerState<TrendPage> {
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: spots.length > 30
? 7
: spots.length > 14
? 3
: 1,
reservedSize: 36,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) {
return const SizedBox.shrink();
}
final d = _filtered[idx]['date'] as DateTime;
return Text(
'${d.month}/${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
// 当前可见刻度与上一个可见刻度相比,月份是否变化
final prevIdx = idx - xInterval.toInt();
final showMonth = idx == 0 ||
prevIdx < 0 ||
(_filtered[prevIdx]['date'] as DateTime).month !=
d.month;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
if (showMonth) ...[
const SizedBox(height: 2),
Text(
'${d.month}',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
],
);
},
),
@@ -491,46 +587,120 @@ class _TrendPageState extends ConsumerState<TrendPage> {
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
isCurved: false,
color: _color,
barWidth: 2.5,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 60,
getDotPainter: (spot, _, _, _) => FlDotCirclePainter(
radius: 3,
color: _color,
strokeWidth: 1,
strokeColor: Colors.white,
),
show: spots.length <= 30,
getDotPainter: (spot, _, _, _) {
final isSelected = spot.x.toInt() == _selectedIdx;
if (isSelected) {
return FlDotCirclePainter(
radius: 5.5,
color: _color,
strokeWidth: 2.5,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(
radius: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: _color,
);
},
),
belowBarData: BarAreaData(
show: true,
color: _color.withAlpha(20),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.22),
_color.withValues(alpha: 0.02),
],
),
),
shadow: Shadow(
color: _color.withValues(alpha: 0.18),
blurRadius: 6,
offset: const Offset(0, 3),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots.map((s) {
final idx = s.x.toInt();
if (idx < 0 || idx >= _filtered.length) return null;
final r = _filtered[idx];
final d = r['date'] as DateTime;
final v = _isBP
? '${r['systolic']}/${r['diastolic']}'
: '${r['value']}';
return LineTooltipItem(
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
TextStyle(
color: _color,
fontSize: 15,
fontWeight: FontWeight.w600,
handleBuiltInTouches: false,
touchCallback: (event, response) {
if (event is FlTapUpEvent) {
final spots = response?.lineBarSpots;
if (spots != null && spots.isNotEmpty) {
final idx = spots.first.x.toInt();
setState(() =>
_selectedIdx = (_selectedIdx == idx) ? null : idx);
} else {
setState(() => _selectedIdx = null);
}
}
},
),
),
duration: Duration.zero,
),
if (tooltipPos != null)
Positioned(
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
top: tooltipPos.dy - 56,
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _color.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828)
.withValues(alpha: 0.14),
blurRadius: 16,
offset: const Offset(0, 6),
),
);
}).toList(),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tooltipText1!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
tooltipText2!,
style: TextStyle(
fontSize: 15,
color: _color,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
],
);
},
),
),
],
@@ -547,7 +717,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Row(
children: [
const Text(
'历史记录',
'录入记录',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
),
const Spacer(),
@@ -565,6 +735,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
final idx = _filtered.indexOf(r);
final isSelected = idx == _selectedIdx;
String display;
if (_isBP) {
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
@@ -591,21 +763,37 @@ class _TrendPageState extends ConsumerState<TrendPage> {
setState(() {
_allRecords.removeWhere((x) => x['id']?.toString() == id);
_filtered.removeWhere((x) => x['id']?.toString() == id);
_selectedIdx = null;
});
return true;
} catch (_) {
return false;
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: GestureDetector(
onTap: () {
if (idx < 0) return;
setState(() =>
_selectedIdx = isSelected ? null : idx);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(bottom: 6),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? _color.withValues(alpha: 0.15)
: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? _color.withValues(alpha: 0.55)
: AppColors.border,
width: isSelected ? 1.5 : 1,
),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
@@ -671,6 +859,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
],
),
),
),
);
}),
],

View File

@@ -7,6 +7,7 @@ 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';
@@ -15,6 +16,9 @@ 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});
@@ -39,6 +43,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
DateTime? _scanStartedAt;
String _activeScanBoundKey = '';
bool _scanning = false;
bool _permissionBlocked = false;
bool _disposed = false;
@override
@@ -66,18 +71,20 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
}
Future<void> _startForegroundScan() async {
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
backgroundColor: AppColors.warning,
),
AppToast.show(
context,
'请先打开定位服务,否则可能无法发现蓝牙设备',
type: AppToastType.warning,
);
}
}
@@ -120,6 +127,42 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
}
}
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;
@@ -213,21 +256,11 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
}
} on TimeoutException {
if (!_disposed && mounted && _connectedDeviceId == bound.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已连接设备,但本次未收到测量数据'),
backgroundColor: AppColors.warning,
),
);
AppToast.show(context, '已连接设备,但本次未收到测量数据', type: AppToastType.warning);
}
} on Exception {
if (!_disposed && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('数据录入失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '数据录入失败,请稍后重试', type: AppToastType.error);
}
} finally {
await _bleService.disconnect();
@@ -272,6 +305,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
if (devices.isNotEmpty &&
!_scanning &&
_busyDeviceId == null &&
!_permissionBlocked &&
_activeScanBoundKey != boundKey) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed && mounted) unawaited(_startForegroundScan());
@@ -288,6 +322,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
return;
}
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
if (_permissionBlocked) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed && mounted) unawaited(_startForegroundScan());
});
@@ -308,8 +343,8 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
tooltip: '新增设备',
onPressed: () => pushRoute(ref, 'deviceScan'),
style: IconButton.styleFrom(
backgroundColor: const Color(0xFFEFF6FF),
foregroundColor: const Color(0xFF2563EB),
backgroundColor: _deviceVisual.lightColor,
foregroundColor: _deviceVisual.color,
fixedSize: const Size(40, 40),
),
icon: const Icon(Icons.add_rounded),
@@ -402,11 +437,7 @@ class _DevicesHeader extends StatelessWidget {
width: 50,
height: 50,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
),
gradient: _deviceVisual.gradient,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
@@ -474,15 +505,11 @@ class _DeviceSection extends StatelessWidget {
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
color: _deviceVisual.lightColor,
borderRadius: BorderRadius.circular(11),
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
type.icon,
size: 19,
color: const Color(0xFF2563EB),
),
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
),
const SizedBox(width: 10),
Text(

View File

@@ -14,6 +14,7 @@ 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 {
@@ -71,16 +72,18 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_results.clear();
});
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
if (!await _ensureBlePermissions()) {
if (mounted) setState(() => _scanning = false);
return;
}
if (Platform.isAndroid) {
final locStatus = await Permission.locationWhenInUse.serviceStatus;
if (locStatus != ServiceStatus.enabled && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
backgroundColor: AppColors.warning,
),
AppToast.show(
context,
'请先打开定位服务,否则可能无法发现蓝牙设备',
type: AppToastType.warning,
);
}
}
@@ -183,16 +186,12 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态')));
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('该设备已绑定')));
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
return;
}
setState(() => _connectingId = remoteId);
@@ -231,31 +230,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
if (mounted) popRoute(ref);
} on TimeoutException {
if (mounted && boundDevice != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')),
AppToast.show(
context,
'${boundDevice.type.label}已绑定,但本次未收到数据',
type: AppToastType.warning,
);
popRoute(ref);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('连接超时,请确认设备仍处于通信状态'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
unawaited(_startScan());
}
} on UnsupportedBleDeviceException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
AppToast.show(context, e.message, type: AppToastType.error);
unawaited(_startScan());
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error),
);
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
unawaited(_startScan());
}
} finally {
@@ -334,6 +326,38 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
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 {

View File

@@ -5,9 +5,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../utils/sse_handler.dart';
import '../../widgets/app_toast.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>(
DietNotifier.new,
@@ -303,14 +305,11 @@ class DietNotifier extends Notifier<DietState> {
}
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
const _dietAccent = Color(0xFFF97316);
const _dietAccentLight = Color(0xFFFFF3E0);
const _dietVisual = AppModuleVisuals.diet;
const _dietAccent = AppColors.diet;
const _dietAccentLight = AppColors.dietLight;
const _dietKcalText = Color(0xFF9A3412);
const _dietGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
);
const _dietGradient = AppColors.dietGradient;
class DietCapturePage extends ConsumerStatefulWidget {
const DietCapturePage({super.key});
@@ -362,47 +361,47 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
children: [
// 图片自适应显示
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Image.file(
File(state.imagePath!),
width: screenW - 48,
height: 220,
fit: BoxFit.cover,
),
child: Column(
children: [
// 图片自适应显示
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Image.file(
File(state.imagePath!),
width: screenW - 48,
height: 220,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 16),
_buildMealSelector(ref),
const SizedBox(height: 16),
if (state.isAnalyzing)
_buildAnalyzing(state)
else if (state.foods.isEmpty)
_buildNoFoodHint()
else ...[
_buildFoodList(ref),
const SizedBox(height: 16),
_buildMealSelector(ref),
const SizedBox(height: 16),
if (state.isAnalyzing)
_buildAnalyzing(state)
else if (state.foods.isEmpty)
_buildNoFoodHint()
else ...[
_buildFoodList(ref),
_buildNutritionCard(ref),
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildNutritionCard(ref),
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
const SizedBox(height: 20),
_buildSaveButton(),
_buildAiCommentary(state.commentary!),
],
const SizedBox(height: 20),
_buildSaveButton(),
],
),
],
),
);
}
@@ -931,11 +930,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
color: _dietAccentLight,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.auto_awesome,
size: 20,
color: _dietAccent,
),
child: Icon(_dietVisual.icon, size: 20, color: _dietAccent),
),
const SizedBox(width: 12),
Expanded(
@@ -981,6 +976,13 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
if (mounted) popRoute(ref);
} catch (e) {
debugPrint('[Diet] 保存记录失败: $e');
if (mounted) {
AppToast.show(
context,
'保存失败,请检查网络后重试',
type: AppToastType.error,
);
}
}
},
child: const Row(

View File

@@ -4,6 +4,7 @@ import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider);
@@ -275,11 +276,10 @@ class _DoctorFollowUpEditPageState
}
void _snack(String msg, {bool ok = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: ok ? AppColors.success : AppColors.error,
),
AppToast.show(
context,
msg,
type: ok ? AppToastType.success : AppToastType.error,
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final api = ref.read(apiClientProvider);
@@ -112,21 +113,11 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
);
ref.invalidate(_docProfileProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存成功'),
backgroundColor: AppColors.success,
),
);
AppToast.show(context, '保存成功', type: AppToastType.success);
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存失败'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '保存失败', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _saving = false);

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
final _reportDetailProvider =
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
@@ -54,9 +55,7 @@ class _DoctorReportDetailPageState
Future<void> _submitReview() async {
if (_severity.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
AppToast.show(context, '请选择严重程度', type: AppToastType.warning);
return;
}
setState(() => _submitting = true);
@@ -73,18 +72,11 @@ class _DoctorReportDetailPageState
setState(() => _submitted = true);
ref.invalidate(_reportDetailProvider(widget.id));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('审核提交成功'),
backgroundColor: AppColors.success,
),
);
AppToast.show(context, '审核提交成功', type: AppToastType.success);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error),
);
AppToast.show(context, '提交失败: $e', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _submitting = false);

View File

@@ -6,11 +6,8 @@ import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/conversation_history_provider.dart';
import '../../widgets/app_toast.dart';
/// 对话历史完整列表页。
/// - 左滑删除(真删,删后 ScaffoldMessenger 提示)
/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll
/// - 点击一条 → loadConversation → popRoute 回首页
class ConversationHistoryPage extends ConsumerWidget {
const ConversationHistoryPage({super.key});
@@ -21,7 +18,7 @@ class ConversationHistoryPage extends ConsumerWidget {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9),
title: const Text('最近 7 次对话'),
title: const Text('对话记录'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
@@ -82,18 +79,11 @@ class ConversationHistoryPage extends ConsumerWidget {
try {
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)),
);
AppToast.show(context, '已删除', type: AppToastType.success);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('删除失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
}
}
@@ -123,18 +113,11 @@ class ConversationHistoryPage extends ConsumerWidget {
.read(conversationHistoryProvider.notifier)
.clearAll();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已清空 $count 条对话')));
AppToast.show(context, '已清空 $count 条对话', type: AppToastType.success);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('清空失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '清空失败,请稍后重试', type: AppToastType.error);
}
}
}
@@ -144,6 +127,7 @@ class _HistoryTile extends StatelessWidget {
final ConversationListItem item;
final VoidCallback onTap;
final VoidCallback onDelete;
const _HistoryTile({
required this.item,
required this.onTap,
@@ -157,25 +141,12 @@ class _HistoryTile extends StatelessWidget {
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 22),
padding: const EdgeInsets.only(right: 18),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(14),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_outline, color: Colors.white),
SizedBox(width: 6),
Text(
'删除',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
],
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
onDelete();
@@ -185,7 +156,7 @@ class _HistoryTile extends StatelessWidget {
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
@@ -194,57 +165,25 @@ class _HistoryTile extends StatelessWidget {
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.forum_rounded,
color: Color(0xFF2563EB),
size: 20,
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_displaySummary(item),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
if (_displayOriginalQuestion(item).isNotEmpty) ...[
Text(
'首问:${_displayOriginalQuestion(item)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
const SizedBox(height: 4),
],
Text(
'${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
],
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
@@ -254,39 +193,29 @@ class _HistoryTile extends StatelessWidget {
);
}
static String _displayTitle(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s;
final t = item.title?.trim();
if (t != null && t.isNotEmpty) return t;
return '未命名对话';
}
static String _displaySummary(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
return _displayTitle(item);
}
static String _displayOriginalQuestion(ConversationListItem item) {
final t = item.title?.trim();
if (t == null || t.isEmpty) return '';
final summary = item.summary?.trim();
if (summary != null && summary.isNotEmpty && summary != t) {
return t.replaceAll(RegExp(r'\s+'), ' ');
}
return '';
if (t != null && t.isNotEmpty) return t.replaceAll(RegExp(r'\s+'), ' ');
return '未命名对话';
}
static String _relativeTime(DateTime time) {
static String _shortDate(DateTime time) {
final now = DateTime.now();
final local = time.toLocal();
final diff = now.difference(local);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
if (diff.inDays < 1) return '${diff.inHours} 小时前';
if (diff.inDays < 7) return '${diff.inDays} 天前';
return '${local.year}/${local.month}/${local.day}';
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return '今天';
}
final yesterday = now.subtract(const Duration(days: 1));
if (local.year == yesterday.year &&
local.month == yesterday.month &&
local.day == yesterday.day) {
return '昨天';
}
return '${local.month}/${local.day}';
}
}
@@ -314,7 +243,7 @@ class _EmptyHint extends StatelessWidget {
),
SizedBox(height: 6),
Text(
'在首页和 AI 健康助手聊聊吧',
'在首页和 AI 健康助手聊天后会显示在这里',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
@@ -328,6 +257,7 @@ class _EmptyHint extends StatelessWidget {
class _ErrorBlock extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBlock({required this.message, required this.onRetry});
@override

View File

@@ -6,6 +6,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
@@ -26,6 +27,8 @@ class _HomePageState extends ConsumerState<HomePage>
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
final _scaffoldKey = GlobalKey<ScaffoldState>();
double? _drawerDragStartX;
String? _pickedImagePath;
int _lastMsgCount = 0;
Timer? _notificationTimer;
@@ -38,11 +41,18 @@ class _HomePageState extends ConsumerState<HomePage>
(_) => ref.invalidate(notificationUnreadCountProvider),
);
_notificationTimer = Timer.periodic(
const Duration(seconds: 30),
const Duration(minutes: 2),
(_) => ref.invalidate(notificationUnreadCountProvider),
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.invalidate(notificationUnreadCountProvider);
}
}
@override
void didChangeMetrics() {
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
@@ -113,20 +123,46 @@ class _HomePageState extends ConsumerState<HomePage>
_lastMsgCount = currentCount;
return Scaffold(
key: _scaffoldKey,
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(),
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
drawerEnableOpenDragGesture: false,
body: AppBackground(
safeArea: true,
child: Column(
children: [
_buildHeader(user),
Expanded(
child: RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
child: Stack(
children: [
RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 44,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (details) {
_drawerDragStartX = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
final startX = _drawerDragStartX;
if (startX == null) return;
if (details.globalPosition.dx - startX < 28) return;
_drawerDragStartX = null;
_scaffoldKey.currentState?.openDrawer();
},
onHorizontalDragEnd: (_) => _drawerDragStartX = null,
onHorizontalDragCancel: () => _drawerDragStartX = null,
),
),
],
),
),
_buildBottomBar(context),
@@ -210,12 +246,12 @@ class _HomePageState extends ConsumerState<HomePage>
}
static final _agentDefs = [
(ActiveAgent.consultation, 'AI问诊', LucideIcons.messageCircle),
(ActiveAgent.health, '记数据', LucideIcons.heartPulse),
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
(ActiveAgent.medication, '药管家', LucideIcons.pill),
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
(ActiveAgent.exercise, '运动', LucideIcons.activity),
ActiveAgent.consultation,
ActiveAgent.health,
ActiveAgent.diet,
ActiveAgent.medication,
ActiveAgent.report,
ActiveAgent.exercise,
];
Widget _buildAgentBar() {
@@ -227,24 +263,18 @@ class _HomePageState extends ConsumerState<HomePage>
itemCount: _agentDefs.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
final gradient = _agentGradient(agent);
final agent = _agentDefs[i];
final visual = _agentVisual(agent);
return GestureDetector(
onTap: () =>
ref.read(chatProvider.notifier).triggerAgent(agent, label),
onTap: () => ref
.read(chatProvider.notifier)
.triggerAgent(agent, visual.label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.86),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.10),
blurRadius: 14,
offset: const Offset(0, 7),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -253,14 +283,14 @@ class _HomePageState extends ConsumerState<HomePage>
width: 16,
height: 16,
decoration: BoxDecoration(
gradient: gradient,
gradient: visual.gradient,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 11, color: Colors.white),
child: Icon(visual.icon, size: 11, color: Colors.white),
),
const SizedBox(width: 6),
Text(
label,
visual.label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
@@ -276,39 +306,41 @@ class _HomePageState extends ConsumerState<HomePage>
);
}
LinearGradient _agentGradient(ActiveAgent agent) {
({String label, IconData icon, LinearGradient gradient}) _agentVisual(
ActiveAgent agent,
) {
({String label, AppModuleVisual visual}) fromModule(
String label,
AppModuleVisual visual,
) => (label: label, visual: visual);
final module = switch (agent) {
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
ActiveAgent.report => fromModule('报告分析', AppModuleVisuals.report),
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
_ => null,
};
if (module != null) {
return (
label: module.label,
icon: module.visual.icon,
gradient: module.visual.gradient,
);
}
return switch (agent) {
ActiveAgent.consultation => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
ActiveAgent.consultation => (
label: 'AI问诊',
icon: LucideIcons.messageCircle,
gradient: AppColors.doctorGradient,
),
ActiveAgent.health => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
_ => (
label: 'AI问诊',
icon: LucideIcons.messageCircle,
gradient: AppColors.primaryGradient,
),
ActiveAgent.diet => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
),
ActiveAgent.medication => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
),
ActiveAgent.report => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
),
ActiveAgent.exercise => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
),
_ => AppColors.primaryGradient,
};
}
@@ -319,20 +351,13 @@ class _HomePageState extends ConsumerState<HomePage>
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 20,
offset: const Offset(0, -8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 6),
const SizedBox(height: 8),
_buildAgentBar(),
const SizedBox(height: 6),
const SizedBox(height: 12),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
],
@@ -503,11 +528,9 @@ class _HomePageState extends ConsumerState<HomePage>
final path = pdfFile.path;
if (path == null || path.isEmpty) return;
// 上传 + 让 AI 看 PDF 内容
await ref.read(chatProvider.notifier).sendPdf(
path,
pdfFile.name,
_textCtrl.text.trim(),
);
await ref
.read(chatProvider.notifier)
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
_textCtrl.clear();
if (mounted) setState(() {});
},

View File

@@ -1,14 +1,17 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_design_tokens.dart';
import '../../../core/app_module_visuals.dart';
import '../../../core/app_theme.dart';
import '../../../core/api_client.dart' show baseUrl;
import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
import '../../../widgets/ai_content.dart';
import '../../../widgets/app_toast.dart';
/// 卡片入场动画包装(从下往上滑入 + 淡入)
class _AnimatedCardEntry extends StatefulWidget {
@@ -260,20 +263,15 @@ class ChatMessagesView extends ConsumerWidget {
const SizedBox(height: 9),
Text(
info.$2,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
style: AppTextStyles.chatTitle.copyWith(
fontSize: 22,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
Text(
info.$3,
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
height: 1.4,
),
style: AppTextStyles.summarySubtitle,
),
],
),
@@ -514,7 +512,7 @@ class ChatMessagesView extends ConsumerWidget {
} else if (isExercise) {
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
title = '运动计划确认';
titleIcon = Icons.directions_run_outlined;
titleIcon = LucideIcons.footprints;
final exName =
(meta['value'] ??
meta['name'] ??
@@ -706,7 +704,7 @@ class ChatMessagesView extends ConsumerWidget {
: Icon(
isMedication
? Icons.medication_liquid_outlined
: Icons.directions_run_outlined,
: LucideIcons.footprints,
size: 36,
color: AppColors.iconColor,
),
@@ -895,9 +893,11 @@ class ChatMessagesView extends ConsumerWidget {
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(
AppToast.show(
context,
).showSnackBar(SnackBar(content: Text(error)));
error,
type: AppToastType.error,
);
}
},
borderRadius: BorderRadius.circular(18),
@@ -1098,63 +1098,13 @@ class ChatMessagesView extends ConsumerWidget {
if (isUser)
SelectableText(
msg.content,
style: const TextStyle(
fontSize: 19,
color: Colors.white,
height: 1.5,
),
style: AppTextStyles.chatBody.copyWith(color: Colors.white),
)
else
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
AiMarkdownView(
data: msg.content,
onTapLink: (text, href, title) =>
_handleMarkdownLink(context, ref, href),
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
a: const TextStyle(
fontSize: 19,
color: AppColors.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: AppColors.primary,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 22,
color: AppColors.textPrimary,
height: 1.35,
fontWeight: FontWeight.w800,
),
h2: const TextStyle(
fontSize: 21,
color: AppColors.textPrimary,
height: 1.4,
fontWeight: FontWeight.w800,
),
h3: const TextStyle(
fontSize: 20,
color: AppColors.textPrimary,
height: 1.4,
fontWeight: FontWeight.w800,
),
listBullet: const TextStyle(
fontSize: 24,
color: AppColors.textPrimary,
height: 1.2,
fontWeight: FontWeight.w900,
),
listBulletPadding: const EdgeInsets.only(right: 8),
),
),
// 图片缩略图(在文字下方)
@@ -1321,6 +1271,7 @@ class ChatMessagesView extends ConsumerWidget {
}
/// 清理 AI 返回文本中的异常占位符Markdown 格式交给 MarkdownBody 渲染)
// ignore: unused_element
static String _cleanAiText(String text) {
var t = text;
// 移除 $1、$2 等占位符
@@ -1449,7 +1400,21 @@ class ChatMessagesView extends ConsumerWidget {
}
static _AgentColors _agentColors(ActiveAgent agent) {
_AgentColors fromModule(AppModuleVisual visual) => _AgentColors(
gradient: [visual.gradient.colors.first, visual.gradient.colors.last],
bg: visual.lightColor,
border: visual.borderColor,
iconBg: visual.lightColor,
accent: visual.color,
verticalGradient: true,
);
return switch (agent) {
ActiveAgent.health => fromModule(AppModuleVisuals.health),
ActiveAgent.diet => fromModule(AppModuleVisuals.diet),
ActiveAgent.medication => fromModule(AppModuleVisuals.medication),
ActiveAgent.report => fromModule(AppModuleVisuals.report),
ActiveAgent.exercise => fromModule(AppModuleVisuals.exercise),
ActiveAgent.consultation => _AgentColors(
gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
bg: const Color(0xFFFFF4F6),
@@ -1458,46 +1423,6 @@ class ChatMessagesView extends ConsumerWidget {
accent: const Color(0xFFFF7F98),
verticalGradient: true,
),
ActiveAgent.health => _AgentColors(
gradient: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
bg: const Color(0xFFF4FEF2),
border: const Color(0xFFD4F0C8),
iconBg: const Color(0xFFF0FCEF),
accent: const Color(0xFF52B87A),
verticalGradient: true,
),
ActiveAgent.diet => _AgentColors(
gradient: [Color(0xFFF6D365), Color(0xFFFDA085)],
bg: const Color(0xFFFFF7F0),
border: const Color(0xFFFFE8D4),
iconBg: const Color(0xFFFFF0E4),
accent: const Color(0xFFF89C5B),
verticalGradient: true,
),
ActiveAgent.medication => _AgentColors(
gradient: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
bg: const Color(0xFFF4F6FF),
border: const Color(0xFFDDE6FF),
iconBg: const Color(0xFFEFF3FF),
accent: const Color(0xFF4F8FF7),
verticalGradient: true,
),
ActiveAgent.report => _AgentColors(
gradient: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
bg: const Color(0xFFF8F5FF),
border: const Color(0xFFE9E0FF),
iconBg: const Color(0xFFF2EDFF),
accent: const Color(0xFF9D8AF8),
verticalGradient: true,
),
ActiveAgent.exercise => _AgentColors(
gradient: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
bg: const Color(0xFFF0FCFF),
border: const Color(0xFFD8F5FC),
iconBg: const Color(0xFFEAFBFF),
accent: const Color(0xFF38BDE8),
verticalGradient: true,
),
_ => _AgentColors(
gradient: [AppColors.primary, AppColors.blueMeasure],
bg: const Color(0xFFF6F3FF),
@@ -1519,7 +1444,7 @@ class ChatMessagesView extends ConsumerWidget {
'AI智能问诊描述症状获取建议',
),
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告AI 辅助解读'),
ActiveAgent.exercise => (LucideIcons.activity, '运动', '制定运动计划,打卡记录进度'),
ActiveAgent.exercise => (LucideIcons.footprints, '运动', '制定运动计划,打卡记录进度'),
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
};
}
@@ -1621,8 +1546,8 @@ class ChatMessagesView extends ConsumerWidget {
// ── 1. 健康指标 ──
// 没记录则整行不显示(避免每天唠叨提醒)
const healthIconColor = Color(0xFF3B82F6);
const healthIconBg = Color(0xFFDBEAFE);
final healthIconColor = AppModuleVisuals.health.color;
final healthIconBg = AppModuleVisuals.health.lightColor;
if (allNull) {
// 不显示
} else if (hasAbnormal) {
@@ -1632,7 +1557,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.warning_amber_rounded,
LucideIcons.triangleAlert,
'健康指标',
trailing: trailing,
status: 'warning',
@@ -1646,7 +1571,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.check_circle,
LucideIcons.circleCheck,
'健康指标',
trailing: '指标正常',
status: 'done',
@@ -1658,8 +1583,8 @@ class ChatMessagesView extends ConsumerWidget {
}
// ── 2. 运动 ──
const exIconColor = Color(0xFFF59E0B);
const exIconBg = Color(0xFFFEF3C7);
final exIconColor = AppModuleVisuals.exercise.color;
final exIconBg = AppModuleVisuals.exercise.lightColor;
final exercisePlan = ref.watch(currentExercisePlanProvider);
var hasExercise = false;
exercisePlan.when(
@@ -1685,7 +1610,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.directions_run,
LucideIcons.footprints,
'$name $dur分钟',
status: done
? 'done'
@@ -1703,7 +1628,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.directions_run,
LucideIcons.footprints,
'运动',
trailing: '正在加载运动计划',
status: 'pending',
@@ -1718,7 +1643,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.directions_run,
LucideIcons.footprints,
'运动',
trailing: '运动计划加载失败',
status: 'overdue',
@@ -1733,7 +1658,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.directions_run,
LucideIcons.footprints,
'运动',
trailing: '暂无今日运动计划',
status: 'pending',
@@ -1746,8 +1671,8 @@ class ChatMessagesView extends ConsumerWidget {
// ── 3. 用药打卡 ──
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
const medIconColor = Color(0xFFEC4899);
const medIconBg = Color(0xFFFCE7F3);
final medIconColor = AppModuleVisuals.medication.color;
final medIconBg = AppModuleVisuals.medication.lightColor;
reminders.whenOrNull(
data: (meds) {
final overdueMeds = meds
@@ -1757,7 +1682,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
LucideIcons.pill,
'用药',
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
status: 'done',
@@ -1815,7 +1740,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
LucideIcons.pill,
title,
trailing: trailing,
status: status,

View File

@@ -8,6 +8,7 @@ import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_error_state.dart';
import '../../widgets/app_toast.dart';
class MedicationCheckInPage extends ConsumerStatefulWidget {
final String? medId;
@@ -66,11 +67,10 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
} catch (e) {
debugPrint('[MedCheckIn] 打卡失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('打卡失败,请稍后重试'),
backgroundColor: AppTheme.error,
),
AppToast.show(
context,
'打卡失败,请稍后重试',
type: AppToastType.error,
);
}
} finally {

View File

@@ -1,18 +1,26 @@
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_toast.dart';
class MedicationEditPage extends ConsumerStatefulWidget {
final String? id;
const MedicationEditPage({super.key, this.id});
@override
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
static const _visual = AppModuleVisuals.medication;
final _nameCtrl = TextEditingController();
final _dosageCtrl = TextEditingController();
final _notesCtrl = TextEditingController();
@@ -74,9 +82,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
Future<void> _save() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入药品名称')));
AppToast.show(context, '请输入药品名称', type: AppToastType.warning);
return;
}
setState(() => _loading = true);
@@ -107,17 +113,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
}
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
if (mounted) {
popRoute(ref);
}
if (mounted) popRoute(ref);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存失败'),
backgroundColor: AppTheme.error,
),
);
AppToast.show(context, '保存失败', type: AppToastType.error);
}
}
if (mounted) setState(() => _loading = false);
@@ -148,23 +147,22 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 名称+剂量 一行
Row(
children: [
Expanded(
flex: 3,
child: _field('药品名称', _nameCtrl, hint: ': 阿司匹林'),
child: _field('药品名称', _nameCtrl, hint: '阿司匹林'),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: _field('剂量', _dosageCtrl, hint: ': 100mg'),
child: _field('剂量', _dosageCtrl, hint: '100mg'),
),
],
),
const SizedBox(height: 16),
// 频率选择
_label('每日服药次数'), const SizedBox(height: 8),
_label('每日服药次数'),
const SizedBox(height: 8),
GestureDetector(
onTap: () async {
final n = await showAppCountPicker(
@@ -172,27 +170,17 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
initialValue: _timesPerDay,
min: 1,
max: 4,
label: ' ',
label: '',
);
if (n != null) _updateTimes(n);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: AppColors.border),
),
child: Text(
'$_timesPerDay 次/天',
style: const TextStyle(fontSize: 18),
),
child: _PickerBox(
child: Text('$_timesPerDay 次/天', style: AppTextStyles.formValue),
),
),
const SizedBox(height: 16),
// 时间选择
_label('服药时间'), const SizedBox(height: 8),
_label('服药时间'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
@@ -213,23 +201,18 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border, width: 1),
borderRadius: AppRadius.mdBorder,
border: Border.all(color: _visual.borderColor, width: 1),
),
child: Text(
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 18,
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
style: AppTextStyles.formValue,
),
),
),
),
),
const SizedBox(height: 16),
// 开始+结束 一行
Row(
children: [
Expanded(
@@ -242,7 +225,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
const SizedBox(width: 12),
Expanded(
child: _dateFieldOpt(
'结束日期(可选)',
'结束日期可选',
_end,
(d) => setState(() => _end = d),
),
@@ -250,7 +233,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
],
),
const SizedBox(height: 16),
_field('备注', _notesCtrl, hint: ': 饭后服用、睡前'),
_field('备注', _notesCtrl, hint: '饭后服用、睡前'),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
@@ -258,20 +241,12 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
onPressed: _loading ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
foregroundColor: _visual.color,
side: BorderSide(color: _visual.color, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(
_loading ? '保存中...' : '保存',
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
),
),
child: Text(_loading ? '保存中...' : '保存', style: AppTextStyles.button),
),
),
const SizedBox(height: 20),
@@ -280,10 +255,8 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
);
}
Widget _label(String text) => Text(
text,
style: TextStyle(fontSize: 17, color: AppColors.textSecondary),
);
Widget _label(String text) => Text(text, style: AppTextStyles.formLabel);
Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -296,27 +269,19 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
hintText: hint,
filled: true,
fillColor: AppTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
borderSide: const BorderSide(color: AppColors.primary),
),
border: _inputBorder(AppColors.border),
enabledBorder: _inputBorder(AppColors.border),
focusedBorder: _inputBorder(_visual.color),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
style: const TextStyle(fontSize: 19),
style: AppTextStyles.formValue,
),
],
);
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -333,22 +298,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
);
if (d != null) cb(d);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: AppColors.border),
),
child: Text(
_displayDate(val),
style: const TextStyle(fontSize: 18),
),
child: _PickerBox(
child: Text(_displayDate(val), style: AppTextStyles.formValue),
),
),
],
);
Widget _dateFieldOpt(
String label,
DateTime? val,
@@ -368,24 +324,17 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
);
if (d != null) cb(d);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: AppColors.border),
),
child: _PickerBox(
child: Row(
children: [
Text(
val != null ? _displayDate(val) : '不设置',
style: TextStyle(
fontSize: 18,
color: val != null ? null : AppTheme.textHint,
Expanded(
child: Text(
val != null ? _displayDate(val) : '不设置',
style: AppTextStyles.formValue.copyWith(
color: val != null ? null : AppTheme.textHint,
),
),
),
if (val != null) const Spacer(),
if (val != null)
GestureDetector(
onTap: () => cb(null),
@@ -401,6 +350,31 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
),
],
);
OutlineInputBorder _inputBorder(Color color) => OutlineInputBorder(
borderRadius: AppRadius.mdBorder,
borderSide: BorderSide(color: color),
);
}
class _PickerBox extends StatelessWidget {
final Widget child;
const _PickerBox({required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.border),
),
child: child,
);
}
}
String _displayDate(DateTime date) {

View File

@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart';
import '../../widgets/app_status_badge.dart';
import '../../widgets/common_widgets.dart';
import '../../widgets/enterprise_widgets.dart';
@@ -16,8 +19,7 @@ class MedicationListPage extends ConsumerStatefulWidget {
}
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
static const _medBlue = Color(0xFF60A5FA);
static const _medCyan = Color(0xFF8B5CF6);
static const _medVisual = AppModuleVisuals.medication;
Future<List<Map<String, dynamic>>>? _future;
@override
@@ -50,7 +52,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
),
floatingActionButton: FloatingActionButton(
onPressed: () => pushRoute(ref, 'medicationEdit'),
backgroundColor: _medBlue,
backgroundColor: _medVisual.color,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.add, size: 28, color: Colors.white),
),
@@ -60,8 +62,8 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
errorTitle: '用药信息加载失败',
onData: (ctx, list) {
if (list.isEmpty) {
return const AppEmptyState(
icon: Icons.medication_outlined,
return AppEmptyState(
icon: _medVisual.icon,
title: '暂无用药',
subtitle: '点击右下角➕添加药品',
);
@@ -77,9 +79,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
EnterpriseHeader(
title: '今日用药概览',
subtitle: '集中管理服药计划、剂量和每日打卡状态',
icon: Icons.medication_outlined,
color: _medBlue,
accent: _medCyan,
icon: _medVisual.icon,
color: _medVisual.color,
accent: AppColors.primary,
showIcon: false,
stats: [
EnterpriseStat(
@@ -129,17 +131,13 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
height: 44,
decoration: BoxDecoration(
gradient: isActive
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_medBlue, _medCyan],
)
? _medVisual.gradient
: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(14),
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
Icons.medication_outlined,
_medVisual.icon,
size: 25,
color: isActive ? Colors.white : AppTheme.textSub,
),
@@ -153,9 +151,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
children: [
Text(
m['name']?.toString() ?? '',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
style: AppTextStyles.listTitle.copyWith(
color: isActive
? AppTheme.text
: AppTheme.textSub,
@@ -163,24 +159,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
),
if (!isActive) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: _medBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(
999,
),
),
child: Text(
'已停',
style: TextStyle(
fontSize: 13,
color: _medBlue,
),
),
AppStatusBadge(
label: '已停',
color: _medVisual.color,
),
],
],
@@ -188,8 +169,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
const SizedBox(height: 2),
Text(
'${m['dosage'] ?? ''} $times',
style: TextStyle(
fontSize: 16,
style: AppTextStyles.listSubtitle.copyWith(
color: AppTheme.textSub,
),
),

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
@@ -619,15 +620,19 @@ class _NotificationVisual {
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
return _NotificationVisual(
visual.icon,
visual.color,
visual.lightColor,
visual.label,
);
}
factory _NotificationVisual.of(InAppNotification item) {
switch (item.actionType) {
case 'exercise':
return const _NotificationVisual(
LucideIcons.activity,
Color(0xFF16A34A),
Color(0xFFEAF8EF),
'运动',
);
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
case 'health':
return item.severity == 'critical'
? const _NotificationVisual(
@@ -636,12 +641,7 @@ class _NotificationVisual {
Color(0xFFFEE2E2),
'紧急',
)
: const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFF59E0B),
Color(0xFFFEF3C7),
'健康',
);
: _NotificationVisual.fromModule(AppModuleVisuals.health);
case 'report':
return item.severity == 'error'
? const _NotificationVisual(
@@ -650,19 +650,9 @@ class _NotificationVisual {
Color(0xFFFEE2E2),
'报告',
)
: const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF8B5CF6),
Color(0xFFEDE9FE),
'报告',
);
: _NotificationVisual.fromModule(AppModuleVisuals.report);
default:
return const _NotificationVisual(
LucideIcons.pill,
Color(0xFF3B82F6),
Color(0xFFDBEAFE),
'用药',
);
return _NotificationVisual.fromModule(AppModuleVisuals.medication);
}
}
}

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
@@ -36,10 +38,10 @@ class ProfilePage extends ConsumerWidget {
),
const SizedBox(height: 14),
_ActionTile(
icon: Icons.folder_shared_outlined,
icon: AppModuleVisuals.health.icon,
title: '健康档案',
subtitle: '维护个人资料、病史、手术和过敏信息',
color: const Color(0xFF8B5CF6),
visual: AppModuleVisuals.health,
onTap: () => pushRoute(ref, 'healthArchive'),
),
const SizedBox(height: 14),
@@ -54,9 +56,7 @@ class ProfilePage extends ConsumerWidget {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rLg),
),
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
title: const Text('退出登录'),
content: const Text('确定退出当前账号?'),
actions: [
@@ -91,12 +91,12 @@ class _AccountCard extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(16),
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
boxShadow: AppShadows.soft,
),
child: Row(
children: [
@@ -110,21 +110,14 @@ class _AccountCard extends StatelessWidget {
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
style: AppTextStyles.summaryTitle.copyWith(fontSize: 20),
),
const SizedBox(height: 5),
Text(
phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
style: AppTextStyles.listSubtitle,
),
],
),
@@ -144,18 +137,13 @@ class _Avatar extends StatelessWidget {
width: 58,
height: 58,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.borderLight),
gradient: AppModuleVisuals.health.gradient,
borderRadius: AppRadius.lgBorder,
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null && avatarUrl!.isNotEmpty
? Image.network(avatarUrl!, fit: BoxFit.cover)
: const Icon(
Icons.person_rounded,
color: AppColors.blueMeasure,
size: 34,
),
: const Icon(Icons.person_rounded, color: Colors.white, size: 34),
);
}
@@ -163,30 +151,30 @@ class _ActionTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final Color color;
final AppModuleVisual visual;
final VoidCallback onTap;
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.color,
required this.visual,
required this.onTap,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.lgBorder,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.lgBorder,
child: Container(
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
boxShadow: AppShadows.soft,
),
child: Row(
children: [
@@ -194,42 +182,28 @@ class _ActionTile extends StatelessWidget {
width: 46,
height: 46,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
gradient: visual.gradient,
borderRadius: AppRadius.mdBorder,
),
child: Icon(icon, color: color, size: 24),
child: Icon(icon, color: Colors.white, size: 24),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
Text(title, style: AppTextStyles.listTitle),
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
style: AppTextStyles.listSubtitle,
),
],
),
),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textHint,
),
const Icon(Icons.chevron_right_rounded, color: AppColors.textHint),
],
),
),
@@ -243,16 +217,13 @@ class _LogoutButton extends StatelessWidget {
const _LogoutButton({required this.onPressed});
@override
Widget build(BuildContext context) => OutlinedButton.icon(
Widget build(BuildContext context) => TextButton(
onPressed: onPressed,
icon: const Icon(Icons.logout_rounded, size: 19),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
minimumSize: const Size.fromHeight(52),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: AppTextStyles.button,
),
child: const Text('退出登录'),
);
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/app_module_visuals.dart';
import '../core/app_theme.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
@@ -9,6 +10,7 @@ import '../providers/data_providers.dart';
import '../widgets/common_widgets.dart';
import '../widgets/app_error_state.dart';
import '../widgets/app_future_view.dart';
import '../widgets/app_toast.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
class DietRecordListPage extends ConsumerStatefulWidget {
@@ -687,8 +689,9 @@ class ExercisePlanPage extends ConsumerStatefulWidget {
}
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
static const _exerciseBlue = Color(0xFF60A5FA);
static const _exerciseViolet = Color(0xFF8B5CF6);
static const _exerciseVisual = AppModuleVisuals.exercise;
static const _exerciseBlue = AppColors.exercise;
static const _exerciseViolet = Color(0xFF059669);
Future<List<Map<String, dynamic>>>? _future;
final Set<String> _busyItems = {};
@@ -718,11 +721,10 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
} catch (e) {
debugPrint('[ExercisePlan] 打卡失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('只能打卡今天的运动任务'),
backgroundColor: AppTheme.error,
),
AppToast.show(
context,
'只能打卡今天的运动任务',
type: AppToastType.error,
);
}
} finally {
@@ -755,7 +757,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
onPressed: () {
pushRoute(ref, 'exerciseCreate');
},
backgroundColor: const Color(0xFF60A5FA),
backgroundColor: AppColors.exercise,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.add, color: Colors.white),
@@ -881,8 +883,8 @@ class _ExercisePlanOverviewCard extends StatelessWidget {
),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.directions_run,
child: Icon(
_ExercisePlanPageState._exerciseVisual.icon,
color: Colors.white,
size: 24,
),
@@ -1302,11 +1304,10 @@ class _ExercisePlanDetailPageState
} catch (e) {
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('只能打卡今天的运动任务'),
backgroundColor: AppTheme.error,
),
AppToast.show(
context,
'只能打卡今天的运动任务',
type: AppToastType.error,
);
}
} finally {
@@ -1802,9 +1803,7 @@ class _ExercisePlanCreatePageState
Future<void> _save() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入运动名称')));
AppToast.show(context, '请输入运动名称', type: AppToastType.warning);
return;
}
final dur = int.tryParse(_durationCtrl.text) ?? 30;
@@ -2325,12 +2324,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
await ref.read(authProvider.notifier).refreshProfile();
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存失败,请重试'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '保存失败,请重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _saving = false);

View File

@@ -3,9 +3,10 @@ import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../widgets/ai_content.dart';
import 'report_pages.dart';
/// AI 解读页从服务器获取真实报告数据
/// AI 解读页从服务器获取真实报告数据
class AiAnalysisPage extends ConsumerStatefulWidget {
final String id;
const AiAnalysisPage({super.key, required this.id});
@@ -99,7 +100,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
_analysisStateCard(
isFailed
? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试')
: 'AI 正在分析报告,请稍后刷新查看',
: 'AI 正在分析报告,请稍后刷新查看',
isFailed: isFailed,
onRetry: isFailed
? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id)
@@ -107,7 +108,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
),
const SizedBox(height: 20),
],
// ── 1. 指标分析 ──
// 指标分析
if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[
_sectionTitle('指标分析'),
const SizedBox(height: 8),
@@ -121,7 +122,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
),
const SizedBox(height: 20),
],
// ── 2. 综合解读 ──
// 综合解读
if (!isAnalyzing && !isFailed) ...[
_sectionTitle('综合解读'),
const SizedBox(height: 8),
@@ -131,35 +132,19 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
analysis.summary.isNotEmpty
AiMarkdownView(
data: analysis.summary.isNotEmpty
? analysis.summary
: 'AI 正在分析中,请稍后刷新查看',
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.7,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'以上为AI预解读不能替代医生诊断和治疗建议',
style: TextStyle(fontSize: 13, color: AppColors.textHint),
),
),
const AiGeneratedNote(),
],
),
),
const SizedBox(height: 20),
],
if (!isAnalyzing && !isFailed) ...[
// ── 3. 医生审核意见 ──
// 医生审核意见
_sectionTitle('医生审核意见'),
const SizedBox(height: 8),
if (isReviewed)

View File

@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/api_client.dart' show baseUrl;
import '../../core/navigation_provider.dart';
@@ -17,6 +18,13 @@ final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
ReportNotifier.new,
);
Duration? reportAnalysisPollDelay(int attempt) {
if (attempt > 15) return null;
if (attempt <= 2) return const Duration(seconds: 4);
if (attempt <= 5) return const Duration(seconds: 8);
return const Duration(seconds: 12);
}
class ReportState {
final List<ReportItem> reports;
final String? uploadingImage;
@@ -132,6 +140,7 @@ class Indicator {
class ReportNotifier extends Notifier<ReportState> {
Timer? _pollTimer;
int _pollAttempt = 0;
@override
ReportState build() {
@@ -185,12 +194,16 @@ class ReportNotifier extends Notifier<ReportState> {
if (!hasAnalyzing) {
_pollTimer?.cancel();
_pollTimer = null;
_pollAttempt = 0;
return;
}
_pollTimer ??= Timer.periodic(
const Duration(seconds: 4),
(_) => loadReports(),
);
if (_pollTimer != null) return;
final delay = reportAnalysisPollDelay(++_pollAttempt);
if (delay == null) return;
_pollTimer = Timer(delay, () {
_pollTimer = null;
loadReports();
});
}
void fetchReportDetail(String reportId) async {
@@ -354,8 +367,9 @@ class ReportNotifier extends Notifier<ReportState> {
class ReportListPage extends ConsumerWidget {
const ReportListPage({super.key});
static const _reportBlue = Color(0xFF8B5CF6);
static const _reportCyan = Color(0xFF38BDF8);
static const _reportVisual = AppModuleVisuals.report;
static const _reportBlue = AppColors.report;
static const _reportCyan = Color(0xFF60A5FA);
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -407,7 +421,7 @@ class ReportListPage extends ConsumerWidget {
EnterpriseHeader(
title: '报告处理概览',
subtitle: '上传检查报告后自动进行 AI 结构化解读',
icon: Icons.description_outlined,
icon: _reportVisual.icon,
color: _reportBlue,
accent: _reportCyan,
showIcon: false,
@@ -564,11 +578,7 @@ class ReportListPage extends ConsumerWidget {
color: _reportBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(50),
),
child: const Icon(
Icons.description_outlined,
size: 44,
color: _reportBlue,
),
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
),
const SizedBox(height: 20),
const Text(
@@ -625,11 +635,7 @@ class ReportListPage extends ConsumerWidget {
color: _reportBlue.withValues(alpha: 0.10),
),
),
child: const Icon(
Icons.description_outlined,
size: 26,
color: _reportBlue,
),
child: Icon(_reportVisual.icon, size: 26, color: _reportBlue),
),
const SizedBox(width: 14),
Expanded(