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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
171
health_app/lib/core/app_design_tokens.dart
Normal file
171
health_app/lib/core/app_design_tokens.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
|
||||
168
health_app/lib/core/app_module_visuals.dart
Normal file
168
health_app/lib/core/app_module_visuals.dart
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() {});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('退出登录'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
87
health_app/lib/widgets/ai_content.dart
Normal file
87
health_app/lib/widgets/ai_content.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
242
health_app/lib/widgets/app_buttons.dart
Normal file
242
health_app/lib/widgets/app_buttons.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
|
||||
50
health_app/lib/widgets/app_status_badge.dart
Normal file
50
health_app/lib/widgets/app_status_badge.dart
Normal 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;
|
||||
}
|
||||
|
||||
86
health_app/lib/widgets/app_toast.dart
Normal file
86
health_app/lib/widgets/app_toast.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user