feat: Cupertino滚轮选择器 + 品牌更名 + UI全面优化

- 日期/时间/次数选择器统一改为底部弹出Cupertino滚轮,无单位标签,双横线选区
- App更名为"小脉健康",副标题"血管病患者的AI健康管理助手"
- 健康仪表盘及侧边栏移除体重指标,侧边栏背景改为蓝白渐变
- 健康档案按钮移入功能入口网格,与其他入口样式统一
- 记数据智能体配色改为绿色渐变(#A1FFCE→#69DB8F)
- 隐私协议/关于页从Text改为MarkdownBody正确渲染
- 运动计划/饮食记录卡片添加边框和阴影
- 服药次数从chip改为滚轮选择器
- 日期显示格式统一为空格分隔(1999 01 01)
- 健康档案页背景改为纯白
This commit is contained in:
MingNian
2026-06-22 13:59:37 +08:00
parent a479e5e95d
commit 431b72d49a
15 changed files with 466 additions and 165 deletions

View File

@@ -12,7 +12,7 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<application
android:label="health_app"
android:label="小脉健康"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity

View File

@@ -17,7 +17,7 @@ class HealthApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
title: '健康管家',
title: '小脉健康',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
localizationsDelegates: const [

View File

@@ -1,7 +1,356 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'app_colors.dart';
const _pickerItemStyle = TextStyle(fontSize: 22, fontWeight: FontWeight.w500);
const _lineColor = Color(0xFFCBD5E1);
Widget _wrapPicker(Widget picker) {
return Stack(
children: [
picker,
Positioned(
top: 0,
bottom: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(height: 1, color: _lineColor),
const SizedBox(height: 38),
Container(height: 1, color: _lineColor),
],
),
),
),
],
);
}
Future<DateTime?> showAppDatePicker(
BuildContext context, {
required DateTime initialDate,
DateTime? firstDate,
DateTime? lastDate,
}) {
final minDate = firstDate ?? DateTime(1900);
final maxDate = lastDate ?? DateTime(2100);
var y = initialDate.year.clamp(minDate.year, maxDate.year);
var m = initialDate.month;
var d = initialDate.day;
final yearCtrl = FixedExtentScrollController(initialItem: y - minDate.year);
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
void clampDay() {
final maxDay = daysInMonth(y, m);
if (d > maxDay) d = maxDay;
}
return showCupertinoModalPopup<DateTime>(
context: context,
builder: (ctx) => Container(
height: 320,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
onPressed: () {
clampDay();
Navigator.pop(ctx, DateTime(y, m, d));
},
),
],
),
),
Expanded(
child: Row(
children: [
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: yearCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
y = minDate.year + i;
clampDay();
final maxDay = daysInMonth(y, m);
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
},
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: monthCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
m = i + 1;
clampDay();
final maxDay = daysInMonth(y, m);
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
},
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: dayCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => d = i + 1,
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
],
),
),
],
),
),
);
}
Future<TimeOfDay?> showAppTimePicker(
BuildContext context, {
required TimeOfDay initialTime,
}) {
var hour = initialTime.hour;
var minute = initialTime.minute;
final hourCtrl = FixedExtentScrollController(initialItem: hour);
final minuteCtrl = FixedExtentScrollController(initialItem: minute);
return showCupertinoModalPopup<TimeOfDay>(
context: context,
builder: (ctx) => Container(
height: 320,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
),
],
),
),
Expanded(
child: Row(
children: [
const Spacer(flex: 1),
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: hourCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => hour = i,
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: minuteCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => minute = i,
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
const Spacer(flex: 1),
],
),
),
],
),
),
);
}
Future<DateTime?> showAppDateTimePicker(
BuildContext context, {
required DateTime initialDate,
DateTime? firstDate,
DateTime? lastDate,
}) {
final minDate = firstDate ?? DateTime(1900);
final maxDate = lastDate ?? DateTime(2100);
var y = initialDate.year.clamp(minDate.year, maxDate.year);
var m = initialDate.month;
var d = initialDate.day;
var h = initialDate.hour;
var min = initialDate.minute;
final yearCtrl = FixedExtentScrollController(initialItem: y - minDate.year);
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
final hourCtrl = FixedExtentScrollController(initialItem: h);
final minuteCtrl = FixedExtentScrollController(initialItem: min);
int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
void clampDay() {
final maxDay = daysInMonth(y, m);
if (d > maxDay) d = maxDay;
}
return showCupertinoModalPopup<DateTime>(
context: context,
builder: (ctx) => Container(
height: 320,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
onPressed: () {
clampDay();
Navigator.pop(ctx, DateTime(y, m, d, h, min));
},
),
],
),
),
Expanded(
child: Row(
children: [
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: yearCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: monthCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: dayCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => d = i + 1,
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: hourCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => h = i,
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: minuteCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => min = i,
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
],
),
),
],
),
),
);
}
Future<int?> showAppCountPicker(
BuildContext context, {
required int initialValue,
int min = 1,
int max = 10,
String label = '',
}) {
var selected = initialValue;
return showCupertinoModalPopup<int>(
context: context,
builder: (ctx) => Container(
height: 320,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
onPressed: () => Navigator.pop(ctx, selected),
),
],
),
),
Expanded(
child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: FixedExtentScrollController(initialItem: selected - min),
itemExtent: 40,
onSelectedItemChanged: (i) => selected = i + min,
children: [
for (var i = min; i <= max; i++)
Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
],
)),
),
],
),
),
);
}
class AppBackground extends StatelessWidget {
final Widget child;
final bool safeArea;

View File

@@ -264,14 +264,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
_BrandMark(),
const SizedBox(height: 16),
const Text(
'健康管家',
'小脉健康',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
const SizedBox(height: 4),
const Text(
'血管病患者的 AI 健康管理助手',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
const SizedBox(height: 8),
Text(
_isLogin ? '登录你的健康账户' : '创建新的健康账户',
style: const TextStyle(

View File

@@ -26,7 +26,6 @@ class _TrendPageState extends ConsumerState<TrendPage> {
{'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)},
{'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)},
{'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)},
{'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)},
];
static const _units = {
@@ -34,7 +33,6 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'heart_rate': 'bpm',
'glucose': 'mmol/L',
'spo2': '%',
'weight': 'kg',
};
static const _names = {
@@ -42,7 +40,6 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'heart_rate': '心率',
'glucose': '血糖',
'spo2': '血氧',
'weight': '体重',
};
String get _unit => _units[_selected] ?? '';
@@ -62,7 +59,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
setState(() => _loading = true);
try {
final api = ref.read(apiClientProvider);
final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight'];
final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2'];
final all = <Map<String, dynamic>>[];
for (final t in types) {
try {
@@ -112,9 +109,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
? 'HeartRate'
: _selected == 'glucose'
? 'Glucose'
: _selected == 'spo2'
? 'SpO2'
: 'Weight';
: 'SpO2';
setState(() {
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
_filtered.sort(
@@ -204,9 +199,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
? 'HeartRate'
: _selected == 'glucose'
? 'Glucose'
: _selected == 'spo2'
? 'SpO2'
: 'Weight',
: 'SpO2',
'source': 'Manual',
'recordedAt': DateTime.now().toUtc().toIso8601String(),
'unit': _unit,
@@ -679,8 +672,6 @@ class _TrendPageState extends ConsumerState<TrendPage> {
return '🩸';
case 'spo2':
return '🫁';
case 'weight':
return '⚖️';
default:
return '📊';
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
@@ -177,8 +178,8 @@ class _DoctorFollowUpEditPageState
Expanded(
child: GestureDetector(
onTap: () async {
final d = await showDatePicker(
context: context,
final d = await showAppDatePicker(
context,
initialDate: _date,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
@@ -186,7 +187,7 @@ class _DoctorFollowUpEditPageState
if (d != null) setState(() => _date = d);
},
child: _pickerBox(
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
'${_date.year} ${_date.month.toString().padLeft(2, '0')} ${_date.day.toString().padLeft(2, '0')}',
),
),
),
@@ -194,10 +195,7 @@ class _DoctorFollowUpEditPageState
Expanded(
child: GestureDetector(
onTap: () async {
final t = await showTimePicker(
context: context,
initialTime: _time,
);
final t = await showAppTimePicker(context, initialTime: _time);
if (t != null) setState(() => _time = t);
},
child: _pickerBox(

View File

@@ -155,7 +155,7 @@ class _HomePageState extends ConsumerState<HomePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'AI 健康管家',
'小脉健康',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -269,7 +269,7 @@ class _HomePageState extends ConsumerState<HomePage> {
ActiveAgent.health => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFAFBD), Color(0xFFC9FFBF)],
colors: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
),
ActiveAgent.diet => const LinearGradient(
begin: Alignment.topCenter,

View File

@@ -89,7 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
),
const SizedBox(height: 16),
Text(
'开始和 AI 健康管家对话吧',
'开始和小脉健康对话吧',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
@@ -1181,7 +1181,7 @@ class ChatMessagesView extends ConsumerWidget {
),
const SizedBox(width: 6),
const Text(
'健康管家',
'小脉健康',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(width: 4),
@@ -1343,11 +1343,11 @@ class ChatMessagesView extends ConsumerWidget {
verticalGradient: true,
),
ActiveAgent.health => _AgentColors(
gradient: [Color(0xFFFFAFBD), Color(0xFFC9FFBF)],
bg: const Color(0xFFF7FDEB),
border: const Color(0xFFEAF7BF),
iconBg: const Color(0xFFF9FCEB),
accent: const Color(0xFF8BD982),
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(
@@ -1404,7 +1404,7 @@ class ChatMessagesView extends ConsumerWidget {
),
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告AI 辅助解读'),
ActiveAgent.exercise => (LucideIcons.activity, '运动', '制定运动计划,打卡记录进度'),
_ => (Icons.forum_outlined, 'AI 助手', '您的智能健康管家'),
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
};
}
@@ -1692,7 +1692,6 @@ class ChatMessagesView extends ConsumerWidget {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
boxShadow: AppColors.cardShadow,
),
clipBehavior: Clip.antiAlias,

View File

@@ -159,14 +159,22 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
const SizedBox(height: 16),
// 频率选择
_label('每日服药次数'), const SizedBox(height: 8),
Wrap(
spacing: 8,
children: List.generate(
4,
(i) => _chip(
'${i + 1}',
_timesPerDay == i + 1,
() => _updateTimes(i + 1),
GestureDetector(
onTap: () async {
final n = await showAppCountPicker(context, initialValue: _timesPerDay, min: 1, max: 4, 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),
),
),
),
@@ -180,8 +188,8 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
_timesPerDay,
(i) => GestureDetector(
onTap: () async {
final t = await showTimePicker(
context: context,
final t = await showAppTimePicker(
context,
initialTime: _times[i],
);
if (t != null) setState(() => _times[i] = t);
@@ -324,8 +332,8 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final d = await showDatePicker(
context: context,
final d = await showAppDatePicker(
context,
initialDate: val,
firstDate: DateTime(2024),
lastDate: DateTime(2030),
@@ -341,7 +349,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
border: Border.all(color: AppColors.border),
),
child: Text(
'${val.month}/${val.day}',
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 18),
),
),
@@ -359,8 +367,8 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final d = await showDatePicker(
context: context,
final d = await showAppDatePicker(
context,
initialDate: val ?? DateTime.now(),
firstDate: DateTime(2024),
lastDate: DateTime(2030),
@@ -378,7 +386,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
child: Row(
children: [
Text(
val != null ? '${val.month}/${val.day}' : '不设置',
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置',
style: TextStyle(
fontSize: 18,
color: val != null ? null : AppTheme.textHint,

View File

@@ -1,4 +1,5 @@
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_theme.dart';
@@ -582,6 +583,8 @@ class DietRecordDetailPage extends ConsumerWidget {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: const Color(0xFFE8ECF0)),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
@@ -629,6 +632,8 @@ class DietRecordDetailPage extends ConsumerWidget {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: const Color(0xFFE8ECF0)),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
@@ -765,6 +770,8 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: const Color(0xFFE8ECF0)),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
@@ -998,8 +1005,8 @@ class _ExercisePlanCreatePageState
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final d = await showDatePicker(
context: context,
final d = await showAppDatePicker(
context,
initialDate: val,
firstDate: DateTime(2024),
lastDate: DateTime(2030),
@@ -1014,7 +1021,7 @@ class _ExercisePlanCreatePageState
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${val.year}-${val.month.toString().padLeft(2, '0')}-${val.day.toString().padLeft(2, '0')}',
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 19),
),
),
@@ -1032,7 +1039,7 @@ class _ExercisePlanCreatePageState
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: context, initialTime: _reminderTime);
final picked = await showAppTimePicker(context, initialTime: _reminderTime);
if (picked != null && mounted) setState(() => _reminderTime = picked);
},
child: Container(
@@ -1320,12 +1327,11 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
Future<void> _pickDate(void Function(String) onPicked) async {
final now = DateTime.now();
final d = await showDatePicker(
context: context,
final d = await showAppDatePicker(
context,
initialDate: DateTime(now.year - 30),
firstDate: DateTime(1900),
lastDate: now,
locale: const Locale('zh'),
);
if (d != null) {
onPicked(
@@ -1400,7 +1406,8 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
@override
Widget build(BuildContext context) {
if (_loading) {
return GradientScaffold(
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
@@ -1417,7 +1424,8 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
body: const Center(child: CircularProgressIndicator()),
);
}
return GradientScaffold(
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
@@ -1718,7 +1726,7 @@ class _DateF extends StatelessWidget {
child: Row(
children: [
Text(
value.isEmpty ? '点击选择日期' : value,
value.isEmpty ? '点击选择日期' : value.replaceAll('-', ' '),
style: TextStyle(
fontSize: 16,
color: value.isEmpty
@@ -1769,7 +1777,7 @@ class _DateF2 extends StatelessWidget {
child: Row(
children: [
Text(
value.isEmpty ? (hint ?? '点击选择') : value,
value.isEmpty ? (hint ?? '点击选择') : value.replaceAll('-', ' '),
style: TextStyle(
fontSize: 13,
color: value.isEmpty
@@ -1833,9 +1841,9 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
Map<String, List<Map<String, dynamic>>> _events = {};
Map<String, dynamic>? _selectedDayData;
static const _medBlue = Color(0xFF0000FF);
static const _exGreen = Color(0xFF00FF00);
static const _fupOrange = Color(0xFFFFA500);
static const _medBlue = Color(0xFF3B82F6);
static const _exGreen = Color(0xFF22C55E);
static const _fupOrange = Color(0xFFF59E0B);
@override
void initState() {
@@ -2308,7 +2316,7 @@ class StaticTextPage extends ConsumerWidget {
const StaticTextPage({super.key, required this.type});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'};
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于小脉健康'};
final contents = {
'privacy': '''## 隐私政策
@@ -2352,12 +2360,12 @@ class StaticTextPage extends ConsumerWidget {
如有任何关于隐私的问题,请联系:
邮箱privacy@healthbutler.com
电话400-xxx-xxxx''',
'about': '''## 关于健康管家
'about': '''## 关于小脉健康
版本v1.0.0 (Build 20260101)
### 产品介绍
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
小脉健康是一款面向血管病患者的 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
### 核心功能
- AI 智能问诊:基于大语言模型的健康咨询服务
@@ -2377,7 +2385,7 @@ class StaticTextPage extends ConsumerWidget {
- 客服热线400-xxx-xxxx工作日 9:00-18:00
### 版权声明
© 2025-2026 健康管家团队。保留所有权利。
© 2025-2026 小脉健康团队。保留所有权利。
本软件受中华人民共和国著作权法保护。''',
};
return GradientScaffold(
@@ -2399,12 +2407,15 @@ class StaticTextPage extends ConsumerWidget {
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Text(
contents[type] ?? '内容加载中...',
style: TextStyle(
fontSize: 17,
height: 1.8,
color: AppColors.textPrimary,
child: MarkdownBody(
data: contents[type] ?? '内容加载中...',
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary),
h1: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary, height: 1.5),
h2: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary, height: 1.5),
h3: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary, height: 1.5),
listBullet: const TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary),
),
),
),

View File

@@ -167,8 +167,8 @@ class NotificationPrefsPage extends ConsumerWidget {
label: '开始',
time: '22:00',
onTap: () async {
final picked = await showTimePicker(
context: context,
final picked = await showAppTimePicker(
context,
initialTime: const TimeOfDay(hour: 22, minute: 0),
);
if (picked != null && context.mounted) {
@@ -194,8 +194,8 @@ class NotificationPrefsPage extends ConsumerWidget {
label: '结束',
time: '08:00',
onTap: () async {
final picked = await showTimePicker(
context: context,
final picked = await showAppTimePicker(
context,
initialTime: const TimeOfDay(hour: 8, minute: 0),
);
if (picked != null && context.mounted) {

View File

@@ -81,7 +81,7 @@ class SettingsPage extends ConsumerWidget {
children: [
_SettingsTile(
icon: LucideIcons.info,
title: '关于健康管家',
title: '关于小脉健康',
colors: const [Color(0xFF60A5FA), Color(0xFF0891B2)],
onTap: () =>
pushRoute(ref, 'staticText', params: {'type': 'about'}),

View File

@@ -20,10 +20,10 @@ class HealthDrawer extends ConsumerWidget {
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFE0C3FC), Color(0xFFFFFFFF), Color(0xFFDCEEFF)],
stops: [0.0, 0.48, 1.0],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF87CEEB), Color(0xFFFFFFFF)],
stops: [0.0, 0.35],
),
),
child: SafeArea(
@@ -36,8 +36,6 @@ class HealthDrawer extends ConsumerWidget {
children: [
_AccountHeader(user: auth.user, ref: ref),
const SizedBox(height: 18),
_ArchiveAction(ref: ref),
const SizedBox(height: 18),
_HealthDashboard(latestHealth: latestHealth, ref: ref),
const SizedBox(height: 18),
_NavigationSection(ref: ref),
@@ -141,64 +139,6 @@ class _AccountHeader extends StatelessWidget {
}
}
class _ArchiveAction extends StatelessWidget {
final WidgetRef ref;
const _ArchiveAction({required this.ref});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => pushRoute(ref, 'healthArchive'),
borderRadius: BorderRadius.circular(24),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white, width: 1.4),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.folder_shared_rounded,
color: Color(0xFF14B8A6),
size: 23,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'健康档案',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: AppColors.textHint,
),
],
),
),
);
}
}
class _HealthDashboard extends StatelessWidget {
final AsyncValue<Map<String, dynamic>> latestHealth;
final WidgetRef ref;
@@ -217,7 +157,7 @@ class _HealthDashboard extends StatelessWidget {
backgroundGradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF00F2FE), Color(0xFF4FACFE)],
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
),
child: latestHealth.when(
data: (data) => _DashboardMetrics(data: data, ref: ref),
@@ -243,6 +183,7 @@ class _DashboardMetrics extends StatelessWidget {
_MetricInfo(
label: '血压',
value: _bpText(data['BloodPressure']),
unit: 'mmHg',
routeType: 'blood_pressure',
),
_MetricInfo(
@@ -263,12 +204,6 @@ class _DashboardMetrics extends StatelessWidget {
unit: '%',
routeType: 'spo2',
),
_MetricInfo(
label: '体重',
value: _metricVal(data['Weight']),
unit: 'kg',
routeType: 'weight',
),
];
return Row(
@@ -321,18 +256,18 @@ class _MetricTile extends StatelessWidget {
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
height: 92,
height: 100,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.30),
color: Colors.white.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.50)),
border: Border.all(color: Colors.white.withValues(alpha: 0.40)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 28,
height: 32,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
@@ -340,7 +275,7 @@ class _MetricTile extends StatelessWidget {
maxLines: 1,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 22,
fontSize: 26,
fontWeight: FontWeight.w900,
color: Colors.white,
),
@@ -350,13 +285,13 @@ class _MetricTile extends StatelessWidget {
if (info.unit != null)
Text(
info.unit!,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: Color(0xE0FFFFFF)),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xE0FFFFFF)),
),
const SizedBox(height: 2),
Text(
info.label,
style: const TextStyle(
fontSize: 12,
fontSize: 14,
fontWeight: FontWeight.w800,
color: Colors.white,
),
@@ -375,6 +310,12 @@ class _NavigationSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final items = [
_NavItem(
icon: Icons.folder_shared_rounded,
title: '档案',
route: 'healthArchive',
colors: const [Color(0xFF6EE7B7), Color(0xFF14B8A6)],
),
_NavItem(
icon: Icons.description_rounded,
title: '报告',
@@ -445,6 +386,7 @@ class _NavTile extends StatelessWidget {
const _NavTile({required this.item, required this.onTap});
String get _title => switch (item.route) {
'healthArchive' => '健康档案',
'reports' => '报告管理',
'medications' => '用药管理',
'dietRecords' => '饮食记录',
@@ -455,6 +397,7 @@ class _NavTile extends StatelessWidget {
};
List<Color> get _colors => switch (item.route) {
'healthArchive' => const [Color(0xFF34D399), Color(0xFF059669)],
'reports' => const [Color(0xFF60A5FA), Color(0xFF2563EB)],
'medications' => const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
'dietRecords' => const [Color(0xFFF6D365), Color(0xFFF97316)],

View File

@@ -17,7 +17,7 @@ void main() {
await tester.pumpWidget(const ProviderScope(child: MaterialApp(home: LoginPage())));
await tester.pumpAndSettle();
expect(find.text('健康管家'), findsOneWidget);
expect(find.text('小脉健康'), findsOneWidget);
expect(find.text('登 录'), findsOneWidget);
});

View File

@@ -14,16 +14,13 @@ echo [1/2] Starting PostgreSQL...
if errorlevel 1 (echo PG already running) else (echo PG started)
echo.
echo [2/3] Starting .NET Backend...
echo [2/2] Starting .NET Backend...
start "Backend" cmd /k "cd /d D:\health_project\backend\src\Health.WebApi && dotnet run"
echo Waiting for backend...
timeout /t 8 /nobreak >nul
echo.
echo [3/3] Setup phone connection...
adb reverse tcp:5000 tcp:5000 2>nul
if errorlevel 1 (echo USB not connected, skip) else (echo Phone -> PC forwarding OK)
echo.
echo All done. Open the app on your phone.
echo All done. Phone connects via WiFi to this PC.
echo If your PC IP changed, rebuild app with:
echo flutter run -d ^<device^> --dart-define=API_BASE_URL=http://^<pc-ip^>:5000
pause