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 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( 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 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( 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 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( 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 showAppCountPicker( BuildContext context, { required int initialValue, int min = 1, int max = 10, String label = '', }) { var selected = initialValue; return showCupertinoModalPopup( 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 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), ); }