Files
AI-Health/health_app/lib/core/app_theme.dart
MingNian 6e5d3e64cd feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色
- 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼
- app_colors / app_design_tokens / app_theme: 配色体系微调
- 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等)

## 登录页品牌升级
- 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent
- login_page 重构, 接入新品牌视觉
- app.dart 启动流程调整

## iOS / Android 配置
- Info.plist: 权限描述调整
- AppIcon / LaunchImage: 资源更新
- AndroidManifest: 权限微调

## 隐私文案修订
- privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述
- terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄

## 通知中心
- notification_center_page: 重构通知项布局

## 其他
- 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system)
- 新增 native_navigation_test
- 新增 HANDOFF 交接文档
- home_message_order_test 更新
- .gitignore 调整
2026-07-16 22:30:23 +08:00

803 lines
26 KiB
Dart

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;
const AppBackground({super.key, required this.child, this.safeArea = false});
@override
Widget build(BuildContext context) {
final content = safeArea ? SafeArea(child: child) : child;
// 全局浅灰白背景,已弃用紫色渐变。
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
),
),
child: content,
);
}
}
class GradientScaffold extends StatelessWidget {
final PreferredSizeWidget? appBar;
final Widget? body;
final Widget? floatingActionButton;
final Widget? drawer;
final Widget? bottomNavigationBar;
final bool extendBody;
const GradientScaffold({
super.key,
this.appBar,
this.body,
this.floatingActionButton,
this.drawer,
this.bottomNavigationBar,
this.extendBody = false,
});
@override
Widget build(BuildContext context) => Scaffold(
// 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现。
backgroundColor: AppColors.background,
appBar: appBar,
body: ColoredBox(
color: AppColors.background,
child: SizedBox.expand(child: body ?? const SizedBox.shrink()),
),
floatingActionButton: floatingActionButton,
drawer: drawer,
bottomNavigationBar: bottomNavigationBar,
extendBody: extendBody,
);
}
class AppTheme {
AppTheme._();
static const Color primary = AppColors.primary;
static const Color primaryLight = AppColors.primaryLight;
static const Color primaryDark = AppColors.primaryDark;
static const Color primaryMid = AppColors.blueMeasure;
static const Color accent = AppColors.success;
static const Color info = AppColors.blueMeasure;
static const Color bg = AppColors.background;
static const Color bgSoft = AppColors.backgroundSoft;
static const Color surface = AppColors.cardBackground;
static const Color surfaceInner = AppColors.cardInner;
static const Color text = AppColors.textPrimary;
static const Color textSub = AppColors.textSecondary;
static const Color textHint = AppColors.textHint;
static const Color border = AppColors.border;
static const Color divider = AppColors.divider;
static const Color success = AppColors.success;
static const Color successLight = AppColors.successLight;
static const Color error = AppColors.error;
static const Color errorLight = AppColors.errorLight;
static const Color warning = AppColors.warning;
static const Color warningLight = AppColors.warningLight;
static const double rXs = 4;
static const double rSm = 8;
static const double rMd = 12;
static const double rLg = 16;
static const double rXl = 20;
static const double rPill = 999;
static const double sXs = 4;
static const double sSm = 8;
static const double sMd = 12;
static const double sLg = 16;
static const double sXl = 20;
static const double sXxl = 24;
static BoxShadow get shadowCard => BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07),
blurRadius: 22,
offset: const Offset(0, 10),
);
static BoxShadow get shadowLight => BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 14,
offset: const Offset(0, 6),
);
static BoxShadow get shadowElevated => BoxShadow(
color: primary.withValues(alpha: 0.12),
blurRadius: 18,
offset: const Offset(0, 8),
);
static BoxShadow get shadowButton => BoxShadow(
color: primary.withValues(alpha: 0.22),
blurRadius: 16,
offset: const Offset(0, 8),
);
static const Map<String, Color> agentColors = {
'default': Color(0xFFF3F0FF),
'consultation': Color(0xFFEFF5FF),
'health': Color(0xFFF3F0FF),
'diet': Color(0xFFFFF7E6),
'medication': Color(0xFFEFF5FF),
'report': Color(0xFFF6F3FF),
'exercise': Color(0xFFEFF8FF),
};
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primary,
primary: primary,
primaryContainer: primaryLight,
onPrimary: Colors.white,
secondary: AppColors.blueMeasure,
surface: surface,
brightness: Brightness.light,
),
scaffoldBackgroundColor: AppColors.background,
splashColor: primary.withValues(alpha: 0.06),
highlightColor: Colors.transparent,
hoverColor: primaryLight.withValues(alpha: 0.55),
fontFamily: null,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: text,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: text,
),
toolbarHeight: 52,
),
cardTheme: CardThemeData(
color: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rLg),
side: const BorderSide(color: AppColors.borderLight),
),
margin: EdgeInsets.zero,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 13),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(rMd),
borderSide: const BorderSide(color: AppColors.borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rMd),
borderSide: const BorderSide(color: AppColors.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rMd),
borderSide: const BorderSide(color: AppColors.auraIndigo, width: 1.3),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(rMd),
borderSide: const BorderSide(color: error),
),
hintStyle: const TextStyle(color: textHint, fontSize: 15),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
elevation: 0,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: text,
side: const BorderSide(color: border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
// TextButton 全局默认黑色文字,避免出现浅紫色按钮。
// 需要红色(如删除)时仍可在使用处单独 styleFrom(foregroundColor: AppColors.error) 覆盖。
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(foregroundColor: text),
),
dialogTheme: DialogThemeData(
backgroundColor: surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: text,
),
titleLarge: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: text,
),
titleMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: text,
),
bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
labelMedium: TextStyle(fontSize: 13, color: textSub),
labelSmall: TextStyle(fontSize: 12, color: textHint),
),
);
static ShadThemeData get shadTheme => ShadThemeData(
brightness: Brightness.light,
colorScheme: ShadVioletColorScheme.light(
background: bg,
foreground: text,
card: surface,
cardForeground: text,
popover: surface,
popoverForeground: text,
primary: primary,
primaryForeground: Colors.white,
secondary: primaryLight,
secondaryForeground: primaryDark,
muted: bgSoft,
mutedForeground: textSub,
accent: AppColors.blueMeasure,
accentForeground: Colors.white,
destructive: error,
destructiveForeground: Colors.white,
border: border,
input: border,
ring: primary,
selection: primaryLight,
),
radius: BorderRadius.circular(rMd),
);
}