后端: - User表加Role字段 + DoctorProfile表 + 废弃旧Doctor数据 - JWT加Role claim + 注册/登录拆分(/api/auth/register + /api/auth/login) - 医生端点全量加JWT鉴权+Role校验(15个端点) - 日历端点改造(用药/运动/随访按日期+星期匹配) - 饮食记录PUT端点 + 健康记录DELETE端点 - 用户Profile端点返回Role 前端: - 登录页重做(角色选择卡片 用户紫/医生蓝 + 审核码6666 + 注册返登录) - App路由分流(userRole→患者端/医生端) - 医生端: 工作台Dashboard + 患者管理(搜索分页) + 问诊/报告/随访CRUD - 报告审核: AI预分析+指标表格+严重程度4级+模板多选+评语 - 随访编辑: 患者选择器+日期时间+打通患者端 - 健康日历: 可点击日历+当日安排列表+用药/运动/随访颜色标记 - 饮食记录: 热量趋势7/30天+日历(一周)+当日胶囊详情+侧滑编辑删除 - 健康档案: 日期选择器+手术可添加多条+白底渐变保存按钮 - 配色系统: 清理25个未使用色+新增doctorBlue/drawerGradient/pageGrey - 患者端侧边栏: 新渐变+白底按钮+紫色汉堡图标 - AI对话: 用户气泡白底黑字+药管家改蓝色 - App启动闪屏: 防登录页闪烁 文档: color_design_system.md(20页面完整配色设计+图标底色对照表)
1279 lines
66 KiB
Dart
1279 lines
66 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
import '../core/app_colors.dart';
|
||
import '../core/app_theme.dart';
|
||
import '../core/navigation_provider.dart';
|
||
import '../providers/auth_provider.dart';
|
||
import '../providers/data_providers.dart';
|
||
import '../providers/omron_device_provider.dart';
|
||
import '../widgets/common_widgets.dart';
|
||
|
||
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
|
||
class DietRecordListPage extends ConsumerStatefulWidget {
|
||
const DietRecordListPage({super.key});
|
||
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
|
||
}
|
||
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||
List<Map<String, dynamic>> _data = [];
|
||
bool _loading = true;
|
||
int _trendDays = 7;
|
||
DateTime _selectedDate = DateTime.now();
|
||
|
||
@override void initState() { super.initState(); _refresh(); }
|
||
Future<void> _refresh() async {
|
||
final records = await ref.read(dietServiceProvider).getRecords();
|
||
if (mounted) setState(() { _data = records; _loading = false; });
|
||
}
|
||
Future<void> _delete(String id) async {
|
||
await ref.read(dietServiceProvider).deleteRecord(id);
|
||
if (mounted) setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
|
||
}
|
||
|
||
String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||
int _dayCalories(DateTime d) {
|
||
final key = _dateKey(d);
|
||
return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0));
|
||
}
|
||
List<Map<String, dynamic>> _dayRecords(DateTime d) {
|
||
final key = _dateKey(d);
|
||
return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).toList();
|
||
}
|
||
List<double> _trendData() => List.generate(_trendDays, (i) => _dayCalories(DateTime.now().subtract(Duration(days: _trendDays - 1 - i))).toDouble());
|
||
|
||
@override Widget build(BuildContext context) {
|
||
if (_loading) return Scaffold(backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('饮食记录', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: const Center(child: CircularProgressIndicator()));
|
||
|
||
final todayRecords = _dayRecords(_selectedDate);
|
||
final totalCal = todayRecords.fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0));
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F5F5),
|
||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('饮食记录', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
|
||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||
// 热量趋势
|
||
Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [const Text('热量趋势', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Spacer(),
|
||
_Toggle('7天', _trendDays == 7, () => setState(() => _trendDays = 7)),
|
||
const SizedBox(width: 6), _Toggle('30天', _trendDays == 30, () => setState(() => _trendDays = 30)),
|
||
]),
|
||
const SizedBox(height: 10), SizedBox(height: 120, child: _TrendChart(_trendData())),
|
||
])),
|
||
const SizedBox(height: 12),
|
||
// 饮食日历
|
||
Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
const Text('饮食日历', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 8),
|
||
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: List.generate(7, (i) {
|
||
final d = DateTime.now().subtract(Duration(days: 6 - i));
|
||
final cal = _dayCalories(d);
|
||
final isToday = _dateKey(d) == _dateKey(DateTime.now());
|
||
final isSel = _dateKey(d) == _dateKey(_selectedDate);
|
||
return GestureDetector(onTap: () => setState(() => _selectedDate = d), child: Container(width: 42, margin: const EdgeInsets.symmetric(horizontal: 3), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(color: isSel ? AppColors.primary : (isToday ? AppColors.primary.withOpacity(0.1) : Colors.white), borderRadius: BorderRadius.circular(12), border: Border.all(color: isSel ? AppColors.primary : (isToday ? AppColors.primary : const Color(0xFFE5E7EB)))), child: Column(children: [
|
||
Text(['一','二','三','四','五','六','日'][d.weekday-1], style: TextStyle(fontSize: 10, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textHint))),
|
||
Text('${d.day}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textPrimary))),
|
||
if (cal > 0) Text('$cal', style: TextStyle(fontSize: 9, color: isSel ? Colors.white70 : AppColors.textHint)),
|
||
])));
|
||
}))),
|
||
])),
|
||
const SizedBox(height: 12),
|
||
// 当日详情
|
||
Row(children: [
|
||
Text('${_selectedDate.month}月${_selectedDate.day}日', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 8), Text('共 $totalCal 千卡', style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||
const Spacer(), Text('${todayRecords.length}条', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
if (todayRecords.isEmpty) const Padding(padding: EdgeInsets.all(20), child: Center(child: Text('当天暂无记录', style: TextStyle(color: AppColors.textHint)))),
|
||
...todayRecords.map((d) {
|
||
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final mealNames = {'Breakfast': '早餐', 'Lunch': '午餐', 'Dinner': '晚餐', 'Snack': '加餐'};
|
||
final mealIcons = {'Breakfast': Icons.wb_sunny, 'Lunch': Icons.wb_sunny_outlined, 'Dinner': Icons.nights_stay, 'Snack': Icons.cookie};
|
||
final mealLabel = mealNames[d['mealType']?.toString()] ?? '';
|
||
final mealIcon = mealIcons[d['mealType']?.toString()] ?? Icons.restaurant;
|
||
final cal = d['totalCalories'] ?? 0;
|
||
final id = d['id']?.toString() ?? '';
|
||
return Padding(padding: const EdgeInsets.only(bottom: 8), child: ClipRRect(borderRadius: BorderRadius.circular(14), child: _SwipeAction(
|
||
onEdit: () => _showEditDialog(id, cal),
|
||
onDelete: () => _delete(id),
|
||
child: Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Row(children: [
|
||
Container(width: 40, height: 40, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(12)), child: Icon(mealIcon, size: 20, color: AppColors.primary)),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [Text(mealLabel, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(width: 8), Text('$cal 千卡', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))]),
|
||
if (items.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 3), child: Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 13, color: AppColors.textHint), maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||
])),
|
||
])),
|
||
)));
|
||
}),
|
||
]),
|
||
);
|
||
}
|
||
|
||
void _showEditDialog(String id, num cal) {
|
||
final ctrl = TextEditingController(text: '$cal');
|
||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
||
title: const Text('修改热量'),
|
||
content: TextField(controller: ctrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '热量(千卡)')),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||
TextButton(onPressed: () async {
|
||
Navigator.pop(ctx);
|
||
await ref.read(dietServiceProvider).updateRecord(id, {'totalCalories': int.tryParse(ctrl.text) ?? 0});
|
||
_refresh();
|
||
}, child: const Text('保存')),
|
||
],
|
||
));
|
||
}
|
||
}
|
||
|
||
class _Toggle extends StatelessWidget {
|
||
final String l; final bool a; final VoidCallback t;
|
||
const _Toggle(this.l, this.a, this.t);
|
||
@override Widget build(BuildContext c) => GestureDetector(onTap: t, child: Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration(color: a ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: a ? AppColors.primary : const Color(0xFFE5E7EB))), child: Text(l, style: TextStyle(fontSize: 12, color: a ? Colors.white : AppColors.textSecondary))));
|
||
}
|
||
|
||
class _TrendChart extends StatelessWidget {
|
||
final List<double> data;
|
||
const _TrendChart(this.data);
|
||
@override Widget build(BuildContext c) {
|
||
if (data.isEmpty) return const Center(child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)));
|
||
final m = data.reduce((a, b) => a > b ? a : b);
|
||
if (m == 0) return const Center(child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)));
|
||
return Row(crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(data.length, (i) {
|
||
final h = (data[i] / m * 100).clamp(4.0, 100.0);
|
||
return Expanded(child: Padding(padding: EdgeInsets.symmetric(horizontal: data.length > 15 ? 1.0 : 2.0), child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||
if (data.length <= 14) Text('${data[i].toInt()}', style: const TextStyle(fontSize: 8, color: AppColors.textHint)),
|
||
const SizedBox(height: 2), Container(height: h, decoration: BoxDecoration(color: data[i] > 0 ? AppColors.primary : const Color(0xFFE5E7EB), borderRadius: BorderRadius.circular(2))),
|
||
])));
|
||
}));
|
||
}
|
||
}
|
||
|
||
class _SwipeAction extends StatelessWidget {
|
||
final Widget child; final VoidCallback onEdit; final VoidCallback onDelete;
|
||
const _SwipeAction({required this.child, required this.onEdit, required this.onDelete});
|
||
@override Widget build(BuildContext c) => Dismissible(
|
||
key: UniqueKey(), direction: DismissDirection.endToStart,
|
||
confirmDismiss: (_) async { return false; },
|
||
background: Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), color: const Color(0xFF3B82F6)), alignment: Alignment.centerLeft, padding: const EdgeInsets.only(left: 20), child: const Icon(Icons.edit, color: Colors.white)),
|
||
secondaryBackground: Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), color: AppColors.error), alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete, color: Colors.white)),
|
||
onDismissed: (_) => onDelete(),
|
||
onUpdate: (details) {
|
||
if (details.reached && details.direction == DismissDirection.startToEnd) { onEdit(); }
|
||
},
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
/// 饮食记录详情页(保留原功能)"""
|
||
class DietRecordDetailPage extends ConsumerWidget {
|
||
final String id;
|
||
const DietRecordDetailPage({super.key, required this.id});
|
||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||
final service = ref.watch(dietServiceProvider);
|
||
return GradientScaffold(
|
||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食详情')),
|
||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||
future: service.getRecords(),
|
||
builder: (ctx, snap) {
|
||
if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
|
||
final d = snap.data!.firstWhere((r) => r['id']?.toString() == id, orElse: () => <String, dynamic>{});
|
||
if (d.isEmpty) return const Center(child: Text('记录不存在'));
|
||
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final totalCal = d['totalCalories'] ?? 0;
|
||
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
||
return ListView(padding: const EdgeInsets.all(16), children: [
|
||
Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
|
||
Container(width: 48, height: 48, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)),
|
||
const SizedBox(width: 14),
|
||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
|
||
Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: AppColors.textHint)),
|
||
]),
|
||
])),
|
||
const SizedBox(height: 16),
|
||
...items.map((f) => Container(margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||
])),
|
||
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
|
||
]))),
|
||
]);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 运动计划列表(含打卡)
|
||
class ExercisePlanPage extends ConsumerStatefulWidget {
|
||
const ExercisePlanPage({super.key});
|
||
@override ConsumerState<ExercisePlanPage> createState() => _ExercisePlanPageState();
|
||
}
|
||
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||
Future<List<Map<String, dynamic>>>? _future;
|
||
@override void initState() { super.initState(); _load(); }
|
||
void _load() => setState(() { _future = ref.read(exerciseServiceProvider).getPlans(); });
|
||
|
||
Future<void> _checkIn(String id, String itemId) async {
|
||
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
_load();
|
||
}
|
||
|
||
Future<void> _deletePlan(String id) async {
|
||
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
_load();
|
||
}
|
||
|
||
@override Widget build(BuildContext context) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('运动计划')),
|
||
floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)),
|
||
body: Container(
|
||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
final plans = snap.data ?? [];
|
||
if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建');
|
||
return ListView.builder(padding: const EdgeInsets.all(12), itemCount: plans.length, itemBuilder: (ctx, i) {
|
||
final p = plans[i];
|
||
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
if (items.isEmpty) return const SizedBox.shrink();
|
||
final total = items.length;
|
||
final done = items.where((it) => it['isCompleted'] == true).length;
|
||
final weekStart = p['weekStartDate']?.toString() ?? '';
|
||
// 用 DayOfWeek 匹配今天(C#: 0=Sun, 6=Sat; Dart weekday%7 对齐)
|
||
final todayCsDow = DateTime.now().weekday % 7;
|
||
final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
|
||
(it) => it['dayOfWeek'] == todayCsDow,
|
||
orElse: () => <String, dynamic>{},
|
||
);
|
||
final hasTodayItem = todayItem.isNotEmpty;
|
||
final todayDone = todayItem['isCompleted'] == true;
|
||
final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
|
||
|
||
return SwipeDeleteTile(
|
||
key: Key(p['id']?.toString() ?? '$i'),
|
||
onDelete: () => _deletePlan(p['id']?.toString() ?? ''),
|
||
onTap: () {},
|
||
child: Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||
child: Row(children: [
|
||
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(exerciseName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 2),
|
||
Text('$weekStart 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 6),
|
||
Text('$done/$total 天已完成', style: const TextStyle(fontSize: 15, color: AppTheme.primary)),
|
||
])),
|
||
GestureDetector(
|
||
onTap: hasTodayItem ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null,
|
||
child: Container(
|
||
width: 40, height: 40,
|
||
decoration: BoxDecoration(
|
||
color: todayDone ? AppColors.successLight : AppColors.cardInner,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : AppColors.textHint),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
});
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 新建运动计划
|
||
class ExercisePlanCreatePage extends ConsumerStatefulWidget {
|
||
const ExercisePlanCreatePage({super.key});
|
||
@override ConsumerState<ExercisePlanCreatePage> createState() => _ExercisePlanCreatePageState();
|
||
}
|
||
class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage> {
|
||
final _nameCtrl = TextEditingController();
|
||
final _durationCtrl = TextEditingController(text: '30');
|
||
DateTime _start = DateTime.now();
|
||
DateTime _end = DateTime.now().add(const Duration(days: 6));
|
||
@override void dispose() { _nameCtrl.dispose(); _durationCtrl.dispose(); super.dispose(); }
|
||
|
||
Future<void> _save() async {
|
||
final name = _nameCtrl.text.trim();
|
||
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入运动名称'))); return; }
|
||
final dur = int.tryParse(_durationCtrl.text) ?? 30;
|
||
await ref.read(exerciseServiceProvider).createPlanSimple({
|
||
'exerciseType': name,
|
||
'durationMinutes': dur,
|
||
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
|
||
'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}',
|
||
});
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
if (mounted) { popRoute(ref); }
|
||
}
|
||
|
||
@override Widget build(BuildContext context) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('新建计划')),
|
||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
|
||
const SizedBox(height: 16),
|
||
_field('每天时长(分钟)', _durationCtrl, hint: '30', number: true),
|
||
const SizedBox(height: 16),
|
||
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
|
||
const SizedBox(height: 16),
|
||
_dateField('结束日期', _end, (d) => setState(() => _end = d)),
|
||
const SizedBox(height: 32),
|
||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) {
|
||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 6),
|
||
TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppColors.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.primary, width: 1.5))), style: const TextStyle(fontSize: 17)),
|
||
]);
|
||
}
|
||
|
||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) {
|
||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 6),
|
||
GestureDetector(
|
||
onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); },
|
||
child: Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||
child: Text('${val.year}-${val.month.toString().padLeft(2,'0')}-${val.day.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 19))),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
/// 复查列表(从服务器读取)
|
||
class FollowUpListPage extends ConsumerStatefulWidget {
|
||
const FollowUpListPage({super.key});
|
||
@override ConsumerState<FollowUpListPage> createState() => _FollowUpListPageState();
|
||
}
|
||
|
||
class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||
Future<List<Map<String, dynamic>>>? _future;
|
||
|
||
@override void initState() { super.initState(); _load(); }
|
||
void _load() => setState(() { _future = ref.read(followUpServiceProvider).getList(); });
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('复查随访'), centerTitle: true),
|
||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(child: CircularProgressIndicator(color: AppTheme.primaryLight));
|
||
}
|
||
final list = snap.data ?? [];
|
||
if (list.isEmpty) {
|
||
return Center(
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.event_note_outlined, size: 64, color: AppColors.textHint),
|
||
const SizedBox(height: 12),
|
||
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
|
||
const SizedBox(height: 4),
|
||
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: AppColors.textHint)),
|
||
]),
|
||
);
|
||
}
|
||
return ListView(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
children: list.map((item) => _FollowUpItem(item: item)).toList(),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _FollowUpItem extends StatelessWidget {
|
||
final Map<String, dynamic> item;
|
||
const _FollowUpItem({required this.item});
|
||
|
||
String _statusLabel(String? status) {
|
||
switch (status) {
|
||
case 'Upcoming': return '即将到来';
|
||
case 'Completed': return '已完成';
|
||
case 'Cancelled': return '已取消';
|
||
default: return status ?? '';
|
||
}
|
||
}
|
||
|
||
Color _statusColor(String? status) {
|
||
switch (status) {
|
||
case 'Upcoming': return AppColors.primary;
|
||
case 'Completed': return AppTheme.success;
|
||
case 'Cancelled': return AppTheme.error;
|
||
default: return AppColors.textHint;
|
||
}
|
||
}
|
||
|
||
Color _statusBg(String? status) {
|
||
switch (status) {
|
||
case 'Upcoming': return AppColors.iconBg;
|
||
case 'Completed': return AppColors.successLight;
|
||
case 'Cancelled': return AppColors.errorLight;
|
||
default: return AppColors.backgroundSoft;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final status = item['status']?.toString();
|
||
final label = _statusLabel(status);
|
||
final color = _statusColor(status);
|
||
final bg = _statusBg(status);
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
boxShadow: [AppTheme.shadowCard],
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rSm)),
|
||
child: Text(label, style: TextStyle(fontSize: 15, color: color, fontWeight: FontWeight.w500)),
|
||
),
|
||
const Spacer(),
|
||
if (item['doctorName'] != null)
|
||
Text('👨⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 4),
|
||
if (item['scheduledAt'] != null)
|
||
Text(_formatDateTime(item['scheduledAt']?.toString()), style: const TextStyle(fontSize: 17, color: AppColors.textHint)),
|
||
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
Text(item['notes']?.toString() ?? '', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||
],
|
||
]),
|
||
);
|
||
}
|
||
|
||
String _formatDateTime(String? iso) {
|
||
if (iso == null) return '';
|
||
final dt = DateTime.tryParse(iso);
|
||
if (dt == null) return iso;
|
||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
}
|
||
|
||
/// 健康档案(可编辑)
|
||
class HealthArchivePage extends ConsumerStatefulWidget {
|
||
const HealthArchivePage({super.key});
|
||
@override ConsumerState<HealthArchivePage> createState() => _HealthArchivePageState();
|
||
}
|
||
|
||
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||
final _nameCtrl = TextEditingController();
|
||
final _genderCtrl = TextEditingController();
|
||
String _birthDate = '';
|
||
final _diagnosisCtrl = TextEditingController();
|
||
final List<Map<String, String>> _surgeries = [];
|
||
final _allergiesCtrl = TextEditingController();
|
||
final _chronicCtrl = TextEditingController();
|
||
final _dietCtrl = TextEditingController();
|
||
final _familyCtrl = TextEditingController();
|
||
bool _loading = true;
|
||
bool _saving = false;
|
||
|
||
@override void initState() { super.initState(); _load(); }
|
||
@override void dispose() {
|
||
_nameCtrl.dispose(); _genderCtrl.dispose();
|
||
_diagnosisCtrl.dispose(); _allergiesCtrl.dispose();
|
||
_chronicCtrl.dispose(); _dietCtrl.dispose(); _familyCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
final srv = ref.read(userServiceProvider);
|
||
try {
|
||
final p = await srv.getProfile();
|
||
if (p != null && mounted) {
|
||
_nameCtrl.text = p['name'] ?? '';
|
||
_genderCtrl.text = p['gender'] ?? '';
|
||
_birthDate = p['birthDate'] ?? '';
|
||
}
|
||
final a = await srv.getHealthArchive();
|
||
if (a != null && mounted) {
|
||
_diagnosisCtrl.text = a['diagnosis'] ?? '';
|
||
final st = a['surgeryType'] as String?;
|
||
final sd = a['surgeryDate'] as String?;
|
||
if (st != null && st.isNotEmpty) _surgeries.add({'type': st, 'date': sd ?? ''});
|
||
_allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? '';
|
||
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? '';
|
||
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? '';
|
||
_familyCtrl.text = a['familyHistory'] ?? '';
|
||
}
|
||
if (mounted) setState(() => _loading = false);
|
||
} catch (_) { if (mounted) setState(() => _loading = false); }
|
||
}
|
||
|
||
Future<void> _pickDate(void Function(String) onPicked) async {
|
||
final now = DateTime.now();
|
||
final d = await showDatePicker(
|
||
context: context,
|
||
initialDate: DateTime(now.year - 30),
|
||
firstDate: DateTime(1900),
|
||
lastDate: now,
|
||
locale: const Locale('zh'),
|
||
);
|
||
if (d != null) {
|
||
onPicked('${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}');
|
||
}
|
||
}
|
||
|
||
void _addSurgery() => setState(() => _surgeries.add({'type': '', 'date': ''}));
|
||
void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i));
|
||
|
||
Future<void> _save() async {
|
||
setState(() => _saving = true);
|
||
try {
|
||
final srv = ref.read(userServiceProvider);
|
||
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthDate);
|
||
final surgeryType = _surgeries.map((s) => s['type'] ?? '').where((s) => s.isNotEmpty).join('、');
|
||
final surgeryDate = _surgeries.map((s) => s['date'] ?? '').where((s) => s.isNotEmpty).join('、');
|
||
await srv.updateHealthArchive({
|
||
'diagnosis': _diagnosisCtrl.text,
|
||
'surgeryType': surgeryType,
|
||
'surgeryDate': surgeryDate,
|
||
'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||
'chronicDiseases': _chronicCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||
'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||
'familyHistory': _familyCtrl.text,
|
||
});
|
||
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success)); }
|
||
} catch (_) {
|
||
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error)); }
|
||
} finally { if (mounted) setState(() => _saving = false); }
|
||
}
|
||
|
||
@override Widget build(BuildContext context) {
|
||
if (_loading) {
|
||
return Scaffold(backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康档案', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: const Center(child: CircularProgressIndicator()));
|
||
}
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F5F5),
|
||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康档案', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
|
||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||
_Section('个人资料', Icons.person_outline, [
|
||
_F('姓名', _nameCtrl, hint: '请输入姓名'),
|
||
const SizedBox(height: 12),
|
||
_F('性别', _genderCtrl, hint: '男/女'),
|
||
const SizedBox(height: 12),
|
||
_DateF('出生日期', _birthDate, () => _pickDate((v) => setState(() => _birthDate = v))),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
_Section('医疗信息', Icons.local_hospital_outlined, [
|
||
_F('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
|
||
const SizedBox(height: 14),
|
||
Row(children: [const Text('手术史', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), const Spacer(), _AddBtn(() => _addSurgery())]),
|
||
..._surgeries.asMap().entries.map((e) {
|
||
final i = e.key; final s = e.value;
|
||
return Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [
|
||
Expanded(child: _F2('手术', s['type'] ?? '', (v) => s['type'] = v, hint: '如: 心脏搭桥')),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: _DateF2('日期', s['date'] ?? '', () => _pickDate((v) => s['date'] = v), hint: '点击选择')),
|
||
GestureDetector(onTap: () => _removeSurgery(i), child: Container(width: 32, height: 32, decoration: BoxDecoration(color: AppColors.errorLight, borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.close, size: 16, color: AppColors.error))),
|
||
]));
|
||
}),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
_Section('病史与限制', Icons.warning_amber_outlined, [
|
||
_F('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
|
||
const SizedBox(height: 12),
|
||
_F('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
|
||
const SizedBox(height: 12),
|
||
_F('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
|
||
const SizedBox(height: 12),
|
||
_F('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
|
||
]),
|
||
const SizedBox(height: 24),
|
||
Container(
|
||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), gradient: AppColors.primaryGradient),
|
||
padding: const EdgeInsets.all(1.5),
|
||
child: SizedBox(width: double.infinity, height: 52, child: ElevatedButton(
|
||
onPressed: _saving ? null : _save,
|
||
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.primary, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)), elevation: 0),
|
||
child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||
)),
|
||
),
|
||
const SizedBox(height: 40),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Section extends StatelessWidget {
|
||
final String title; final IconData icon; final List<Widget> fields;
|
||
const _Section(this.title, this.icon, this.fields);
|
||
@override Widget build(BuildContext c) => Container(padding: const EdgeInsets.all(18), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFE5E7EB))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Container(width: 36, height: 36, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: AppColors.primary)),
|
||
const SizedBox(width: 10), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||
]),
|
||
const SizedBox(height: 16), ...fields,
|
||
]));
|
||
}
|
||
|
||
class _F extends StatelessWidget {
|
||
final String label; final TextEditingController ctrl; final String? hint;
|
||
const _F(this.label, this.ctrl, {this.hint});
|
||
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 6),
|
||
TextField(controller: ctrl, style: const TextStyle(fontSize: 16), decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint), filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.primary)))),
|
||
]);
|
||
}
|
||
|
||
class _F2 extends StatelessWidget {
|
||
final String label; final String value; final void Function(String) chg; final String? hint;
|
||
const _F2(this.label, this.value, this.chg, {this.hint});
|
||
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 4),
|
||
TextField(
|
||
controller: TextEditingController(text: value),
|
||
onChanged: chg,
|
||
style: const TextStyle(fontSize: 14),
|
||
decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.primary))),
|
||
),
|
||
]);
|
||
}
|
||
|
||
class _DateF extends StatelessWidget {
|
||
final String label; final String value; final VoidCallback onTap;
|
||
const _DateF(this.label, this.value, this.onTap);
|
||
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 6),
|
||
GestureDetector(onTap: onTap, child: Container(height: 48, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), alignment: Alignment.centerLeft, child: Row(children: [
|
||
Text(value.isEmpty ? '点击选择日期' : value, style: TextStyle(fontSize: 16, color: value.isEmpty ? AppColors.textHint : AppColors.textPrimary)),
|
||
const Spacer(), const Icon(Icons.calendar_today, size: 18, color: AppColors.textHint),
|
||
]))),
|
||
]);
|
||
}
|
||
|
||
class _DateF2 extends StatelessWidget {
|
||
final String label; final String value; final VoidCallback onTap; final String? hint;
|
||
const _DateF2(this.label, this.value, this.onTap, {this.hint});
|
||
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||
const SizedBox(height: 4),
|
||
GestureDetector(onTap: onTap, child: Container(height: 44, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(8)), alignment: Alignment.centerLeft, child: Row(children: [
|
||
Text(value.isEmpty ? (hint ?? '点击选择') : value, style: TextStyle(fontSize: 13, color: value.isEmpty ? AppColors.textHint : AppColors.textPrimary)),
|
||
const Spacer(), const Icon(Icons.calendar_today, size: 14, color: AppColors.textHint),
|
||
]))),
|
||
]);
|
||
}
|
||
|
||
class _AddBtn extends StatelessWidget {
|
||
final VoidCallback onTap;
|
||
const _AddBtn(this.onTap);
|
||
@override
|
||
Widget build(BuildContext c) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF0F0FF),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.add, size: 14, color: AppColors.primary),
|
||
SizedBox(width: 2),
|
||
Text('添加', style: TextStyle(fontSize: 13, color: AppColors.primary)),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 健康日历
|
||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||
const HealthCalendarPage({super.key});
|
||
@override ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
|
||
}
|
||
|
||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||
DateTime _currentMonth = DateTime.now();
|
||
DateTime? _selectedDate;
|
||
Map<String, List<Map<String, dynamic>>> _events = {};
|
||
Map<String, dynamic>? _selectedDayData;
|
||
|
||
static const _medBlue = Color(0xFF0000FF);
|
||
static const _exGreen = Color(0xFF00FF00);
|
||
static const _fupOrange = Color(0xFFFFA500);
|
||
|
||
@override void initState() { super.initState(); _loadMonth(); _selectDate(DateTime.now()); }
|
||
String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||
|
||
Future<void> _loadMonth() async {
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.get('/api/calendar', queryParameters: {'year': _currentMonth.year, 'month': _currentMonth.month});
|
||
final list = (res.data['data'] as List?) ?? [];
|
||
final events = <String, List<Map<String, dynamic>>>{};
|
||
for (final item in list) {
|
||
final map = item as Map<String, dynamic>;
|
||
final types = (map['events'] as List?)?.cast<String>() ?? [];
|
||
events[map['date'] as String] = types.map((t) => <String, dynamic>{'type': t}).toList();
|
||
}
|
||
if (mounted) setState(() => _events = events);
|
||
// 如果有选中的日期,刷新详情
|
||
if (_selectedDate != null) _selectDate(_selectedDate!);
|
||
} catch (_) {}
|
||
}
|
||
|
||
void _selectDate(DateTime d) {
|
||
setState(() { _selectedDate = d; _selectedDayData = null; });
|
||
final key = _dateKey(d);
|
||
final details = _events[key];
|
||
if (details != null && details.isNotEmpty) {
|
||
// 从后端拉详细数据
|
||
_loadDayDetail(d);
|
||
}
|
||
}
|
||
|
||
Future<void> _loadDayDetail(DateTime d) async {
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.get('/api/calendar/day', queryParameters: {
|
||
'date': _dateKey(d),
|
||
});
|
||
if (mounted) setState(() => _selectedDayData = res.data['data'] as Map<String, dynamic>?);
|
||
} catch (_) {}
|
||
}
|
||
|
||
@override Widget build(BuildContext context) {
|
||
final today = DateTime.now();
|
||
final isToday = _selectedDate != null && _dateKey(_selectedDate!) == _dateKey(today);
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F5F5),
|
||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康日历', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
|
||
body: Column(children: [
|
||
Container(color: Colors.white, child: Column(children: [
|
||
_buildMonthHeader(),
|
||
_buildWeekdayHeader(),
|
||
_buildCalendarGrid(),
|
||
])),
|
||
const SizedBox(height: 12),
|
||
// 图例
|
||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||
_Legend('用药提醒', _medBlue),
|
||
const SizedBox(width: 20),
|
||
_Legend('运动计划', _exGreen),
|
||
const SizedBox(width: 20),
|
||
_Legend('复查随访', _fupOrange),
|
||
])),
|
||
const SizedBox(height: 12),
|
||
// 当日详情
|
||
Expanded(child: _buildDayDetail()),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildMonthHeader() => Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||
IconButton(icon: const Icon(Icons.chevron_left), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); }),
|
||
Text('${_currentMonth.year}年${_currentMonth.month}月', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||
IconButton(icon: const Icon(Icons.chevron_right), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); }),
|
||
]));
|
||
|
||
Widget _buildWeekdayHeader() {
|
||
const wd = ['日', '一', '二', '三', '四', '五', '六'];
|
||
return Row(children: wd.map((d) => Expanded(child: Center(child: Text(d, style: const TextStyle(fontSize: 14, color: AppColors.textHint))))).toList());
|
||
}
|
||
|
||
Widget _buildCalendarGrid() {
|
||
final first = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||
final last = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
||
final dim = last.day;
|
||
final sw = first.weekday % 7;
|
||
final days = List.generate(42, (i) { final di = i - sw; return di < 0 || di >= dim ? null : di + 1; });
|
||
final today = DateTime.now();
|
||
|
||
return GridView.builder(
|
||
shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
|
||
itemCount: 42,
|
||
itemBuilder: (_, i) {
|
||
final day = days[i];
|
||
if (day == null) return const SizedBox();
|
||
final d = DateTime(_currentMonth.year, _currentMonth.month, day);
|
||
final isToday = _dateKey(d) == _dateKey(today);
|
||
final isSel = _selectedDate != null && _dateKey(d) == _dateKey(_selectedDate!);
|
||
final evs = _events[_dateKey(d)] ?? [];
|
||
|
||
return GestureDetector(
|
||
onTap: () => _selectDate(d),
|
||
child: Container(
|
||
margin: const EdgeInsets.all(2),
|
||
decoration: BoxDecoration(
|
||
color: isSel ? AppColors.primary.withOpacity(0.08) : (isToday ? AppColors.primary.withOpacity(0.05) : Colors.white),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: isSel ? Border.all(color: AppColors.primary, width: 1.5) : (isToday ? Border.all(color: AppColors.primary.withOpacity(0.3), width: 1) : null),
|
||
),
|
||
child: Stack(alignment: Alignment.center, children: [
|
||
Text('$day', style: TextStyle(fontSize: 16, color: isSel ? AppColors.primary : AppColors.textPrimary, fontWeight: isSel || isToday ? FontWeight.w600 : FontWeight.normal)),
|
||
if (evs.isNotEmpty)
|
||
Positioned(bottom: 4, child: Row(children: evs.take(3).map((e) => Container(width: 5, height: 5, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration(color: _dotColor(e['type']?.toString() ?? ''), shape: BoxShape.circle))).toList())),
|
||
]),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Color _dotColor(String t) => switch (t) { 'medication' => _medBlue, 'exercise' => _exGreen, 'followup' => _fupOrange, _ => AppColors.textHint };
|
||
|
||
Widget _buildDayDetail() {
|
||
if (_selectedDate == null) return const Center(child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)));
|
||
if (_selectedDayData == null) {
|
||
final evs = _events[_dateKey(_selectedDate!)] ?? [];
|
||
if (evs.isEmpty) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.event_available, size: 40, color: AppColors.textHint), const SizedBox(height: 8), Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint))]));
|
||
}
|
||
|
||
final data = _selectedDayData;
|
||
if (data == null) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
|
||
const SizedBox(height: 8),
|
||
Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)),
|
||
]));
|
||
|
||
final meds = (data['medications'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final exs = (data['exercises'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final fups = (data['followUps'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
|
||
if (meds.isEmpty && exs.isEmpty && fups.isEmpty) {
|
||
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
|
||
const SizedBox(height: 8),
|
||
Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)),
|
||
]));
|
||
}
|
||
|
||
return ListView(padding: const EdgeInsets.symmetric(horizontal: 16), children: [
|
||
// 用药
|
||
if (meds.isNotEmpty) ...[
|
||
_SectionTitle('用药提醒', _medBlue, Icons.medication),
|
||
...meds.map((m) => _EventCard('${m['name'] ?? ''} ${m['dosage'] ?? ''}', '${m['timeOfDay'] ?? ''}', _medBlue)),
|
||
],
|
||
// 运动
|
||
if (exs.isNotEmpty) ...[
|
||
_SectionTitle('运动计划', _exGreen, Icons.fitness_center),
|
||
...exs.map((e) => _EventCard('${e['type'] ?? ''}', '${e['duration'] ?? 0}分钟', _exGreen)),
|
||
],
|
||
// 随访
|
||
if (fups.isNotEmpty) ...[
|
||
_SectionTitle('复查随访', _fupOrange, Icons.event_note),
|
||
...fups.map((f) => _EventCard(f['title'] ?? '', f['doctorName'] ?? '', _fupOrange)),
|
||
],
|
||
]);
|
||
}
|
||
}
|
||
|
||
class _SectionTitle extends StatelessWidget {
|
||
final String title; final Color color; final IconData icon;
|
||
const _SectionTitle(this.title, this.color, this.icon);
|
||
@override Widget build(BuildContext c) => Padding(padding: const EdgeInsets.only(top: 12, bottom: 6), child: Row(children: [
|
||
Container(width: 4, height: 16, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))),
|
||
const SizedBox(width: 8), Icon(icon, size: 16, color: color),
|
||
const SizedBox(width: 6), Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color)),
|
||
]));
|
||
}
|
||
|
||
class _EventCard extends StatelessWidget {
|
||
final String title; final String sub; final Color color;
|
||
const _EventCard(this.title, this.sub, this.color);
|
||
@override Widget build(BuildContext c) => Container(
|
||
margin: const EdgeInsets.only(bottom: 6),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE5E7EB))),
|
||
child: Row(children: [
|
||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||
const SizedBox(width: 10),
|
||
Expanded(child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))),
|
||
Text(sub, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||
]),
|
||
);
|
||
}
|
||
|
||
class _Legend extends StatelessWidget {
|
||
final String label; final Color color;
|
||
const _Legend(this.label, this.color);
|
||
@override Widget build(BuildContext c) => Row(children: [
|
||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||
const SizedBox(width: 4),
|
||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||
]);
|
||
}
|
||
|
||
/// 静态文本页
|
||
class StaticTextPage extends ConsumerWidget {
|
||
final String type;
|
||
const StaticTextPage({super.key, required this.type});
|
||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'};
|
||
final contents = {
|
||
'privacy': '''## 隐私政策
|
||
|
||
更新日期:2026年1月1日
|
||
|
||
### 一、信息收集
|
||
我们收集以下类型的信息:
|
||
- 账户信息:手机号、昵称、头像(您主动提供)
|
||
- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
|
||
- 用药信息:药品名称、剂量、服药时间等用药计划数据
|
||
- 饮食记录:通过拍照或手动录入的饮食数据
|
||
- 设备信息:设备型号、操作系统版本(用于适配优化)
|
||
- 日志信息:App 使用情况、崩溃报告
|
||
|
||
### 二、信息使用
|
||
我们使用您的信息用于以下目的:
|
||
- 提供和改进健康管理服务
|
||
- AI 健康分析和个性化建议
|
||
- 用药提醒和复查通知推送
|
||
- App 功能优化和问题修复
|
||
|
||
### 三、信息保护
|
||
- 所有健康数据均采用 HTTPS 加密传输
|
||
- 数据存储于安全服务器,采用行业标准的加密措施
|
||
- 我们不会向任何第三方出售、出租或共享您的个人健康数据
|
||
- 医生仅可查看其签约患者的数据,且需经过您的授权
|
||
|
||
### 四、信息保留
|
||
- 对话记录保留 30 天后自动删除
|
||
- 您可以随时删除自己的健康数据和对话记录
|
||
- 账号注销后,所有数据将在 7 天内永久删除
|
||
|
||
### 五、您的权利
|
||
- 查看和导出您的个人数据
|
||
- 修改不准确的个人信息
|
||
- 删除不需要的数据
|
||
- 注销账号并清除所有数据
|
||
- 关闭推送通知
|
||
|
||
### 六、联系我们
|
||
如有任何关于隐私的问题,请联系:
|
||
邮箱:privacy@healthbutler.com
|
||
电话:400-xxx-xxxx''',
|
||
'about': '''## 关于健康管家
|
||
|
||
版本:v1.0.0 (Build 20260101)
|
||
|
||
### 产品介绍
|
||
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
|
||
|
||
### 核心功能
|
||
- AI 智能问诊:基于大语言模型的健康咨询服务
|
||
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
|
||
- 智能用药管理:AI 解析处方,自动生成用药计划和提醒
|
||
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
|
||
- 报告智能解读:上传检查报告,AI 自动提取指标并预解读
|
||
- 运动计划管理:制定和追踪每日运动目标
|
||
- 在线医生问诊:与签约医生进行远程咨询
|
||
|
||
### 开发团队
|
||
由专业医疗团队与 AI 技术团队联合打造。
|
||
|
||
### 技术支持
|
||
如遇到问题或有建议,请通过以下方式联系我们:
|
||
- 在线客服:App 内「设置」→「意见反馈」
|
||
- 客服热线:400-xxx-xxxx(工作日 9:00-18:00)
|
||
|
||
### 版权声明
|
||
© 2025-2026 健康管家团队。保留所有权利。
|
||
本软件受中华人民共和国著作权法保护。''',
|
||
};
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.white,
|
||
elevation: 0,
|
||
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
|
||
title: Text(titles[type] ?? '', style: TextStyle(color: AppColors.textPrimary, fontWeight: FontWeight.w600)),
|
||
centerTitle: true,
|
||
),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Text(contents[type] ?? '内容加载中...', style: TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary)),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 旧版设备管理页,已移至 device/device_management_page.dart
|
||
class _DeviceManagementPageLegacy extends ConsumerWidget {
|
||
const _DeviceManagementPageLegacy({super.key});
|
||
|
||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||
final device = ref.watch(omronDeviceProvider);
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
backgroundColor: AppColors.cardBackground,
|
||
title: const Text('蓝牙血压计'),
|
||
),
|
||
body: device.isBound ? _buildBoundCard(context, ref, device) : _buildEmptyState(context, ref),
|
||
);
|
||
}
|
||
|
||
Widget _buildEmptyState(BuildContext context, WidgetRef ref) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(
|
||
width: 88, height: 88,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.iconBg,
|
||
borderRadius: BorderRadius.circular(28),
|
||
),
|
||
child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint),
|
||
),
|
||
const SizedBox(height: 20),
|
||
const Text('未绑定设备', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
const SizedBox(height: 8),
|
||
const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
|
||
const SizedBox(height: 28),
|
||
SizedBox(
|
||
width: 220,
|
||
height: 52,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||
icon: const Icon(Icons.add, size: 22),
|
||
label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: AppColors.primary,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
|
||
Widget _buildBoundCard(BuildContext context, WidgetRef ref, DeviceBindState device) => SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(children: [
|
||
// 设备信息卡片
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(24),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardBackground,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
boxShadow: AppColors.cardShadow,
|
||
),
|
||
child: Column(children: [
|
||
Container(
|
||
width: 72, height: 72,
|
||
decoration: BoxDecoration(
|
||
gradient: device.isConnected ? AppColors.primaryGradient : const LinearGradient(colors: [Color(0xFF9CA3AF), Color(0xFF6B7280)]),
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
),
|
||
child: Icon(
|
||
device.isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
|
||
size: 36, color: Colors.white,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: device.isConnected ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
device.isConnected ? '已连接' : '未连接',
|
||
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600,
|
||
color: device.isConnected ? const Color(0xFF059669) : const Color(0xFFDC2626)),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(device.name ?? '血压计', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||
const SizedBox(height: 6),
|
||
Text('MAC: ${device.mac ?? '—'}', style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||
if (device.lastSync != null) ...[
|
||
const SizedBox(height: 4),
|
||
Text('上次同步: ${device.lastSync}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||
],
|
||
const SizedBox(height: 24),
|
||
if (device.isConnected) ...[
|
||
// 已连接:显示断开和解绑
|
||
Row(children: [
|
||
Expanded(
|
||
child: OutlinedButton.icon(
|
||
onPressed: () => _unbind(context, ref),
|
||
icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error),
|
||
label: const Text('解绑', style: TextStyle(color: AppColors.error)),
|
||
style: OutlinedButton.styleFrom(
|
||
side: const BorderSide(color: AppColors.error),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
] else ...[
|
||
// 未连接:显示重新连接和解绑
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 52,
|
||
child: ElevatedButton.icon(
|
||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||
icon: const Icon(Icons.bluetooth_searching, size: 20),
|
||
label: const Text('重新连接设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: AppColors.primary,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton.icon(
|
||
onPressed: () => _unbind(context, ref),
|
||
icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error),
|
||
label: const Text('解绑设备', style: TextStyle(color: AppColors.error)),
|
||
style: OutlinedButton.styleFrom(
|
||
side: const BorderSide(color: AppColors.error),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
]),
|
||
),
|
||
|
||
// 最后读数卡片
|
||
if (device.lastReading != null) ...[
|
||
const SizedBox(height: 16),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardBackground,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
boxShadow: AppColors.cardShadow,
|
||
),
|
||
child: Column(children: [
|
||
Row(children: [
|
||
Container(
|
||
width: 6, height: 18,
|
||
decoration: BoxDecoration(
|
||
gradient: AppColors.primaryGradient,
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text('最近一次测量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
Row(children: [
|
||
Container(
|
||
width: 56, height: 56,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.iconBg,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
),
|
||
child: const Text('🩺', style: TextStyle(fontSize: 28)),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(device.lastReading!.display, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w800, color: AppColors.textPrimary, letterSpacing: -0.5)),
|
||
Text('mmHg', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||
]),
|
||
const Spacer(),
|
||
if (device.lastReading!.pulse != null)
|
||
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||
Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.primary)),
|
||
const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||
]),
|
||
]),
|
||
]),
|
||
),
|
||
],
|
||
|
||
// 使用说明
|
||
const SizedBox(height: 16),
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.iconBg,
|
||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(children: [
|
||
Icon(Icons.lightbulb_outline, size: 18, color: AppColors.primary),
|
||
const SizedBox(width: 8),
|
||
const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
]),
|
||
const SizedBox(height: 10),
|
||
_guideItem('1', '血压计装好电池,绑好袖带'),
|
||
_guideItem('2', '血压计按开始键开始测量'),
|
||
_guideItem('3', '测量完成后数据自动同步到 App'),
|
||
_guideItem('4', '打开此页面时保持蓝牙开启'),
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
|
||
Widget _guideItem(String num, String text) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primary)),
|
||
const SizedBox(width: 6),
|
||
Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
|
||
]),
|
||
);
|
||
|
||
void _unbind(BuildContext context, WidgetRef ref) async {
|
||
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
||
title: const Text('解绑设备'),
|
||
content: const Text('解绑后需重新扫描连接,确定吗?'),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑', style: TextStyle(color: AppColors.error))),
|
||
],
|
||
));
|
||
if (ok == true) {
|
||
await ref.read(omronDeviceProvider.notifier).unbind();
|
||
}
|
||
}
|
||
}
|
||
|
||
Widget _empty(BuildContext context, String title, String subtitle) =>
|
||
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textHint),
|
||
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
|
||
]));
|