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

- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心
- 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles
- 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
This commit is contained in:
MingNian
2026-07-08 21:25:07 +08:00
parent 7a93237069
commit 1c020b8ae5
45 changed files with 3480 additions and 1000 deletions

View File

@@ -118,6 +118,13 @@ class _RootNavigator extends ConsumerWidget {
}
});
}
if (!authState.isLoading &&
!authState.isLoggedIn &&
current.name != 'login') {
WidgetsBinding.instance.addPostFrameCallback((_) {
goRoute(ref, 'login');
});
}
return PopScope(
canPop: stack.length <= 1,

View File

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://192.168.10.139:5000',
defaultValue: 'http://10.4.212.224:5000',
);
class ApiException implements Exception {
@@ -19,21 +19,46 @@ class ApiException implements Exception {
String toString() => message;
}
typedef AuthExpiredListener = void Function();
class AuthExpiredNotifier {
final List<AuthExpiredListener> _listeners = [];
AuthExpiredListener addListener(AuthExpiredListener listener) {
_listeners.add(listener);
return () => _listeners.remove(listener);
}
void notify() {
for (final listener in List<AuthExpiredListener>.from(_listeners)) {
listener();
}
}
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient {
final Dio _dio;
final LocalDatabase _db;
final AuthExpiredNotifier? _authExpiredNotifier;
ApiClient({required LocalDatabase db})
: _db = db,
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
)) {
ApiClient({
required LocalDatabase db,
AuthExpiredNotifier? authExpiredNotifier,
}) : _db = db,
_authExpiredNotifier = authExpiredNotifier,
_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: false, responseBody: false));
_dio.interceptors.add(
LogInterceptor(requestBody: false, responseBody: false),
);
}
Dio get dio => _dio;
@@ -51,8 +76,15 @@ class ApiClient {
await _db.delete('refresh_token');
}
void notifyAuthExpired() {
_authExpiredNotifier?.notify();
}
/// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
Future<Response> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
return _request(_dio.get(path, queryParameters: queryParameters));
}
@@ -72,9 +104,16 @@ class ApiClient {
}
/// 上传文件multipart返回文件 URL
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
Future<String?> uploadFile(
String path,
File file, {
String fieldName = 'file',
}) async {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
fieldName: await MultipartFile.fromFile(
file.path,
filename: file.path.split('/').last,
),
});
final res = await _request(_dio.post(path, data: formData));
final data = res.data;
@@ -118,14 +157,15 @@ class ApiClient {
return _ensureBusinessSuccess(await request);
} on DioException catch (error) {
final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
final message =
body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null;
throw ApiException(
message,
@@ -144,7 +184,10 @@ class _AuthInterceptor extends Interceptor {
_AuthInterceptor(this._client);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (!options.path.contains('/auth/')) {
final token = await _client.accessToken;
if (token != null) {
@@ -160,20 +203,26 @@ class _AuthInterceptor extends Interceptor {
final refresh = await _client.refreshToken;
if (refresh != null) {
try {
final response = await Dio(BaseOptions(baseUrl: baseUrl))
.post('/api/auth/refresh', data: {'refreshToken': refresh});
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);
final retryResponse = await Dio(
BaseOptions(baseUrl: baseUrl),
).fetch(opts);
return handler.resolve(retryResponse);
}
} catch (e) { log('[ApiClient] token刷新失败: $e'); }
} catch (e) {
log('[ApiClient] token刷新失败: $e');
}
}
await _client.clearTokens();
_client.notifyAuthExpired();
}
handler.next(err);
}

View File

@@ -20,6 +20,37 @@ class AppColors {
static const Color meadowAccent = Color(0xFFBAE6FD);
static const Color sageAccent = Color(0xFFE9D5FF);
static const Color health = Color(0xFF3B82F6);
static const Color healthLight = Color(0xFFEFF6FF);
static const Color healthBorder = Color(0xFFBFDBFE);
static const Color medication = Color(0xFF8B5CF6);
static const Color medicationLight = Color(0xFFF5F3FF);
static const Color medicationBorder = Color(0xFFDDD6FE);
static const Color exercise = Color(0xFF10B981);
static const Color exerciseLight = Color(0xFFECFDF5);
static const Color exerciseBorder = Color(0xFFA7F3D0);
static const Color report = Color(0xFF2563EB);
static const Color reportLight = Color(0xFFEEF2FF);
static const Color reportBorder = Color(0xFFC7D2FE);
static const Color diet = Color(0xFFF97316);
static const Color dietLight = Color(0xFFFFF7ED);
static const Color dietBorder = Color(0xFFFED7AA);
static const Color device = Color(0xFF06B6D4);
static const Color deviceLight = Color(0xFFECFEFF);
static const Color deviceBorder = Color(0xFFA5F3FC);
static const Color notification = Color(0xFF7C5CFF);
static const Color notificationLight = Color(0xFFF5F3FF);
static const Color notificationBorder = Color(0xFFDDD6FE);
static const Color doctor = Color(0xFF0F766E);
static const Color doctorLight = Color(0xFFF0FDFA);
static const Color doctorBorder = Color(0xFF99F6E4);
static const Color calendar = Color(0xFF6366F1);
static const Color calendarLight = Color(0xFFEEF2FF);
static const Color calendarBorder = Color(0xFFC7D2FE);
static const Color followup = Color(0xFFDB2777);
static const Color followupLight = Color(0xFFFDF2F8);
static const Color followupBorder = Color(0xFFFBCFE8);
static const Color background = Color(0xFFF8FAFF);
static const Color backgroundSoft = Color(0xFFFFFFFF);
static const Color cardBackground = Color(0xFFFFFFFF);
@@ -131,6 +162,66 @@ class AppColors {
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
);
static const LinearGradient healthGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
);
static const LinearGradient medicationGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
);
static const LinearGradient exerciseGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF34D399), Color(0xFF059669)],
);
static const LinearGradient reportGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
);
static const LinearGradient dietGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
);
static const LinearGradient deviceGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
);
static const LinearGradient notificationGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)],
);
static const LinearGradient doctorCareGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF14B8A6), Color(0xFF0F766E)],
);
static const LinearGradient calendarGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF818CF8), Color(0xFF4F46E5)],
);
static const LinearGradient followupGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF472B6), Color(0xFFDB2777)],
);
static List<BoxShadow> get cardShadow => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07),

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppSpacing {
AppSpacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 24;
static const EdgeInsets page = EdgeInsets.fromLTRB(16, 12, 16, 24);
static const EdgeInsets pageWithFab = EdgeInsets.fromLTRB(16, 12, 16, 88);
static const EdgeInsets panel = EdgeInsets.all(16);
static const EdgeInsets listItem = EdgeInsets.all(16);
static const EdgeInsets sheet = EdgeInsets.fromLTRB(20, 18, 20, 24);
static const EdgeInsets drawer = EdgeInsets.symmetric(horizontal: 12);
}
class AppRadius {
AppRadius._();
static const double xs = 6;
static const double sm = 10;
static const double md = 14;
static const double lg = 16;
static const double xl = 20;
static const double card = 24;
static const double pill = 999;
static BorderRadius get xsBorder => BorderRadius.circular(xs);
static BorderRadius get smBorder => BorderRadius.circular(sm);
static BorderRadius get mdBorder => BorderRadius.circular(md);
static BorderRadius get lgBorder => BorderRadius.circular(lg);
static BorderRadius get xlBorder => BorderRadius.circular(xl);
static BorderRadius get cardBorder => BorderRadius.circular(card);
static BorderRadius get pillBorder => BorderRadius.circular(pill);
}
class AppShadows {
AppShadows._();
static List<BoxShadow> get none => const [];
static List<BoxShadow> get soft => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 14,
offset: const Offset(0, 6),
),
];
static List<BoxShadow> get panel => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07),
blurRadius: 22,
offset: const Offset(0, 10),
),
];
static List<BoxShadow> get floating => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.14),
blurRadius: 28,
offset: const Offset(0, 14),
),
];
}
class AppTextStyles {
AppTextStyles._();
static const TextStyle appBarTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle summaryTitle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
height: 1.2,
);
static const TextStyle summarySubtitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
height: 1.35,
);
static const TextStyle sectionTitle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle listTitle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.22,
);
static const TextStyle primaryListTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.22,
);
static const TextStyle listSubtitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
height: 1.35,
);
static const TextStyle formLabel = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
);
static const TextStyle formValue = TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
static const TextStyle tag = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
height: 1.15,
);
static const TextStyle button = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
);
static const TextStyle miniButton = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
);
static const TextStyle chatBody = TextStyle(
fontSize: 18,
height: 1.5,
color: AppColors.textPrimary,
);
static const TextStyle chatTitle = TextStyle(
fontSize: 20,
height: 1.35,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle aiNote = TextStyle(
fontSize: 13,
color: AppColors.textHint,
height: 1.25,
);
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'app_colors.dart';
enum AppModule {
ai,
health,
medication,
exercise,
report,
diet,
device,
notification,
doctor,
calendar,
followup,
}
class AppModuleVisual {
final AppModule module;
final String label;
final IconData icon;
final Color color;
final Color lightColor;
final Color borderColor;
final LinearGradient gradient;
const AppModuleVisual({
required this.module,
required this.label,
required this.icon,
required this.color,
required this.lightColor,
required this.borderColor,
required this.gradient,
});
}
class AppModuleVisuals {
AppModuleVisuals._();
static const ai = AppModuleVisual(
module: AppModule.ai,
label: 'AI',
icon: LucideIcons.bot,
color: AppColors.primary,
lightColor: AppColors.primarySoft,
borderColor: AppColors.primaryLight,
gradient: AppColors.primaryGradient,
);
static const health = AppModuleVisual(
module: AppModule.health,
label: '健康',
icon: LucideIcons.heartPulse,
color: AppColors.health,
lightColor: AppColors.healthLight,
borderColor: AppColors.healthBorder,
gradient: AppColors.healthGradient,
);
static const medication = AppModuleVisual(
module: AppModule.medication,
label: '用药',
icon: LucideIcons.pill,
color: AppColors.medication,
lightColor: AppColors.medicationLight,
borderColor: AppColors.medicationBorder,
gradient: AppColors.medicationGradient,
);
static const exercise = AppModuleVisual(
module: AppModule.exercise,
label: '运动',
icon: LucideIcons.footprints,
color: AppColors.exercise,
lightColor: AppColors.exerciseLight,
borderColor: AppColors.exerciseBorder,
gradient: AppColors.exerciseGradient,
);
static const report = AppModuleVisual(
module: AppModule.report,
label: '报告',
icon: LucideIcons.fileText,
color: AppColors.report,
lightColor: AppColors.reportLight,
borderColor: AppColors.reportBorder,
gradient: AppColors.reportGradient,
);
static const diet = AppModuleVisual(
module: AppModule.diet,
label: '饮食',
icon: LucideIcons.utensils,
color: AppColors.diet,
lightColor: AppColors.dietLight,
borderColor: AppColors.dietBorder,
gradient: AppColors.dietGradient,
);
static const device = AppModuleVisual(
module: AppModule.device,
label: '设备',
icon: LucideIcons.bluetooth,
color: AppColors.device,
lightColor: AppColors.deviceLight,
borderColor: AppColors.deviceBorder,
gradient: AppColors.deviceGradient,
);
static const notification = AppModuleVisual(
module: AppModule.notification,
label: '通知',
icon: LucideIcons.bell,
color: AppColors.notification,
lightColor: AppColors.notificationLight,
borderColor: AppColors.notificationBorder,
gradient: AppColors.notificationGradient,
);
static const doctor = AppModuleVisual(
module: AppModule.doctor,
label: '医生',
icon: LucideIcons.stethoscope,
color: AppColors.doctor,
lightColor: AppColors.doctorLight,
borderColor: AppColors.doctorBorder,
gradient: AppColors.doctorCareGradient,
);
static const calendar = AppModuleVisual(
module: AppModule.calendar,
label: '日历',
icon: LucideIcons.calendarDays,
color: AppColors.calendar,
lightColor: AppColors.calendarLight,
borderColor: AppColors.calendarBorder,
gradient: AppColors.calendarGradient,
);
static const followup = AppModuleVisual(
module: AppModule.followup,
label: '随访',
icon: LucideIcons.calendarCheck,
color: AppColors.followup,
lightColor: AppColors.followupLight,
borderColor: AppColors.followupBorder,
gradient: AppColors.followupGradient,
);
static const values = <AppModule, AppModuleVisual>{
AppModule.ai: ai,
AppModule.health: health,
AppModule.medication: medication,
AppModule.exercise: exercise,
AppModule.report: report,
AppModule.diet: diet,
AppModule.device: device,
AppModule.notification: notification,
AppModule.doctor: doctor,
AppModule.calendar: calendar,
AppModule.followup: followup,
};
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
}

View File

@@ -28,6 +28,18 @@ import '../pages/doctor/doctor_profile_page.dart';
import '../pages/admin/admin_home_page.dart';
import '../pages/admin/admin_add_doctor_page.dart';
import '../providers/auth_provider.dart' show userRoleProvider;
import '../widgets/app_error_state.dart';
Widget _missingParamPage() {
return const Scaffold(
body: AppErrorState(title: '页面参数错误', subtitle: '缺少打开页面所需的信息,请返回后重试'),
);
}
String? _requiredParam(Map<String, String> params, String key) {
final value = params[key]?.trim();
return value == null || value.isEmpty ? null : value;
}
/// 根据路由信息返回对应页面
Widget buildPage(RouteInfo route, WidgetRef ref) {
@@ -59,7 +71,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'reports':
return const ReportListPage();
case 'aiAnalysis':
return AiAnalysisPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
case 'reportOriginal':
return ReportOriginalPage(
url: params['url'] ?? '',
@@ -82,13 +95,17 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'doctorSettings':
return const DoctorSettingsPage();
case 'doctorPatientDetail':
return DoctorPatientDetailPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorPatientDetailPage(id: id);
case 'doctorChat':
return DoctorChatPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorChatPage(id: id);
case 'doctorReportDetail':
return DoctorReportDetailPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
case 'doctorFollowUpEdit':
return DoctorFollowUpEditPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
case 'devices':
return const DeviceManagementPage();
case 'deviceScan':
@@ -106,9 +123,11 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'conversationHistory':
return const ConversationHistoryPage();
case 'staticText':
return StaticTextPage(type: params['type']!);
final type = _requiredParam(params, 'type');
return type == null ? _missingParamPage() : StaticTextPage(type: type);
case 'dietDetail':
return DietRecordDetailPage(id: params['id']!);
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DietRecordDetailPage(id: id);
default:
return const LoginPage();
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../models/ble_device.dart';
@@ -15,6 +16,9 @@ import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart';
import '../../services/health_ble_service.dart';
import '../../widgets/ble_sync_dialog.dart';
import '../../widgets/app_toast.dart';
const _deviceVisual = AppModuleVisuals.device;
class DeviceManagementPage extends ConsumerStatefulWidget {
const DeviceManagementPage({super.key});
@@ -39,6 +43,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
DateTime? _scanStartedAt;
String _activeScanBoundKey = '';
bool _scanning = false;
bool _permissionBlocked = false;
bool _disposed = false;
@override
@@ -66,18 +71,20 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
}
Future<void> _startForegroundScan() async {
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
if (!await _ensureBlePermissions()) {
if (mounted) setState(() => _scanning = false);
return;
}
if (_disposed || !mounted) return;
if (Platform.isAndroid) {
final locStatus = await Permission.locationWhenInUse.serviceStatus;
if (_disposed || !mounted) return;
if (locStatus != ServiceStatus.enabled && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
backgroundColor: AppColors.warning,
),
AppToast.show(
context,
'请先打开定位服务,否则可能无法发现蓝牙设备',
type: AppToastType.warning,
);
}
}
@@ -120,6 +127,42 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
}
}
Future<bool> _ensureBlePermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
final granted = statuses.values.every((status) => status.isGranted);
if (granted) {
_permissionBlocked = false;
return true;
}
_permissionBlocked = true;
if (_disposed || !mounted) return false;
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('需要蓝牙权限'),
content: const Text('同步已绑定设备需要蓝牙权限,请在系统设置中开启后重试。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('稍后再说'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
openAppSettings();
},
child: const Text('去设置'),
),
],
),
);
return false;
}
void _onScanResults(List<ScanResult> results) {
if (!mounted || _busyDeviceId != null) return;
final scanStartedAt = _scanStartedAt;
@@ -213,21 +256,11 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
}
} on TimeoutException {
if (!_disposed && mounted && _connectedDeviceId == bound.id) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('已连接设备,但本次未收到测量数据'),
backgroundColor: AppColors.warning,
),
);
AppToast.show(context, '已连接设备,但本次未收到测量数据', type: AppToastType.warning);
}
} on Exception {
if (!_disposed && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('数据录入失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '数据录入失败,请稍后重试', type: AppToastType.error);
}
} finally {
await _bleService.disconnect();
@@ -272,6 +305,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
if (devices.isNotEmpty &&
!_scanning &&
_busyDeviceId == null &&
!_permissionBlocked &&
_activeScanBoundKey != boundKey) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed && mounted) unawaited(_startForegroundScan());
@@ -288,6 +322,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
return;
}
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
if (_permissionBlocked) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_disposed && mounted) unawaited(_startForegroundScan());
});
@@ -308,8 +343,8 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
tooltip: '新增设备',
onPressed: () => pushRoute(ref, 'deviceScan'),
style: IconButton.styleFrom(
backgroundColor: const Color(0xFFEFF6FF),
foregroundColor: const Color(0xFF2563EB),
backgroundColor: _deviceVisual.lightColor,
foregroundColor: _deviceVisual.color,
fixedSize: const Size(40, 40),
),
icon: const Icon(Icons.add_rounded),
@@ -402,11 +437,7 @@ class _DevicesHeader extends StatelessWidget {
width: 50,
height: 50,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
),
gradient: _deviceVisual.gradient,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
@@ -474,15 +505,11 @@ class _DeviceSection extends StatelessWidget {
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
color: _deviceVisual.lightColor,
borderRadius: BorderRadius.circular(11),
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
type.icon,
size: 19,
color: const Color(0xFF2563EB),
),
child: Icon(type.icon, size: 19, color: _deviceVisual.color),
),
const SizedBox(width: 10),
Text(

View File

@@ -14,6 +14,7 @@ import '../../models/bp_reading.dart';
import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart';
import '../../services/health_ble_service.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/ble_sync_dialog.dart';
class DeviceScanPage extends ConsumerStatefulWidget {
@@ -71,16 +72,18 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_results.clear();
});
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
if (!await _ensureBlePermissions()) {
if (mounted) setState(() => _scanning = false);
return;
}
if (Platform.isAndroid) {
final locStatus = await Permission.locationWhenInUse.serviceStatus;
if (locStatus != ServiceStatus.enabled && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
backgroundColor: AppColors.warning,
),
AppToast.show(
context,
'请先打开定位服务,否则可能无法发现蓝牙设备',
type: AppToastType.warning,
);
}
}
@@ -183,16 +186,12 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态')));
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('该设备已绑定')));
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
return;
}
setState(() => _connectingId = remoteId);
@@ -231,31 +230,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
if (mounted) popRoute(ref);
} on TimeoutException {
if (mounted && boundDevice != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')),
AppToast.show(
context,
'${boundDevice.type.label}已绑定,但本次未收到数据',
type: AppToastType.warning,
);
popRoute(ref);
} else if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('连接超时,请确认设备仍处于通信状态'),
backgroundColor: AppColors.error,
),
);
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
unawaited(_startScan());
}
} on UnsupportedBleDeviceException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
AppToast.show(context, e.message, type: AppToastType.error);
unawaited(_startScan());
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error),
);
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
unawaited(_startScan());
}
} finally {
@@ -334,6 +326,38 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
return true;
}
Future<bool> _ensureBlePermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
final granted = statuses.values.every((status) => status.isGranted);
if (granted) return true;
if (!mounted) return false;
AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('需要蓝牙权限'),
content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('稍后再说'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
openAppSettings();
},
child: const Text('去设置'),
),
],
),
);
return false;
}
}
class _ScanningEmpty extends StatelessWidget {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,13 @@ class UserInfo {
final String? name;
final String? avatarUrl;
UserInfo({required this.id, required this.phone, this.role = 'User', this.name, this.avatarUrl});
UserInfo({
required this.id,
required this.phone,
this.role = 'User',
this.name,
this.avatarUrl,
});
}
class AuthState {
@@ -23,17 +29,34 @@ class AuthState {
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
}
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
final authProvider = NotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
final localDbProvider = Provider<LocalDatabase>((ref) => LocalDatabase.instance);
final localDbProvider = Provider<LocalDatabase>(
(ref) => LocalDatabase.instance,
);
final authExpiredNotifierProvider = Provider<AuthExpiredNotifier>((ref) {
return AuthExpiredNotifier();
});
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(db: ref.watch(localDbProvider));
return ApiClient(
db: ref.watch(localDbProvider),
authExpiredNotifier: ref.watch(authExpiredNotifierProvider),
);
});
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() {
final removeListener = ref.read(authExpiredNotifierProvider).addListener(
() {
state = const AuthState(isLoggedIn: false, isLoading: false);
},
);
ref.onDispose(removeListener);
_checkAuth();
// 初始为"加载中",让启动闸门显示 Splash 盖住登录页/首页初始态
return const AuthState(isLoggedIn: false, isLoading: true);
@@ -50,14 +73,17 @@ class AuthNotifier extends Notifier<AuthState> {
state = const AuthState(isLoading: true);
try {
final response = await Dio(BaseOptions(baseUrl: baseUrl))
.post('/api/auth/refresh', data: {'refreshToken': refresh});
final response = await Dio(
BaseOptions(baseUrl: baseUrl),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final data = response.data['data'];
if (data != null) {
await db.write('access_token', data['accessToken']);
await db.write('refresh_token', data['refreshToken']);
final u = data['user'] as Map<String, dynamic>?;
state = AuthState(isLoggedIn: true, isLoading: false,
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'),
);
_loadProfile();
@@ -77,11 +103,15 @@ class AuthNotifier extends Notifier<AuthState> {
final response = await api.get('/api/user/profile');
final user = response.data['data'];
if (user != null) {
state = AuthState(isLoggedIn: true, isLoading: false,
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '', phone: user['phone'] ?? '',
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? state.user?.role ?? 'User',
name: user['name'], avatarUrl: user['avatarUrl'],
name: user['name'],
avatarUrl: user['avatarUrl'],
),
);
}
@@ -96,27 +126,50 @@ class AuthNotifier extends Notifier<AuthState> {
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});
return (error: null, devCode: response.data['data']?['devCode'] as String?);
final response = await api.post(
'/api/auth/send-sms',
data: {'phone': phone},
);
return (
error: null,
devCode: response.data['data']?['devCode'] as String?,
);
} catch (e) {
return (error: '发送失败: $e', devCode: null);
}
}
/// 注册(新用户,需选身份)
Future<String?> register(String phone, String code, String name, String doctorId) async {
Future<String?> register(
String phone,
String code,
String name,
String doctorId,
) async {
try {
final api = ref.read(apiClientProvider);
final response = await api.post('/api/auth/register', data: {
'phone': phone, 'smsCode': code, 'name': name, 'doctorId': doctorId,
});
final response = await api.post(
'/api/auth/register',
data: {
'phone': phone,
'smsCode': code,
'name': name,
'doctorId': doctorId,
},
);
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'] ?? '', role: user['role'] ?? 'User'),
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
),
);
return null;
} catch (e) {
@@ -128,16 +181,23 @@ class AuthNotifier extends Notifier<AuthState> {
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 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,
state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '', phone: user['phone'] ?? '',
role: user['role'] ?? 'User', name: user['name'],
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
name: user['name'],
avatarUrl: user['avatarUrl'],
),
);
@@ -153,7 +213,11 @@ class AuthNotifier extends Notifier<AuthState> {
final db = ref.read(localDbProvider);
final refresh = await db.read('refresh_token');
if (refresh != null) {
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { log('[Auth] logout: $e'); }
try {
await api.post('/api/auth/logout', data: {'refreshToken': refresh});
} catch (e) {
log('[Auth] logout: $e');
}
}
await api.clearTokens();
state = const AuthState(isLoggedIn: false, isLoading: false);

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
class AiContent {
AiContent._();
static String clean(String text) {
var t = text;
t = t.replaceAll(RegExp(r'\$\d+'), '');
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### ');
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return t.trim();
}
static String plain(String text) {
final cleaned = clean(text);
return cleaned
.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'$1')
.replaceAll(RegExp(r'__(.*?)__'), r'$1')
.replaceAll(RegExp(r'`([^`]+)`'), r'$1')
.replaceAll(RegExp(r'^\s*[-*]\s+', multiLine: true), '')
.trim();
}
}
class AiMarkdownView extends StatelessWidget {
final String data;
final bool selectable;
final void Function(String text, String? href, String title)? onTapLink;
const AiMarkdownView({
super.key,
required this.data,
this.selectable = true,
this.onTapLink,
});
@override
Widget build(BuildContext context) {
return MarkdownBody(
data: AiContent.clean(data),
selectable: selectable,
onTapLink: onTapLink,
styleSheet: MarkdownStyleSheet(
p: AppTextStyles.chatBody,
a: AppTextStyles.chatBody.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
decorationColor: AppColors.primary,
),
strong: AppTextStyles.chatBody.copyWith(fontWeight: FontWeight.w800),
h1: AppTextStyles.chatTitle.copyWith(fontSize: 20),
h2: AppTextStyles.chatTitle.copyWith(fontSize: 20),
h3: AppTextStyles.chatTitle.copyWith(fontSize: 19),
listBullet: AppTextStyles.chatBody.copyWith(
fontSize: 21,
fontWeight: FontWeight.w900,
),
listBulletPadding: const EdgeInsets.only(right: 8),
blockquote: AppTextStyles.chatBody.copyWith(color: AppColors.textSecondary),
),
);
}
}
class AiGeneratedNote extends StatelessWidget {
const AiGeneratedNote({super.key});
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(top: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome_rounded, size: 15, color: AppColors.textHint),
SizedBox(width: 5),
Text('内容由 AI 生成', style: AppTextStyles.aiNote),
],
),
);
}

