Initial commit: 健康管家 AI 健康陪伴助手

- Backend: .NET 10 Minimal API + EF Core + PostgreSQL
- Frontend: Flutter + Riverpod + GoRouter + Dio
- AI: DeepSeek LLM + Qwen VLM (OpenAI-compatible)
- Auth: SMS + JWT (access/refresh tokens)
- Features: AI chat, health tracking, medication management, diet analysis, exercise plans, doctor consultations, report analysis
This commit is contained in:
MingNian
2026-06-02 11:11:29 +08:00
commit 14d7c30d3d
144 changed files with 11436 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart';
/// 登录页——手机号 + 验证码
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _phoneCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
bool _agreed = false;
bool _sending = false;
int _countdown = 0;
bool _loading = false;
String? _error;
@override
void dispose() {
_phoneCtrl.dispose();
_codeCtrl.dispose();
super.dispose();
}
Future<void> _sendSms() async {
final phone = _phoneCtrl.text.trim();
if (phone.length != 11 || !phone.startsWith('1')) {
setState(() => _error = '请输入正确的手机号');
return;
}
setState(() { _sending = true; _error = null; });
final result = await ref.read(authProvider.notifier).sendSms(phone);
setState(() { _sending = false; });
if (result.error != null) {
setState(() => _error = result.error);
return;
}
// 开发阶段自动填充验证码
if (result.devCode != null) {
_codeCtrl.text = result.devCode!;
}
setState(() => _countdown = 60);
_startCountdown();
}
void _startCountdown() async {
for (var i = 60; i > 0; i--) {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
setState(() => _countdown = i - 1);
}
}
Future<void> _login() async {
if (!_agreed) {
setState(() => _error = '请阅读并同意服务协议和隐私政策');
return;
}
setState(() { _loading = true; _error = null; });
final err = await ref.read(authProvider.notifier).login(
_phoneCtrl.text.trim(),
_codeCtrl.text.trim(),
);
setState(() => _loading = false);
if (err != null) {
setState(() => _error = err);
return;
}
if (mounted) context.go('/home');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
// 已登录直接跳转
if (authState.isLoggedIn && !authState.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) => context.go('/home'));
}
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 80),
// Logo
Icon(Icons.local_hospital, size: 64, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text('健康管家', style: Theme.of(context).textTheme.headlineLarge),
const SizedBox(height: 8),
Text('您的 AI 健康陪伴助手', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 48),
// 手机号
TextField(
controller: _phoneCtrl,
keyboardType: TextInputType.phone,
maxLength: 11,
decoration: const InputDecoration(
hintText: '手机号',
prefixText: '+86 ',
counterText: '',
),
),
const SizedBox(height: 16),
// 验证码
Row(
children: [
Expanded(
child: TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: const InputDecoration(hintText: '验证码', counterText: ''),
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
height: 48,
child: ElevatedButton(
onPressed: (_countdown > 0 || _sending) ? null : _sendSms,
style: ElevatedButton.styleFrom(
backgroundColor: _countdown > 0 ? Colors.grey[300] : null,
),
child: Text(
_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码',
style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : null),
),
),
),
],
),
const SizedBox(height: 16),
// 协议勾选
Row(
children: [
Checkbox(value: _agreed, onChanged: (v) => setState(() => _agreed = v ?? false)),
GestureDetector(
onTap: () => setState(() => _agreed = !_agreed),
child: Text('已阅读并同意《服务协议》《隐私政策》', style: Theme.of(context).textTheme.labelMedium),
),
],
),
const SizedBox(height: 24),
// 登录按钮
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!, style: const TextStyle(color: AppColors.errorRed, fontSize: 14)),
),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _loading ? null : _login,
child: _loading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('登 录'),
),
),
const SizedBox(height: 80),
],
),
),
),
);
}
}
/// 引用 AppTheme 颜色
class AppColors {
static const Color errorRed = Color(0xFFE53935);
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/data_providers.dart';
/// 趋势图表页面
class TrendPage extends ConsumerStatefulWidget {
final String metricType;
const TrendPage({super.key, required this.metricType});
@override ConsumerState<TrendPage> createState() => _TrendPageState();
}
class _TrendPageState extends ConsumerState<TrendPage> {
int _period = 7;
@override Widget build(BuildContext context) {
final labels = {'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势'};
final service = ref.watch(healthServiceProvider);
return Scaffold(
appBar: AppBar(title: Text(labels[widget.metricType] ?? '趋势图表')),
body: Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)),
const SizedBox(width: 8), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)),
const SizedBox(width: 8), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)),
]),
),
Expanded(child: FutureBuilder<List<Map<String, dynamic>>>(
future: service.getTrend(widget.metricType, period: _period),
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (!snap.hasData || snap.data!.isEmpty) {
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.show_chart, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text('暂无足够数据', style: Theme.of(context).textTheme.bodyMedium),
]));
}
final records = snap.data!;
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: records.length,
itemBuilder: (ctx, i) {
final r = records[i];
String value;
if (widget.metricType == 'blood_pressure') {
value = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
} else {
value = '${r['value'] ?? '--'}';
}
final isAbnormal = r['isAbnormal'] == true;
final date = r['recordedAt'] != null ? DateTime.parse(r['recordedAt']).toLocal().toString().substring(0, 16) : '--';
return ListTile(
title: Text(value, style: TextStyle(fontSize: 16, color: isAbnormal ? const Color(0xFFE53935) : null)),
subtitle: Text(date, style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
trailing: isAbnormal ? const Icon(Icons.warning_amber, color: Color(0xFFE53935), size: 20) : const Icon(Icons.check_circle, color: Color(0xFF43A047), size: 20),
);
},
);
},
)),
]),
);
}
}
class _TimeChip extends StatelessWidget {
final String label; final bool selected; final VoidCallback onTap;
const _TimeChip({required this.label, required this.selected, required this.onTap});
@override Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFF635BFF))),
child: Text(label, style: TextStyle(fontSize: 14, color: selected ? Colors.white : const Color(0xFF635BFF))),
),
);
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/data_providers.dart';
/// 医生列表页
class DoctorListPage extends ConsumerWidget {
const DoctorListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final doctors = ref.watch(doctorListProvider);
return Scaffold(
appBar: AppBar(title: const Text('选择医生')),
body: doctors.when(
data: (list) {
if (list.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.person_search, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12),
Text('暂无可用医生', style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
return ListView.builder(
itemCount: list.length,
itemBuilder: (ctx, i) {
final d = list[i];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFEDEBFF),
child: Text(
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
style: const TextStyle(fontSize: 22, color: Color(0xFF635BFF)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(d['name'] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
]),
const SizedBox(height: 4),
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF635BFF))),
const SizedBox(height: 2),
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
],
),
),
ElevatedButton(
onPressed: () async {
// TODO: 点击「咨询」创建问诊并跳转聊天页
},
child: const Text('咨询'),
),
]),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => Center(
child: Text('加载失败', style: Theme.of(context).textTheme.bodyMedium),
),
),
);
}
}
/// 问诊对话页
class DoctorChatPage extends ConsumerWidget {
final String id;
const DoctorChatPage({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('问诊对话')),
body: Center(
child: Text('问诊 #$id', style: Theme.of(context).textTheme.bodyLarge),
),
);
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/chat_provider.dart';
import '../../widgets/agent_bar.dart';
import '../../widgets/health_drawer.dart';
import 'widgets/chat_messages_view.dart';
/// 首页——主界面
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true;
@override
void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
void _sendMessage() {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
_textCtrl.clear();
ref.read(chatProvider.notifier).sendMessage(text);
}
@override
Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final selectedAgent = ref.watch(selectedAgentProvider);
return Scaffold(
drawer: const HealthDrawer(),
body: SafeArea(
child: Column(children: [
_buildHeader(context),
if (_taskCardsExpanded) _buildTaskCards(chatState),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
const AgentBar(),
_buildInputBar(),
]),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(children: [
Builder(builder: (ctx) => IconButton(
icon: const Icon(Icons.menu, size: 24),
onPressed: () => Scaffold.of(ctx).openDrawer(),
)),
const Spacer(),
Text('健康管家', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
const SizedBox(width: 48),
]),
);
}
Widget _buildTaskCards(ChatState chatState) {
return GestureDetector(
onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); },
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(12),
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)),
const SizedBox(width: 8),
const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)),
),
]),
if (chatState.noticeText != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
),
]),
),
);
}
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))],
),
child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)),
);
}
List<Widget> _getAgentButtons(ActiveAgent agent) {
final buttons = <Widget>[];
if (agent == ActiveAgent.health) {
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
} else if (agent == ActiveAgent.diet) {
buttons.add(_panelBtn('拍照', Icons.camera_alt));
buttons.add(_panelBtn('上传照片', Icons.photo_library));
} else if (agent == ActiveAgent.medication) {
buttons.add(_panelBtn('用药管理', Icons.medication));
buttons.add(_panelBtn('用药提醒', Icons.alarm));
} else if (agent == ActiveAgent.consultation) {
buttons.add(_panelBtn('找医生', Icons.person_search));
} else if (agent == ActiveAgent.exercise) {
buttons.add(_panelBtn('查看本周计划', Icons.calendar_view_week));
buttons.add(_panelBtn('创建新计划', Icons.add_circle_outline));
}
return buttons;
}
Widget _panelBtn(String label, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {},
icon: Icon(icon, size: 20),
label: Text(label),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
);
}
Widget _buildInputBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () {}),
Expanded(
child: TextField(
controller: _textCtrl,
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
),
),
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
]),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/chat_provider.dart';
/// 对话消息列表
class ChatMessagesView extends ConsumerWidget {
final ScrollController scrollCtrl;
final List<ChatMessage> messages;
const ChatMessagesView({super.key, required this.scrollCtrl, required this.messages});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatState = ref.watch(chatProvider);
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, size: 48, color: Colors.grey[300]),
const SizedBox(height: 12),
Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
return ListView.builder(
controller: scrollCtrl,
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
return _buildMessageBubble(context, msg, chatState);
},
);
}
Widget _buildMessageBubble(BuildContext context, ChatMessage msg, ChatState chatState) {
final isUser = msg.isUser;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isUser ? const Color(0xFF635BFF) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: isUser ? null : const Border(left: BorderSide(color: Color(0xFF635BFF), width: 3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser && chatState.isStreaming && msg.content.isEmpty)
_buildThinkingIndicator()
else
Text(
msg.content.isEmpty && !isUser ? '...' : msg.content,
style: TextStyle(fontSize: 16, color: isUser ? Colors.white : const Color(0xFF1A1A1A)),
),
if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'AI 健康管家 · 仅供参考',
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
),
),
],
),
),
);
}
Widget _buildThinkingIndicator() {
return const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
SizedBox(width: 8),
Text('思考中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
],
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/data_providers.dart';
/// 用药列表页
class MedicationListPage extends ConsumerWidget {
const MedicationListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final meds = ref.watch(medicationListProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的用药')),
body: meds.when(
data: (list) {
if (list.isEmpty) return _empty(context);
return ListView.builder(
itemCount: list.length,
itemBuilder: (ctx, i) {
final m = list[i];
final times = (m['timeOfDay'] as List?)?.cast<String>() ?? [];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: const Icon(Icons.medication, color: Color(0xFF635BFF)),
title: Text('${m['name']} ${m['dosage'] ?? ''}', style: const TextStyle(fontSize: 16)),
subtitle: Text('每天 ${times.join("")}', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
trailing: IconButton(icon: const Icon(Icons.check_circle_outline, color: Color(0xFF43A047)), onPressed: () async {
await ref.read(medicationServiceProvider).confirm(m['id']);
ref.invalidate(medicationListProvider);
}),
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => _empty(context),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/medications/add').then((_) => ref.invalidate(medicationListProvider)),
icon: const Icon(Icons.add), label: const Text('添加药品'),
),
);
}
Widget _empty(BuildContext context) => Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.medication, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text('暂无用药计划', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8), Text('可通过 AI 对话或手动添加', style: Theme.of(context).textTheme.labelMedium),
]));
}
/// 编辑用药页
class MedicationEditPage extends ConsumerStatefulWidget {
final String? id;
const MedicationEditPage({super.key, this.id});
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _timeCtrl = TextEditingController();
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); }
Future<void> _save() async {
await ref.read(medicationServiceProvider).create({
'name': _nameCtrl.text, 'dosage': _dosageCtrl.text,
'frequency': 'Daily', 'timeOfDay': [if (_timeCtrl.text.isNotEmpty) _timeCtrl.text],
'source': 'Manual', 'startDate': DateTime.now().toIso8601String().substring(0, 10),
});
if (mounted) context.pop();
}
@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: '药品名称', hintText: '如:阿司匹林')),
const SizedBox(height: 16), TextField(controller: _dosageCtrl, decoration: const InputDecoration(labelText: '剂量', hintText: '100mg')),
const SizedBox(height: 16), TextField(controller: _timeCtrl, decoration: const InputDecoration(labelText: '服药时间', hintText: '08:00:00')),
const SizedBox(height: 32), SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
]),
);
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart';
/// 个人中心页面
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final user = auth.user;
return Scaffold(
appBar: AppBar(title: const Text('个人中心')),
body: ListView(
children: [
// 头像区
Container(
padding: const EdgeInsets.all(24),
color: const Color(0xFFEDEBFF),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: const Color(0xFF635BFF),
child: Text(
(user?.name ?? '?')[0],
style: const TextStyle(fontSize: 32, color: Colors.white),
),
),
const SizedBox(height: 12),
Text(user?.name ?? '未设置', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Text(user?.phone ?? '', style: Theme.of(context).textTheme.bodyMedium),
],
),
),
const SizedBox(height: 8),
_MenuItem(icon: Icons.person, title: '编辑资料', onTap: () => context.push('/profile/edit')),
_MenuItem(icon: Icons.folder, title: '健康档案', onTap: () => context.push('/health-archive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () {}),
const Divider(),
_MenuItem(icon: Icons.settings, title: '设置', onTap: () => context.push('/settings')),
_MenuItem(icon: Icons.info, title: '关于', onTap: () => context.push('/page/about')),
const Divider(),
_MenuItem(
icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935),
onTap: () 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('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); if (context.mounted) context.go('/login'); }
},
),
],
),
);
}
}
class _MenuItem extends StatelessWidget {
final IconData icon; final String title; final VoidCallback onTap; final Color? textColor;
const _MenuItem({required this.icon, required this.title, required this.onTap, this.textColor});
@override
Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor ?? const Color(0xFF1A1A1A))), trailing: const Icon(Icons.chevron_right, size: 20), onTap: onTap);
}

