Files
AI-Health/health_app/lib/pages/remaining_pages.dart
MingNian 07ddf2577a feat: 用药提醒功能 + 移除医生相关页面
- 后端新增 GET /api/medications/reminders 接口
- 前端任务卡片区显示真实用药提醒
- 移除 DoctorListPage/DoctorChatPage 路由
- 移除"找医生"面板按钮
- 医生端另做 Web 页面
2026-06-03 15:11:12 +08:00

605 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/data_providers.dart';
/// 饮食记录列表
class DietRecordListPage extends ConsumerWidget {
const DietRecordListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final service = ref.watch(dietServiceProvider);
return Scaffold(
appBar: AppBar(title: const Text('饮食记录')),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: service.getRecords(),
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snap.hasData || snap.data!.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入');
return ListView.builder(
itemCount: snap.data!.length,
itemBuilder: (ctx, i) {
final d = snap.data![i];
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
title: Text('${d['mealType'] ?? ''} ${d['totalCalories'] ?? 0}千卡'),
subtitle: Text(items.map((f) => f['name']).join(' | ')),
trailing: _starWidget(d['healthScore']),
),
);
},
);
},
),
);
}
Widget _starWidget(dynamic score) {
final s = score is int ? score : 3;
return Row(mainAxisSize: MainAxisSize.min, children: List.generate(5, (i) => Icon(Icons.star, size: 16, color: i < s ? const Color(0xFFF9A825) : Colors.grey[300])));
}
}
/// 运动计划页
class ExercisePlanPage extends ConsumerWidget {
const ExercisePlanPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final plan = ref.watch(currentExercisePlanProvider);
return Scaffold(
appBar: AppBar(title: const Text('运动计划'), centerTitle: true),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _createDefaultPlan(ref, context),
icon: const Icon(Icons.add),
label: const Text('创建本周计划'),
backgroundColor: const Color(0xFF635BFF),
),
body: plan.when(
data: (data) {
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final completedCount = items.where((i) => i['isCompleted'] == true).length;
final totalCount = items.where((i) => i['isRestDay'] != true).length;
return ListView(children: [
_buildProgressCard(completedCount, totalCount),
const SizedBox(height: 16),
...items.asMap().entries.map((entry) {
final i = entry.key;
final item = entry.value;
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
final isRest = item['isRestDay'] == true;
final isDone = item['isCompleted'] == true;
return _ExercisePlanItem(
day: weekDays[day],
dayIndex: day,
isRest: isRest,
isDone: isDone,
exerciseType: item['exerciseType']?.toString() ?? '',
duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0,
onCheckIn: () => _checkIn(ref, item['id'], context),
);
}),
]);
},
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))),
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
),
);
}
Widget _buildProgressCard(int completed, int total) {
final progress = total > 0 ? (completed / total * 100).toInt() : 0;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF5F3FF),
borderRadius: BorderRadius.circular(20),
),
child: Column(children: [
const Text('🏃 本周运动进度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFF635BFF),
borderRadius: BorderRadius.circular(30),
),
child: Center(
child: Text('$progress%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('已完成 $completed/$total', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Container(
height: 8,
decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)),
child: FractionallySizedBox(
widthFactor: progress / 100,
child: Container(
decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(4)),
),
),
),
]),
),
]),
]),
);
}
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
final service = ref.read(exerciseServiceProvider);
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
final items = List.generate(7, (i) => {
'dayOfWeek': i,
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
'isRestDay': i == 2 || i == 5,
});
await service.createPlan({
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': items,
});
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('运动计划已创建 ✅'),
backgroundColor: Color(0xFF635BFF),
));
}
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
final service = ref.read(exerciseServiceProvider);
await service.checkIn(itemId);
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('打卡成功 ✅'),
backgroundColor: Color(0xFF43A047),
));
}
}
class _ExercisePlanItem extends StatelessWidget {
final String day;
final int dayIndex;
final bool isRest;
final bool isDone;
final String exerciseType;
final int duration;
final VoidCallback onCheckIn;
const _ExercisePlanItem({
required this.day,
required this.dayIndex,
required this.isRest,
required this.isDone,
required this.exerciseType,
required this.duration,
required this.onCheckIn,
});
@override
Widget build(BuildContext context) {
final today = DateTime.now().weekday - 1;
final isToday = dayIndex == today;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Row(children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF5F3FF),
borderRadius: BorderRadius.circular(12),
),
child: isDone
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
: isRest
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF635BFF)),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)),
if (isToday) const SizedBox(width: 4),
if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))),
]),
const SizedBox(height: 4),
Text(
isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
]),
),
if (!isRest && !isDone)
ElevatedButton(
onPressed: onCheckIn,
child: const Text('打卡'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF635BFF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
),
if (isDone)
const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))),
]),
);
}
}
/// 复查列表
class FollowUpListPage extends ConsumerWidget {
const FollowUpListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context),
child: const Icon(Icons.add),
backgroundColor: const Color(0xFF635BFF),
),
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('添加复查提醒'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(decoration: const InputDecoration(labelText: '医院名称')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '科室')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '备注')),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('复查提醒已添加 ✅'),
backgroundColor: Color(0xFF635BFF),
));
},
child: const Text('保存'),
),
],
),
);
}
}
final _mockFollowUps = [
{'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'},
{'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'},
{'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'},
];
class _FollowUpItem extends StatelessWidget {
final Map<String, dynamic> item;
const _FollowUpItem({required this.item});
@override
Widget build(BuildContext context) {
final isCompleted = item['status'] == 'completed';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
borderRadius: BorderRadius.circular(8),
),
child: Text(
isCompleted ? '已完成' : '待就诊',
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
),
),
const SizedBox(width: 8),
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
]),
const SizedBox(height: 12),
Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
],
]),
);
}
}
/// 健康档案
class HealthArchivePage extends ConsumerWidget {
const HealthArchivePage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final service = ref.watch(userServiceProvider);
return Scaffold(
appBar: AppBar(title: const Text('健康档案')),
body: FutureBuilder<Map<String, dynamic>?>(
future: service.getHealthArchive(),
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
final data = snap.data;
if (data == null || data.isEmpty) return _empty(context, '暂无健康档案', '可通过 AI 对话或手动填写');
return ListView(
padding: const EdgeInsets.all(16),
children: [
_Section(title: '基本信息', children: [
_Field('诊断', data['diagnosis']), _Field('手术类型', data['surgeryType']),
_Field('手术日期', data['surgeryDate']),
]),
_Section(title: '病史与限制', children: [
_Field('过敏史', _listStr(data['allergies'])),
_Field('饮食限制', _listStr(data['dietRestrictions'])),
_Field('慢性病史', _listStr(data['chronicDiseases'])),
_Field('家族病史', data['familyHistory']),
]),
],
);
},
),
);
}
String _listStr(dynamic list) => list is List ? list.join('') : '--';
}
class _Section extends StatelessWidget {
final String title; final List<Widget> children;
const _Section({required this.title, required this.children});
@override Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(padding: const EdgeInsets.only(bottom: 8, top: 16), child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))),
...children,
]);
}
class _Field extends StatelessWidget {
final String label; final String? value;
const _Field(this.label, this.value);
@override Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 80, child: Text('$label', style: const TextStyle(fontSize: 14, color: Color(0xFF666666)))),
Expanded(child: Text(value ?? '--', style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)))),
]),
);
}
/// 编辑资料
class EditProfilePage extends ConsumerStatefulWidget {
const EditProfilePage({super.key});
@override ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
final _nameCtrl = TextEditingController(); final _genderCtrl = TextEditingController(); final _birthCtrl = TextEditingController();
@override void dispose() { _nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose(); super.dispose(); }
@override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); }
void _load() async {
final p = await ref.read(userServiceProvider).getProfile();
if (p != null && mounted) {
setState(() { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; _birthCtrl.text = p['birthDate'] ?? ''; });
}
}
Future<void> _save() async {
await ref.read(userServiceProvider).updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
if (mounted) Navigator.pop(context);
}
@override Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('编辑资料')),
body: ListView(padding: const EdgeInsets.all(16), children: [
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '姓名')),
const SizedBox(height: 16), TextField(controller: _genderCtrl, decoration: const InputDecoration(labelText: '性别')),
const SizedBox(height: 16), TextField(controller: _birthCtrl, decoration: const InputDecoration(labelText: '出生日期', hintText: 'YYYY-MM-DD')),
const SizedBox(height: 32), SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
]),
);
}
/// 健康日历
class HealthCalendarPage extends ConsumerStatefulWidget {
const HealthCalendarPage({super.key});
@override ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
}
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
@override Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('健康日历'), centerTitle: true),
body: Column(children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
_buildCalendarGrid(),
const SizedBox(height: 16),
_buildLegend(),
]),
);
}
Widget _buildMonthHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 32),
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
),
Text(
'${_currentMonth.year}${_currentMonth.month}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 32),
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
),
],
);
}
Widget _buildWeekdayHeader() {
const weekdays = ['', '', '', '', '', '', ''];
return Row(children: weekdays.map((day) => Expanded(
child: Center(child: Text(day, style: TextStyle(fontSize: 14, color: Colors.grey[500]))),
)).toList());
}
Widget _buildCalendarGrid() {
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final daysInMonth = lastDay.day;
final startWeekday = firstDay.weekday % 7;
final days = List.generate(42, (i) {
final dayIndex = i - startWeekday;
if (dayIndex < 0 || dayIndex >= daysInMonth) return null;
return dayIndex + 1;
});
return Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
itemCount: 42,
itemBuilder: (ctx, i) {
final day = days[i];
if (day == null) return const SizedBox();
return _buildDayCell(day);
},
),
);
}
Widget _buildDayCell(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final today = DateTime.now();
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
final events = _getEvents(date);
return Container(
decoration: isToday ? BoxDecoration(
color: const Color(0xFF635BFF),
borderRadius: BorderRadius.circular(20),
) : null,
child: Stack(
alignment: Alignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 16,
color: isToday ? Colors.white : Colors.black,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
),
),
if (events.isNotEmpty)
Positioned(
bottom: 4,
child: Row(children: events.map((type) => Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: _getEventColor(type),
borderRadius: BorderRadius.circular(3),
),
)).toList()),
),
],
),
);
}
List<String> _getEvents(DateTime date) {
final events = <String>[];
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise');
if (date.day == 20) events.add('followup');
return events;
}
Color _getEventColor(String type) {
switch (type) {
case 'medication': return const Color(0xFF635BFF);
case 'exercise': return const Color(0xFF43A047);
case 'followup': return const Color(0xFFF59E0B);
default: return Colors.grey;
}
}
Widget _buildLegend() {
final items = [
{'color': const Color(0xFF635BFF), 'label': '用药提醒'},
{'color': const Color(0xFF43A047), 'label': '运动计划'},
{'color': const Color(0xFFF59E0B), 'label': '复查随访'},
];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
const SizedBox(width: 4),
Text(item['label'] as String, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
const SizedBox(width: 20),
])).toList()),
);
}
}
/// 静态文本页
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': '关于'};
return Scaffold(appBar: AppBar(title: Text(titles[type] ?? '')), body: const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('内容后期填充', style: TextStyle(color: Color(0xFF999999))))));
}
}
/// 设备管理(占位)
class DeviceManagementPage extends ConsumerWidget {
const DeviceManagementPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '设备管理', '暂无绑定设备');
}
Widget _empty(BuildContext context, String title, String subtitle) => Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
])),
);