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:
18
health_app/lib/app.dart
Normal file
18
health_app/lib/app.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'core/app_router.dart';
|
||||
import 'core/app_theme.dart';
|
||||
|
||||
/// 健康管家 App 根组件
|
||||
class HealthApp extends StatelessWidget {
|
||||
const HealthApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
title: '健康管家',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
routerConfig: AppRouter.router,
|
||||
);
|
||||
}
|
||||
}
|
||||
99
health_app/lib/core/api_client.dart
Normal file
99
health_app/lib/core/api_client.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'secure_storage.dart';
|
||||
|
||||
/// API 基础地址
|
||||
const String baseUrl = 'http://10.4.172.93:5000';
|
||||
|
||||
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
|
||||
class ApiClient {
|
||||
final Dio _dio;
|
||||
final SecureStorage _storage;
|
||||
|
||||
ApiClient({required SecureStorage storage})
|
||||
: _storage = storage,
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
)) {
|
||||
_dio.interceptors.add(_AuthInterceptor(this));
|
||||
_dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: false));
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
Future<String?> get accessToken => _storage.readAccessToken();
|
||||
Future<String?> get refreshToken => _storage.readRefreshToken();
|
||||
|
||||
Future<void> saveTokens(String access, String refresh) async {
|
||||
await _storage.writeAccessToken(access);
|
||||
await _storage.writeRefreshToken(refresh);
|
||||
}
|
||||
|
||||
Future<void> clearTokens() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
|
||||
/// 带 token 的 GET 请求
|
||||
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
return _dio.get(path, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
/// 带 token 的 POST 请求
|
||||
Future<Response> post(String path, {dynamic data}) async {
|
||||
return _dio.post(path, data: data);
|
||||
}
|
||||
|
||||
/// 带 token 的 PUT 请求
|
||||
Future<Response> put(String path, {dynamic data}) async {
|
||||
return _dio.put(path, data: data);
|
||||
}
|
||||
|
||||
/// 带 token 的 DELETE 请求
|
||||
Future<Response> delete(String path) async {
|
||||
return _dio.delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证拦截器:自动注入 token + 401 刷新
|
||||
class _AuthInterceptor extends Interceptor {
|
||||
final ApiClient _client;
|
||||
|
||||
_AuthInterceptor(this._client);
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
if (!options.path.contains('/auth/')) {
|
||||
final token = await _client.accessToken;
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
final refresh = await _client.refreshToken;
|
||||
if (refresh != null) {
|
||||
try {
|
||||
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
||||
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||
final data = response.data['data'];
|
||||
if (data != null) {
|
||||
await _client.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final opts = err.requestOptions;
|
||||
final token = data['accessToken'];
|
||||
opts.headers['Authorization'] = 'Bearer $token';
|
||||
final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts);
|
||||
return handler.resolve(retryResponse);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
await _client.clearTokens();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
57
health_app/lib/core/app_router.dart
Normal file
57
health_app/lib/core/app_router.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../pages/auth/login_page.dart';
|
||||
import '../pages/home/home_page.dart';
|
||||
import '../pages/chart/trend_page.dart';
|
||||
import '../pages/medication/medication_list_page.dart';
|
||||
import '../pages/report/report_pages.dart';
|
||||
import '../pages/consultation/consultation_pages.dart';
|
||||
import '../pages/settings/settings_pages.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
import '../pages/remaining_pages.dart';
|
||||
|
||||
/// 应用路由配置
|
||||
class AppRouter {
|
||||
AppRouter._();
|
||||
|
||||
static final GoRouter router = GoRouter(
|
||||
initialLocation: '/login',
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
|
||||
GoRoute(path: '/home', builder: (_, _) => const HomePage()),
|
||||
GoRoute(path: '/trend/:type', builder: (_, state) => TrendPage(metricType: state.pathParameters['type']!)),
|
||||
GoRoute(path: '/calendar', builder: (_, _) => const HealthCalendarPage()),
|
||||
|
||||
// 用药
|
||||
GoRoute(path: '/medications', builder: (_, _) => const MedicationListPage()),
|
||||
GoRoute(path: '/medications/add', builder: (_, _) => const MedicationEditPage()),
|
||||
GoRoute(path: '/medications/:id/edit', builder: (_, state) => MedicationEditPage(id: state.pathParameters['id'])),
|
||||
|
||||
// 报告
|
||||
GoRoute(path: '/reports', builder: (_, _) => const ReportListPage()),
|
||||
GoRoute(path: '/reports/:id', builder: (_, state) => ReportDetailPage(id: state.pathParameters['id']!)),
|
||||
|
||||
// 问诊
|
||||
GoRoute(path: '/doctors', builder: (_, _) => const DoctorListPage()),
|
||||
GoRoute(path: '/consultation/:id', builder: (_, state) => DoctorChatPage(id: state.pathParameters['id']!)),
|
||||
|
||||
// 运动
|
||||
GoRoute(path: '/exercise-plan', builder: (_, _) => const ExercisePlanPage()),
|
||||
|
||||
// 饮食
|
||||
GoRoute(path: '/diet-records', builder: (_, _) => const DietRecordListPage()),
|
||||
|
||||
// 个人中心
|
||||
GoRoute(path: '/profile', builder: (_, _) => const ProfilePage()),
|
||||
GoRoute(path: '/profile/edit', builder: (_, _) => const EditProfilePage()),
|
||||
GoRoute(path: '/health-archive', builder: (_, _) => const HealthArchivePage()),
|
||||
|
||||
// 复查
|
||||
GoRoute(path: '/followups', builder: (_, _) => const FollowUpListPage()),
|
||||
|
||||
// 设置
|
||||
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||
GoRoute(path: '/settings/notifications', builder: (_, _) => const NotificationPrefsPage()),
|
||||
GoRoute(path: '/page/:type', builder: (_, state) => StaticTextPage(type: state.pathParameters['type']!)),
|
||||
],
|
||||
);
|
||||
}
|
||||
77
health_app/lib/core/app_theme.dart
Normal file
77
health_app/lib/core/app_theme.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 健康管家主题配置——薰衣草紫 + 温暖治愈风
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const Color primaryColor = Color(0xFF635BFF);
|
||||
static const Color primaryLight = Color(0xFFEDEBFF);
|
||||
static const Color primaryDark = Color(0xFF4B44D6);
|
||||
static const Color background = Color(0xFFF8F9FF);
|
||||
static const Color cardWhite = Color(0xFFFFFFFF);
|
||||
static const Color textPrimary = Color(0xFF1A1A1A);
|
||||
static const Color textSecondary = Color(0xFF666666);
|
||||
static const Color textPlaceholder = Color(0xFF999999);
|
||||
static const Color successGreen = Color(0xFF43A047);
|
||||
static const Color errorRed = Color(0xFFE53935);
|
||||
static const Color warningYellow = Color(0xFFF9A825);
|
||||
static const Color secondaryButton = Color(0xFFE5E5F7);
|
||||
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
primary: primaryColor,
|
||||
surface: background,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: background,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: cardWhite,
|
||||
foregroundColor: textPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: cardWhite,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: cardWhite,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 1.5),
|
||||
),
|
||||
hintStyle: const TextStyle(color: textPlaceholder, fontSize: 16),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: textPrimary),
|
||||
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: textPrimary),
|
||||
bodyLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w400, color: textPrimary),
|
||||
bodyMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: textSecondary),
|
||||
labelMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: textSecondary),
|
||||
labelSmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
35
health_app/lib/core/secure_storage.dart
Normal file
35
health_app/lib/core/secure_storage.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Token 安全存储(iOS Keychain / Android EncryptedSharedPreferences / Web 内存)
|
||||
class SecureStorage {
|
||||
final FlutterSecureStorage _storage;
|
||||
static final Map<String, String> _webFallback = {};
|
||||
|
||||
SecureStorage() : _storage = const FlutterSecureStorage();
|
||||
static const _access = 'access_token';
|
||||
static const _refresh = 'refresh_token';
|
||||
|
||||
bool get _isWeb => kIsWeb;
|
||||
|
||||
Future<void> writeAccessToken(String t) async {
|
||||
if (_isWeb) { _webFallback[_access] = t; return; }
|
||||
await _storage.write(key: _access, value: t);
|
||||
}
|
||||
Future<String?> readAccessToken() async {
|
||||
if (_isWeb) return _webFallback[_access];
|
||||
return _storage.read(key: _access);
|
||||
}
|
||||
Future<void> writeRefreshToken(String t) async {
|
||||
if (_isWeb) { _webFallback[_refresh] = t; return; }
|
||||
await _storage.write(key: _refresh, value: t);
|
||||
}
|
||||
Future<String?> readRefreshToken() async {
|
||||
if (_isWeb) return _webFallback[_refresh];
|
||||
return _storage.read(key: _refresh);
|
||||
}
|
||||
Future<void> deleteAll() async {
|
||||
if (_isWeb) { _webFallback.clear(); return; }
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
}
|
||||
8
health_app/lib/main.dart
Normal file
8
health_app/lib/main.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const ProviderScope(child: HealthApp()));
|
||||
}
|
||||
184
health_app/lib/pages/auth/login_page.dart
Normal file
184
health_app/lib/pages/auth/login_page.dart
Normal 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);
|
||||
}
|
||||
79
health_app/lib/pages/chart/trend_page.dart
Normal file
79
health_app/lib/pages/chart/trend_page.dart
Normal 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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
93
health_app/lib/pages/consultation/consultation_pages.dart
Normal file
93
health_app/lib/pages/consultation/consultation_pages.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
174
health_app/lib/pages/home/home_page.dart
Normal file
174
health_app/lib/pages/home/home_page.dart
Normal 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),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
health_app/lib/pages/home/widgets/chat_messages_view.dart
Normal file
88
health_app/lib/pages/home/widgets/chat_messages_view.dart
Normal 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))),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
80
health_app/lib/pages/medication/medication_list_page.dart
Normal file
80
health_app/lib/pages/medication/medication_list_page.dart
Normal 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('保存'))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
69
health_app/lib/pages/profile/profile_page.dart
Normal file
69
health_app/lib/pages/profile/profile_page.dart
Normal 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);
|
||||
}
|
||||
196
health_app/lib/pages/remaining_pages.dart
Normal file
196
health_app/lib/pages/remaining_pages.dart
Normal 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),
|
||||
])),
|
||||
);
|
||||
25
health_app/lib/pages/report/report_pages.dart
Normal file
25
health_app/lib/pages/report/report_pages.dart
Normal 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),
|
||||
])),
|
||||
);
|
||||
67
health_app/lib/pages/settings/settings_pages.dart
Normal file
67
health_app/lib/pages/settings/settings_pages.dart
Normal 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));
|
||||
}
|
||||
139
health_app/lib/providers/auth_provider.dart
Normal file
139
health_app/lib/providers/auth_provider.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api_client.dart';
|
||||
import '../core/secure_storage.dart';
|
||||
|
||||
/// 用户简要信息
|
||||
class UserInfo {
|
||||
final String id;
|
||||
final String phone;
|
||||
final String? name;
|
||||
final String? avatarUrl;
|
||||
|
||||
UserInfo({required this.id, required this.phone, this.name, this.avatarUrl});
|
||||
}
|
||||
|
||||
/// 认证状态
|
||||
class AuthState {
|
||||
final UserInfo? user;
|
||||
final bool isLoggedIn;
|
||||
final bool isLoading;
|
||||
|
||||
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
|
||||
}
|
||||
|
||||
/// 认证 Provider
|
||||
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
||||
|
||||
final secureStorageProvider = Provider<SecureStorage>((ref) => SecureStorage());
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
return ApiClient(storage: ref.watch(secureStorageProvider));
|
||||
});
|
||||
|
||||
class AuthNotifier extends Notifier<AuthState> {
|
||||
@override
|
||||
AuthState build() {
|
||||
_checkAuth();
|
||||
return const AuthState(isLoading: true);
|
||||
}
|
||||
|
||||
Future<void> _checkAuth() async {
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final refresh = await storage.readRefreshToken();
|
||||
if (refresh == null) {
|
||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
||||
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||
final data = response.data['data'];
|
||||
if (data != null) {
|
||||
await storage.writeAccessToken(data['accessToken']);
|
||||
await storage.writeRefreshToken(data['refreshToken']);
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(id: '', phone: '', name: data['user']?['name']),
|
||||
);
|
||||
_loadProfile();
|
||||
} else {
|
||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||
}
|
||||
} catch (_) {
|
||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/user/profile');
|
||||
final user = response.data['data'];
|
||||
if (user != null) {
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(
|
||||
id: user['id'] ?? '',
|
||||
phone: user['phone'] ?? '',
|
||||
name: user['name'],
|
||||
avatarUrl: user['avatarUrl'],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 发送验证码,返回 (error, devCode)
|
||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post('/api/auth/send-sms', data: {'phone': phone});
|
||||
final devCode = response.data['data']?['devCode'] as String?;
|
||||
return (error: null, devCode: devCode);
|
||||
} catch (e) {
|
||||
return (error: '发送失败: $e', devCode: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证码登录
|
||||
Future<String?> login(String phone, String code) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post('/api/auth/login', data: {'phone': phone, 'smsCode': code});
|
||||
final data = response.data['data'];
|
||||
if (data == null) return response.data['message'] ?? '登录失败';
|
||||
|
||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final user = data['user'];
|
||||
state = AuthState(
|
||||
isLoggedIn: true,
|
||||
isLoading: false,
|
||||
user: UserInfo(
|
||||
id: user['id'] ?? '',
|
||||
phone: user['phone'] ?? '',
|
||||
name: user['name'],
|
||||
avatarUrl: user['avatarUrl'],
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return '登录失败: $e';
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出
|
||||
Future<void> logout() async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final refresh = await storage.readRefreshToken();
|
||||
if (refresh != null) {
|
||||
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (_) {}
|
||||
}
|
||||
await api.clearTokens();
|
||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||
}
|
||||
}
|
||||
159
health_app/lib/providers/chat_provider.dart
Normal file
159
health_app/lib/providers/chat_provider.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
class ChatMessage {
|
||||
final String id;
|
||||
final String role;
|
||||
String content;
|
||||
final DateTime createdAt;
|
||||
ChatMessage(
|
||||
{required this.id,
|
||||
required this.role,
|
||||
required this.content,
|
||||
required this.createdAt});
|
||||
bool get isUser => role == 'user';
|
||||
}
|
||||
|
||||
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise }
|
||||
|
||||
class ChatState {
|
||||
final ActiveAgent activeAgent;
|
||||
final List<ChatMessage> messages;
|
||||
final String? conversationId;
|
||||
final bool isStreaming;
|
||||
final String? noticeText;
|
||||
const ChatState({
|
||||
this.activeAgent = ActiveAgent.default_,
|
||||
this.messages = const [],
|
||||
this.conversationId,
|
||||
this.isStreaming = false,
|
||||
this.noticeText,
|
||||
});
|
||||
ChatState copyWith({ActiveAgent? activeAgent, List<ChatMessage>? messages,
|
||||
String? conversationId, bool? isStreaming, String? noticeText,
|
||||
bool clearNotice = false}) =>
|
||||
ChatState(
|
||||
activeAgent: activeAgent ?? this.activeAgent,
|
||||
messages: messages ?? this.messages,
|
||||
conversationId: conversationId ?? this.conversationId,
|
||||
isStreaming: isStreaming ?? this.isStreaming,
|
||||
noticeText: clearNotice ? null : (noticeText ?? this.noticeText),
|
||||
);
|
||||
}
|
||||
|
||||
class SelectedAgentNotifier extends Notifier<ActiveAgent?> {
|
||||
@override
|
||||
ActiveAgent? build() => null;
|
||||
void select(ActiveAgent? a) => state = a;
|
||||
}
|
||||
|
||||
final selectedAgentProvider =
|
||||
NotifierProvider<SelectedAgentNotifier, ActiveAgent?>(SelectedAgentNotifier.new);
|
||||
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(ChatNotifier.new);
|
||||
|
||||
class ChatNotifier extends Notifier<ChatState> {
|
||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||
|
||||
@override
|
||||
ChatState build() => const ChatState();
|
||||
|
||||
void setAgent(ActiveAgent a) {
|
||||
_subscription?.cancel();
|
||||
state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
if (text.trim().isEmpty || state.isStreaming) return;
|
||||
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, userMsg], isStreaming: true);
|
||||
|
||||
final aiMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
try {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) {
|
||||
_addError(aiMsg, '未登录,请重新登录');
|
||||
return;
|
||||
}
|
||||
|
||||
final agentPath =
|
||||
state.activeAgent.name.replaceFirst('default_', 'default');
|
||||
final stream = SseHandler.connect(
|
||||
agentType: agentPath,
|
||||
message: text,
|
||||
conversationId: state.conversationId,
|
||||
token: token,
|
||||
);
|
||||
|
||||
await for (final event in stream) {
|
||||
_processEvent(event, aiMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
_addError(aiMsg, '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
void _addError(ChatMessage aiMsg, String errorText) {
|
||||
state = state.copyWith(
|
||||
messages: [
|
||||
...state.messages,
|
||||
ChatMessage(
|
||||
id: 'err_${DateTime.now().millisecondsSinceEpoch}',
|
||||
role: 'assistant',
|
||||
content: errorText,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
],
|
||||
isStreaming: false,
|
||||
clearNotice: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _processEvent(Map<String, dynamic> j, ChatMessage aiMsg) {
|
||||
final a = j['action'] as String?;
|
||||
switch (a) {
|
||||
case 'conversation_id':
|
||||
state = state.copyWith(conversationId: j['data']?.toString());
|
||||
case 'answer':
|
||||
aiMsg.content += (j['data'] as String?) ?? '';
|
||||
_update(aiMsg);
|
||||
case 'notice':
|
||||
state = state.copyWith(noticeText: j['message'] as String?);
|
||||
case 'status':
|
||||
_done(aiMsg);
|
||||
case 'error':
|
||||
_done(aiMsg);
|
||||
}
|
||||
}
|
||||
|
||||
void _update(ChatMessage m) {
|
||||
final u = state.messages.toList();
|
||||
final i = u.indexWhere((x) => x.id == m.id);
|
||||
if (i >= 0) {
|
||||
u[i] = m;
|
||||
} else if (m.content.isNotEmpty) {
|
||||
u.add(m);
|
||||
}
|
||||
state = state.copyWith(messages: u);
|
||||
}
|
||||
|
||||
void _done(ChatMessage m) {
|
||||
final u = state.messages.toList();
|
||||
if (!u.any((x) => x.id == m.id) && m.content.isNotEmpty) u.add(m);
|
||||
state = state.copyWith(messages: u, isStreaming: false, clearNotice: true);
|
||||
}
|
||||
}
|
||||
58
health_app/lib/providers/data_providers.dart
Normal file
58
health_app/lib/providers/data_providers.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import '../services/health_service.dart';
|
||||
|
||||
/// 健康数据服务
|
||||
final healthServiceProvider = Provider<HealthService>((ref) {
|
||||
return HealthService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final userServiceProvider = Provider<UserService>((ref) {
|
||||
return UserService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final medicationServiceProvider = Provider<MedicationService>((ref) {
|
||||
return MedicationService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final dietServiceProvider = Provider<DietService>((ref) {
|
||||
return DietService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final consultationServiceProvider = Provider<ConsultationService>((ref) {
|
||||
return ConsultationService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
||||
return ExerciseService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
/// 最新健康数据 Provider
|
||||
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(healthServiceProvider);
|
||||
return service.getLatest();
|
||||
});
|
||||
|
||||
/// 用药列表 Provider
|
||||
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getList();
|
||||
});
|
||||
|
||||
/// 医生列表 Provider
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
return service.getDoctors();
|
||||
});
|
||||
|
||||
/// 问诊配额 Provider
|
||||
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
return service.getQuota();
|
||||
});
|
||||
|
||||
/// 当前运动计划 Provider
|
||||
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final service = ref.watch(exerciseServiceProvider);
|
||||
return service.getCurrentPlan();
|
||||
});
|
||||
158
health_app/lib/services/health_service.dart
Normal file
158
health_app/lib/services/health_service.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import '../core/api_client.dart';
|
||||
|
||||
/// 健康数据服务
|
||||
class HealthService {
|
||||
final ApiClient _api;
|
||||
HealthService(this._api);
|
||||
|
||||
/// 获取各指标最新值
|
||||
Future<Map<String, dynamic>> getLatest() async {
|
||||
final res = await _api.get('/api/health-records/latest');
|
||||
return res.data['data'] ?? {};
|
||||
}
|
||||
|
||||
/// 获取趋势数据
|
||||
Future<List<Map<String, dynamic>>> getTrend(String type, {int period = 7}) async {
|
||||
final res = await _api.get('/api/health-records/trend', queryParameters: {'type': type, 'period': period});
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// 获取记录列表
|
||||
Future<List<Map<String, dynamic>>> getRecords({String? type, int? days}) async {
|
||||
final params = <String, dynamic>{};
|
||||
if (type != null) params['type'] = type;
|
||||
if (days != null) params['days'] = days;
|
||||
final res = await _api.get('/api/health-records', queryParameters: params);
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户服务
|
||||
class UserService {
|
||||
final ApiClient _api;
|
||||
UserService(this._api);
|
||||
|
||||
Future<Map<String, dynamic>?> getProfile() async {
|
||||
final res = await _api.get('/api/user/profile');
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
Future<void> updateProfile({String? name, String? gender, String? birthDate}) async {
|
||||
await _api.put('/api/user/profile', data: {'name': name, 'gender': gender, 'birthDate': birthDate});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getHealthArchive() async {
|
||||
final res = await _api.get('/api/user/health-archive');
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
Future<void> updateHealthArchive(Map<String, dynamic> data) async {
|
||||
await _api.put('/api/user/health-archive', data: data);
|
||||
}
|
||||
|
||||
Future<void> deleteAccount() async {
|
||||
await _api.delete('/api/user/account');
|
||||
}
|
||||
}
|
||||
|
||||
/// 用药服务
|
||||
class MedicationService {
|
||||
final ApiClient _api;
|
||||
MedicationService(this._api);
|
||||
|
||||
Future<List<Map<String, dynamic>>> getList() async {
|
||||
final res = await _api.get('/api/medications');
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await _api.post('/api/medications', data: data);
|
||||
}
|
||||
|
||||
Future<void> update(String id, Map<String, dynamic> data) async {
|
||||
await _api.put('/api/medications/$id', data: data);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _api.delete('/api/medications/$id');
|
||||
}
|
||||
|
||||
Future<void> confirm(String id) async {
|
||||
await _api.post('/api/medications/$id/confirm');
|
||||
}
|
||||
}
|
||||
|
||||
/// 饮食服务
|
||||
class DietService {
|
||||
final ApiClient _api;
|
||||
DietService(this._api);
|
||||
|
||||
Future<List<Map<String, dynamic>>> getRecords({String? date, String? mealType}) async {
|
||||
final params = <String, dynamic>{};
|
||||
if (date != null) params['date'] = date;
|
||||
if (mealType != null) params['mealType'] = mealType;
|
||||
final res = await _api.get('/api/diet-records', queryParameters: params);
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await _api.post('/api/diet-records', data: data);
|
||||
}
|
||||
}
|
||||
|
||||
/// 问诊服务
|
||||
class ConsultationService {
|
||||
final ApiClient _api;
|
||||
ConsultationService(this._api);
|
||||
|
||||
Future<List<Map<String, dynamic>>> getDoctors() async {
|
||||
final res = await _api.get('/doctors');
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getQuota() async {
|
||||
final res = await _api.get('/user/consultation-quota');
|
||||
return res.data['data'] ?? {};
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createConsultation(String doctorId) async {
|
||||
final res = await _api.post('/consultations', data: {'doctorId': doctorId});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getMessages(String consultationId, {String? after}) async {
|
||||
final params = <String, dynamic>{};
|
||||
if (after != null) params['after'] = after;
|
||||
final res = await _api.get('/consultations/$consultationId/messages', queryParameters: params);
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String consultationId, String content) async {
|
||||
await _api.post('/consultations/$consultationId/messages', data: {'content': content});
|
||||
}
|
||||
}
|
||||
|
||||
/// 运动服务
|
||||
class ExerciseService {
|
||||
final ApiClient _api;
|
||||
ExerciseService(this._api);
|
||||
|
||||
Future<Map<String, dynamic>?> getCurrentPlan() async {
|
||||
final res = await _api.get('/exercise-plans/current');
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
Future<void> createPlan(Map<String, dynamic> data) async {
|
||||
await _api.post('/exercise-plans', data: data);
|
||||
}
|
||||
|
||||
Future<void> checkIn(String itemId) async {
|
||||
await _api.post('/exercise-plans/items/$itemId/checkin');
|
||||
}
|
||||
}
|
||||
84
health_app/lib/utils/sse_handler.dart
Normal file
84
health_app/lib/utils/sse_handler.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api_client.dart';
|
||||
|
||||
/// 跨平台 SSE 流处理(基于 Dio 流式响应,支持 Android/iOS/Web)
|
||||
class SseHandler {
|
||||
/// 连接 SSE 端点,返回事件流
|
||||
static Stream<Map<String, dynamic>> connect({
|
||||
required String agentType,
|
||||
required String message,
|
||||
String? conversationId,
|
||||
required String token,
|
||||
}) {
|
||||
final params = <String, String>{
|
||||
'message': message,
|
||||
'token': token,
|
||||
};
|
||||
if (conversationId != null) {
|
||||
params['conversationId'] = conversationId;
|
||||
}
|
||||
final query = params.entries
|
||||
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
final url = '$baseUrl/api/ai/$agentType/chat?$query';
|
||||
|
||||
final controller = StreamController<Map<String, dynamic>>();
|
||||
_connect(controller, url);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
static Future<void> _connect(
|
||||
StreamController<Map<String, dynamic>> controller,
|
||||
String url,
|
||||
) async {
|
||||
try {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
));
|
||||
|
||||
final response = await dio.get(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.stream),
|
||||
);
|
||||
|
||||
final stream = response.data.stream as Stream<List<int>>;
|
||||
var buffer = '';
|
||||
|
||||
await for (final chunk in stream) {
|
||||
if (controller.isClosed) break;
|
||||
final text = utf8.decode(chunk, allowMalformed: true);
|
||||
buffer += text;
|
||||
|
||||
// 按行解析 SSE 数据
|
||||
while (buffer.contains('\n')) {
|
||||
final newlineIdx = buffer.indexOf('\n');
|
||||
var line = buffer.substring(0, newlineIdx).trim();
|
||||
buffer = buffer.substring(newlineIdx + 1);
|
||||
|
||||
if (line.isEmpty || !line.startsWith('data: ')) continue;
|
||||
final data = line.substring(6);
|
||||
|
||||
if (data == '[DONE]') {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
controller.add(jsonDecode(data) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
// 跳过无法解析的行
|
||||
}
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
} catch (e) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add({'action': 'error', 'message': e.toString()});
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
health_app/lib/widgets/agent_bar.dart
Normal file
61
health_app/lib/widgets/agent_bar.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/chat_provider.dart';
|
||||
|
||||
/// 智能体胶囊栏——横向滑动
|
||||
class AgentBar extends ConsumerWidget {
|
||||
const AgentBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selected = ref.watch(selectedAgentProvider);
|
||||
final chatNotifier = ref.read(chatProvider.notifier);
|
||||
|
||||
void onTap(ActiveAgent agent) {
|
||||
final notifier = ref.read(selectedAgentProvider.notifier);
|
||||
notifier.select(agent == selected ? null : agent);
|
||||
chatNotifier.setAgent(agent);
|
||||
}
|
||||
|
||||
return Container(
|
||||
height: 48,
|
||||
color: Colors.white,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
_buildCapsule('AI问诊', Icons.medical_services, ActiveAgent.consultation, selected, onTap),
|
||||
_buildCapsule('记数据', Icons.edit_note, ActiveAgent.health, selected, onTap),
|
||||
_buildCapsule('拍饮食', Icons.restaurant, ActiveAgent.diet, selected, onTap),
|
||||
_buildCapsule('药管家', Icons.medication, ActiveAgent.medication, selected, onTap),
|
||||
_buildCapsule('看报告', Icons.description, ActiveAgent.report, selected, onTap),
|
||||
_buildCapsule('运动计划', Icons.fitness_center, ActiveAgent.exercise, selected, onTap),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCapsule(String label, IconData icon, ActiveAgent agent, ActiveAgent? selected, void Function(ActiveAgent) onTap) {
|
||||
final isSelected = selected == agent;
|
||||
return GestureDetector(
|
||||
onTap: () => onTap(agent),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF635BFF) : Colors.white,
|
||||
border: Border.all(color: const Color(0xFF635BFF)),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF635BFF)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF635BFF))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
health_app/lib/widgets/health_drawer.dart
Normal file
119
health_app/lib/widgets/health_drawer.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
|
||||
/// 侧滑抽屉——健康概览 + 历史对话 + 菜单
|
||||
class HealthDrawer extends ConsumerWidget {
|
||||
const HealthDrawer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final user = auth.user;
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 用户信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/profile'),
|
||||
child: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: const Color(0xFFEDEBFF),
|
||||
child: Icon(Icons.person, size: 32, color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(user?.name ?? '未设置昵称', style: Theme.of(context).textTheme.titleMedium),
|
||||
if (user != null) const SizedBox(height: 4),
|
||||
Text(user?.phone ?? '', style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => context.push('/settings')),
|
||||
const Divider(),
|
||||
|
||||
// 健康概览——接真实数据
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
latestHealth.when(
|
||||
data: (data) => Column(children: [
|
||||
_HealthMetric(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => context.push('/trend/blood_pressure')),
|
||||
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], '次/分'), onTap: () => context.push('/trend/heart_rate')),
|
||||
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], 'mmol/L'), onTap: () => context.push('/trend/glucose')),
|
||||
_HealthMetric(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => context.push('/trend/spo2')),
|
||||
]),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
|
||||
error: (_, _) => Column(children: [
|
||||
_HealthMetric(icon: Icons.favorite, label: '血压', value: '--'),
|
||||
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: '--'),
|
||||
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: '--'),
|
||||
_HealthMetric(icon: Icons.air, label: '血氧', value: '--'),
|
||||
]),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const Expanded(child: Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14)))),
|
||||
|
||||
const Divider(),
|
||||
_DrawerItem(icon: Icons.logout, label: '退出登录', 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'); }
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _bpText(dynamic bp) {
|
||||
if (bp == null) return '--';
|
||||
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
||||
return '--';
|
||||
}
|
||||
|
||||
String _metricText(dynamic metric, String unit) {
|
||||
if (metric == null) return '--';
|
||||
if (metric is Map) {
|
||||
final v = metric['value'];
|
||||
return v != null ? '$v $unit' : '--';
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
}
|
||||
|
||||
class _DrawerItem extends StatelessWidget {
|
||||
final IconData icon; final String label; final VoidCallback onTap;
|
||||
const _DrawerItem({required this.icon, required this.label, required this.onTap});
|
||||
@override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true);
|
||||
}
|
||||
|
||||
class _HealthMetric extends StatelessWidget {
|
||||
final IconData icon; final String label; final String value; final VoidCallback? onTap;
|
||||
const _HealthMetric({required this.icon, required this.label, required this.value, this.onTap});
|
||||
@override Widget build(BuildContext context) => ListTile(
|
||||
leading: Icon(icon, size: 18, color: const Color(0xFF635BFF)),
|
||||
title: Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
||||
trailing: Text(value, style: TextStyle(fontSize: 16, color: value == '--' ? const Color(0xFF999999) : const Color(0xFF1A1A1A))),
|
||||
dense: true,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user