View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
enum AppButtonVariant { primary, secondary, outline, ghost, danger, gradientOutline }
class AppButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final AppButtonVariant variant;
final Color? color;
final LinearGradient? gradient;
final bool loading;
final double height;
final bool expand;
const AppButton({
super.key,
required this.label,
this.onPressed,
this.icon,
this.variant = AppButtonVariant.primary,
this.color,
this.gradient,
this.loading = false,
this.height = 50,
this.expand = true,
});
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !loading;
final baseColor = color ?? AppColors.primary;
final child = _ButtonContent(
label: label,
icon: icon,
loading: loading,
foreground: _foreground(baseColor),
);
if (variant == AppButtonVariant.gradientOutline) {
return _GradientOutlineButton(
label: label,
icon: icon,
loading: loading,
enabled: enabled,
height: height,
expand: expand,
gradient: gradient ?? AppColors.actionOutlineGradient,
onPressed: onPressed,
);
}
final decoration = _decoration(baseColor, enabled);
final width = expand ? double.infinity : null;
return SizedBox(
height: height,
width: width,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: AppRadius.lgBorder,
child: Ink(
decoration: decoration,
child: Center(child: child),
),
),
),
);
}
Color _foreground(Color baseColor) {
return switch (variant) {
AppButtonVariant.primary => Colors.white,
AppButtonVariant.secondary => baseColor,
AppButtonVariant.outline => baseColor,
AppButtonVariant.ghost => baseColor,
AppButtonVariant.danger => variant == AppButtonVariant.danger && color == null
? AppColors.error
: Colors.white,
AppButtonVariant.gradientOutline => AppColors.textPrimary,
};
}
BoxDecoration _decoration(Color baseColor, bool enabled) {
final disabledColor = const Color(0xFFE5E7EB);
return switch (variant) {
AppButtonVariant.primary => BoxDecoration(
color: enabled ? baseColor : disabledColor,
borderRadius: AppRadius.lgBorder,
boxShadow: enabled ? AppShadows.soft : AppShadows.none,
),
AppButtonVariant.secondary => BoxDecoration(
color: enabled ? baseColor.withValues(alpha: 0.10) : disabledColor,
borderRadius: AppRadius.lgBorder,
),
AppButtonVariant.outline => BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: enabled ? baseColor.withValues(alpha: 0.38) : disabledColor),
),
AppButtonVariant.ghost => BoxDecoration(
color: enabled ? baseColor.withValues(alpha: 0.08) : disabledColor,
borderRadius: AppRadius.lgBorder,
),
AppButtonVariant.danger => BoxDecoration(
color: enabled ? (color ?? AppColors.error) : disabledColor,
borderRadius: AppRadius.lgBorder,
),
AppButtonVariant.gradientOutline => const BoxDecoration(),
};
}
}
class _ButtonContent extends StatelessWidget {
final String label;
final IconData? icon;
final bool loading;
final Color foreground;
const _ButtonContent({
required this.label,
required this.icon,
required this.loading,
required this.foreground,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (loading)
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: foreground),
)
else if (icon != null)
Icon(icon, size: 19, color: foreground),
if (loading || icon != null) const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.button.copyWith(color: foreground),
),
),
],
);
}
}
class _GradientOutlineButton extends StatelessWidget {
final String label;
final IconData? icon;
final bool loading;
final bool enabled;
final double height;
final bool expand;
final LinearGradient gradient;
final VoidCallback? onPressed;
const _GradientOutlineButton({
required this.label,
required this.icon,
required this.loading,
required this.enabled,
required this.height,
required this.expand,
required this.gradient,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
width: expand ? double.infinity : null,
padding: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
gradient: enabled ? gradient : null,
color: enabled ? null : AppColors.borderLight,
borderRadius: AppRadius.lgBorder,
boxShadow: enabled ? AppShadows.soft : AppShadows.none,
),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.lg - 1.5),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(AppRadius.lg - 1.5),
child: Center(
child: _ButtonContent(
label: label,
icon: icon,
loading: loading,
foreground: AppColors.textPrimary,
),
),
),
),
);
}
}
class AppIconTile extends StatelessWidget {
final IconData icon;
final Color color;
final Color backgroundColor;
final double size;
final double iconSize;
final BorderRadius? borderRadius;
const AppIconTile({
super.key,
required this.icon,
required this.color,
required this.backgroundColor,
this.size = 44,
this.iconSize = 22,
this.borderRadius,
});
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: borderRadius ?? AppRadius.mdBorder,
border: Border.all(color: color.withValues(alpha: 0.16)),
),
child: Icon(icon, size: iconSize, color: color),
);
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
class AppStatusBadge extends StatelessWidget {
final String label;
final Color color;
final Color? backgroundColor;
final IconData? icon;
const AppStatusBadge({
super.key,
required this.label,
required this.color,
this.backgroundColor,
this.icon,
});
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minHeight: 24),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor ?? color.withValues(alpha: 0.10),
borderRadius: AppRadius.pillBorder,
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
],
Text(label, style: AppTextStyles.tag.copyWith(color: color)),
],
),
);
}
class AppStatusColors {
AppStatusColors._();
static Color done = AppColors.success;
static Color warning = AppColors.warning;
static Color danger = AppColors.error;
static Color info = AppColors.health;
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
enum AppToastType { success, error, warning, info }
class AppToast {
AppToast._();
static OverlayEntry? _entry;
static void show(
BuildContext context,
String message, {
AppToastType type = AppToastType.info,
Duration duration = const Duration(milliseconds: 2200),
}) {
_entry?.remove();
final overlay = Overlay.of(context);
final visual = _visual(type);
_entry = OverlayEntry(
builder: (ctx) => Positioned(
top: MediaQuery.paddingOf(ctx).top + 62,
left: 20,
right: 20,
child: IgnorePointer(
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
constraints: BoxConstraints(
minHeight: 42,
maxWidth: MediaQuery.sizeOf(ctx).width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: visual.color.withValues(alpha: 0.20)),
boxShadow: AppShadows.floating,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(visual.icon, size: 18, color: visual.color),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.25,
),
),
),
],
),
),
),
),
),
),
);
overlay.insert(_entry!);
Future.delayed(duration, () {
_entry?.remove();
_entry = null;
});
}
static ({IconData icon, Color color}) _visual(AppToastType type) {
return switch (type) {
AppToastType.success => (icon: LucideIcons.circleCheck, color: AppColors.success),
AppToastType.error => (icon: LucideIcons.circleX, color: AppColors.error),
AppToastType.warning => (icon: LucideIcons.triangleAlert, color: AppColors.warning),
AppToastType.info => (icon: LucideIcons.info, color: AppColors.health),
};
}
}

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/app_module_visuals.dart';
import '../models/bp_reading.dart';
Future<void> showBleSyncDialog(
@@ -17,31 +19,35 @@ Future<void> showBleSyncDialog(
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 380),
padding: const EdgeInsets.fromLTRB(22, 28, 22, 22),
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.14),
blurRadius: 34,
offset: const Offset(0, 18),
),
],
borderRadius: AppRadius.xlBorder,
boxShadow: AppShadows.floating,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'数据已录入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: AppModuleVisuals.device.gradient,
borderRadius: AppRadius.mdBorder,
),
child: Icon(
AppModuleVisuals.device.icon,
color: Colors.white,
size: 25,
),
),
const SizedBox(height: 24),
const SizedBox(height: 14),
Text(
'数据已录入',
textAlign: TextAlign.center,
style: AppTextStyles.summaryTitle.copyWith(fontSize: 22),
),
const SizedBox(height: 22),
Row(
children: [
Expanded(
@@ -51,7 +57,7 @@ Future<void> showBleSyncDialog(
unit: 'mmHg',
),
),
const SizedBox(width: 16),
const SizedBox(width: 14),
Expanded(
child: _MetricCard(
label: '舒张压',
@@ -62,7 +68,7 @@ Future<void> showBleSyncDialog(
],
),
if (reading.pulse != null) ...[
const SizedBox(height: 16),
const SizedBox(height: 14),
Row(
children: [
Expanded(
@@ -76,7 +82,7 @@ Future<void> showBleSyncDialog(
],
),
],
const SizedBox(height: 28),
const SizedBox(height: 26),
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: Container(
@@ -84,17 +90,13 @@ Future<void> showBleSyncDialog(
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: AppColors.doctorGradient,
borderRadius: BorderRadius.circular(18),
gradient: AppModuleVisuals.device.gradient,
borderRadius: AppRadius.lgBorder,
boxShadow: AppColors.buttonShadow,
),
child: const Text(
child: Text(
'知道了',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w900,
),
style: AppTextStyles.button.copyWith(color: Colors.white),
),
),
),
@@ -121,8 +123,9 @@ class _MetricCard extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
color: AppModuleVisuals.device.lightColor,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppModuleVisuals.device.borderColor),
),
child: Column(
children: [
@@ -136,23 +139,9 @@ class _MetricCard extends StatelessWidget {
),
),
const SizedBox(height: 6),
Text(
unit,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
Text(unit, style: AppTextStyles.tag),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
Text(label, style: AppTextStyles.tag),
],
),
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/app_theme.dart';
class EnterpriseStat {
@@ -35,12 +36,12 @@ class EnterpriseHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.xlBorder,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
boxShadow: AppShadows.soft,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -71,23 +72,14 @@ class EnterpriseHeader extends StatelessWidget {
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w900,
),
style: AppTextStyles.summaryTitle,
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.25,
),
style: AppTextStyles.summarySubtitle,
),
],
),
@@ -143,7 +135,7 @@ class _HeaderStat extends StatelessWidget {
accent.withValues(alpha: 0.06),
],
),
borderRadius: BorderRadius.circular(10),
borderRadius: AppRadius.smBorder,
border: Border.all(color: AppColors.borderLight, width: 1.1),
),
child: Row(

View File

@@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../providers/conversation_history_provider.dart';
import 'app_toast.dart';
import '../providers/data_providers.dart';
import 'drawer_shell.dart';
@@ -23,7 +26,7 @@ class HealthDrawer extends ConsumerWidget {
physics: const BouncingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
padding: const EdgeInsets.fromLTRB(12, 16, 12, 24),
sliver: SliverList.list(
children: [
_AccountHeader(user: auth.user, ref: ref),
@@ -305,71 +308,66 @@ class _NavigationSection extends StatelessWidget {
Widget build(BuildContext context) {
final items = [
_NavItem(
icon: Icons.folder_shared_rounded,
icon: LucideIcons.folderHeart,
title: '档案',
route: 'healthArchive',
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
),
_NavItem(
icon: Icons.description_rounded,
icon: LucideIcons.fileText,
title: '报告',
route: 'reports',
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
),
_NavItem(
icon: Icons.medication_rounded,
icon: LucideIcons.pill,
title: '用药',
route: 'medications',
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
),
_NavItem(
icon: Icons.restaurant_rounded,
icon: LucideIcons.utensils,
title: '饮食',
route: 'dietRecords',
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
),
_NavItem(
icon: Icons.calendar_month_rounded,
icon: LucideIcons.calendarDays,
title: '日历',
route: 'calendar',
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
),
_NavItem(
icon: Icons.event_available_rounded,
icon: LucideIcons.calendarCheck,
title: '随访',
route: 'followups',
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
),
_NavItem(
icon: Icons.directions_run_rounded,
icon: LucideIcons.footprints,
title: '运动',
route: 'exercisePlan',
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
),
_NavItem(
icon: Icons.bluetooth_connected_rounded,
title: '蓝牙设备',
icon: LucideIcons.bluetooth,
title: '设备',
route: 'devices',
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
),
];
return _Panel(
title: '功能入口',
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFAFFFFFF), Color(0xF5EFF6FF)],
),
return _LightSection(
title: '常用功能',
child: GridView.builder(
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisExtent: 98,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
crossAxisCount: 2,
mainAxisExtent: 48,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemBuilder: (context, index) {
final item = items[index];
@@ -398,14 +396,14 @@ class _NavTile extends StatelessWidget {
};
List<Color> get _colors => switch (item.route) {
'healthArchive' => const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
'reports' => const [Color(0xFF60A5FA), Color(0xFF2563EB)],
'medications' => const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
'dietRecords' => const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
'healthArchive' => const [AppColors.healthLight, AppColors.health],
'reports' => const [AppColors.reportLight, AppColors.report],
'medications' => const [AppColors.medicationLight, AppColors.medication],
'dietRecords' => const [AppColors.dietLight, AppColors.diet],
'calendar' => const [AppColors.calendarLight, AppColors.calendar],
'followups' => const [AppColors.followupLight, AppColors.followup],
'exercisePlan' => const [AppColors.exerciseLight, AppColors.exercise],
'devices' => const [AppColors.deviceLight, AppColors.device],
_ => item.colors,
};
@@ -413,39 +411,39 @@ class _NavTile extends StatelessWidget {
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
borderRadius: AppRadius.mdBorder,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight, width: 1.1),
boxShadow: AppColors.cardShadowLight,
color: Colors.white.withValues(alpha: 0.54),
borderRadius: AppRadius.mdBorder,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
child: Row(
children: [
Container(
width: 44,
height: 44,
width: 30,
height: 30,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
),
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.smBorder,
),
child: Icon(item.icon, color: Colors.white, size: 24),
child: Icon(item.icon, color: Colors.white, size: 19),
),
const SizedBox(height: 9),
Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
const SizedBox(width: 8),
Expanded(
child: Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
],
@@ -455,6 +453,39 @@ class _NavTile extends StatelessWidget {
}
}
class _LightSection extends StatelessWidget {
final String title;
final Widget child;
const _LightSection({required this.title, required this.child});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
),
],
),
),
const SizedBox(height: 10),
child,
],
);
}
class _Panel extends StatelessWidget {
final String title;
final Widget child;
@@ -484,18 +515,12 @@ class _Panel extends StatelessWidget {
const Color(0xFFF8F4FF).withValues(alpha: 0.84),
],
),
borderRadius: BorderRadius.circular(28),
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: Colors.white.withValues(alpha: 0.82),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5DF6).withValues(alpha: 0.08),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
boxShadow: AppShadows.soft,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -611,8 +636,6 @@ class _NavItem {
required this.colors,
});
}
/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。
class _HistorySection extends ConsumerWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
@@ -668,7 +691,7 @@ class _HistorySection extends ConsumerWidget {
item: preview[i],
isLast: i == preview.length - 1,
onTap: () async {
Navigator.of(context).maybePop(); // 关侧边栏
Navigator.of(context).maybePop(); // 关侧边栏
await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
@@ -779,11 +802,10 @@ class _HistoryActions extends StatelessWidget {
await ref.read(conversationHistoryProvider.notifier).clearAll();
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('清空失败,请稍后重试'),
backgroundColor: AppColors.error,
),
AppToast.show(
context,
'清空失败,请稍后重试',
type: AppToastType.error,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/core/api_client.dart';
void main() {
test('AuthExpiredNotifier notifies all current listeners once', () {
final notifier = AuthExpiredNotifier();
var first = 0;
var second = 0;
final removeFirst = notifier.addListener(() => first++);
notifier.addListener(() => second++);
notifier.notify();
removeFirst();
notifier.notify();
expect(first, 1);
expect(second, 2);
});
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health_app/core/app_router.dart';
import 'package:health_app/core/navigation_provider.dart';
void main() {
testWidgets(
'detail routes show an error instead of throwing when id is missing',
(tester) async {
await tester.pumpWidget(
ProviderScope(
child: Consumer(
builder: (context, ref, _) {
return MaterialApp(
home: buildPage(const RouteInfo('aiAnalysis'), ref),
);
},
),
),
);
expect(tester.takeException(), isNull);
expect(find.text('页面参数错误'), findsOneWidget);
},
);
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/pages/report/report_pages.dart';
void main() {
test('report polling backs off and stops after the retry budget', () {
expect(reportAnalysisPollDelay(1), const Duration(seconds: 4));
expect(reportAnalysisPollDelay(3), const Duration(seconds: 8));
expect(reportAnalysisPollDelay(6), const Duration(seconds: 12));
expect(reportAnalysisPollDelay(16), isNull);
});
}