View File

@@ -0,0 +1,196 @@
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('运动计划')),
body: plan.when(
data: (data) {
if (data == null) return _empty(context, '运动计划', '暂无运动计划');
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return ListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) {
final item = items[i];
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
final isRest = item['isRestDay'] == true;
final isDone = item['isCompleted'] == true;
return ListTile(
leading: Icon(isDone ? Icons.check_circle : Icons.circle_outlined, color: isDone ? const Color(0xFF43A047) : Colors.grey),
title: Text('${weekDays[day]} ${isRest ? '休息日' : '${item['exerciseType']} ${item['durationMinutes']}分钟'}'),
trailing: isDone ? const Text('✅ 已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))) : const Text('待完成', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => _empty(context, '运动计划', '暂无运动计划'),
),
);
}
}
/// 复查列表
class FollowUpListPage extends ConsumerWidget {
const FollowUpListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '复查随访', '暂无复查安排');
}
/// 健康档案
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 ConsumerWidget {
const HealthCalendarPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '健康日历', '暂无数据');
}
/// 静态文本页
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),
])),
);

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 报告列表页
class ReportListPage extends ConsumerWidget {
const ReportListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '暂无报告', '可到「看报告」上传');
}
/// 报告详情页
class ReportDetailPage extends ConsumerWidget {
final String id;
const ReportDetailPage({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '报告详情', '报告 #$id');
}
Widget _emptyPage(BuildContext context, String title, String subtitle) => Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.description, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
])),
);

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart';
/// 设置页
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('设置')),
body: ListView(children: [
_SetItem(icon: Icons.shield, title: '隐私保护中心', onTap: () => context.push('/page/privacy')),
_SetItem(icon: Icons.notifications, title: '通知偏好', onTap: () => context.push('/settings/notifications')),
_SetItem(icon: Icons.text_fields, title: '字体大小', trailing: _FontSlider()),
_SetItem(icon: Icons.article, title: '协议与公告', onTap: () => context.push('/page/terms')),
_SetItem(icon: Icons.info, title: '关于', onTap: () => context.push('/page/about')),
const Divider(),
_SetItem(icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935), onTap: () 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('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); if (context.mounted) context.go('/login'); }
}),
]),
);
}
class _SetItem extends StatelessWidget {
final IconData icon; final String title; final VoidCallback? onTap; final Widget? trailing; final Color? textColor;
const _SetItem({required this.icon, required this.title, this.onTap, this.trailing, this.textColor});
@override
Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor)), trailing: trailing ?? const Icon(Icons.chevron_right, size: 20), onTap: onTap);
}
class _FontSlider extends StatefulWidget {
@override State<_FontSlider> createState() => _FontSliderState();
}
class _FontSliderState extends State<_FontSlider> {
double _value = 1.0;
@override Widget build(BuildContext context) => SizedBox(width: 120, child: Slider(value: _value, min: 0.8, max: 1.6, divisions: 8, label: '${_value.toStringAsFixed(1)}x', onChanged: (v) => setState(() => _value = v)));
}
/// 通知偏好页
class NotificationPrefsPage extends ConsumerWidget {
const NotificationPrefsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('通知偏好')),
body: ListView(children: [
_SwitchTile(icon: Icons.medication, title: '用药提醒'),
_SwitchTile(icon: Icons.calendar_month, title: '复查提醒'),
_SwitchTile(icon: Icons.chat, title: '医生回复'),
_SwitchTile(icon: Icons.warning_amber, title: '异常警告'),
]),
);
}
class _SwitchTile extends StatefulWidget {
final IconData icon; final String title;
const _SwitchTile({required this.icon, required this.title});
@override State<_SwitchTile> createState() => _SwitchTileState();
}
class _SwitchTileState extends State<_SwitchTile> {
bool _on = true;
@override Widget build(BuildContext context) => SwitchListTile(secondary: Icon(widget.icon), title: Text(widget.title), value: _on, onChanged: (v) => setState(() => _on = v));
}