Flutter for OpenHarmony BMI 计算器全面升级:从基础工具到专业健康助手的进化之路

Flutter for OpenHarmony BMI 计算器全面升级:从基础工具到专业健康助手的进化之路

在上一篇《Flutter BMI 健康计算器:打造支持深色模式的智能健康工具》中,我们实现了一个功能完整、主题适配良好的基础版本。而今天这份代码,则是一次全方位的专业级升级 ------它不仅保留了原有优点,更引入了动画增强、数据持久化、历史记录、可视化仪表盘、响应式布局等高级特性,将一个简单的计算工具转变为真正有温度、有记忆、有洞察的健康助手。

🌐 加入社区 欢迎加入 开源鸿蒙跨平台开发者社区 ,获取最新资源与技术支持: 👉 开源鸿蒙跨平台开发者社区


完整效果

一、视觉系统:Material 3 + 精致色彩体系

✅ 优化点:拥抱 Material You 设计语言

dart 复制代码
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6366F1)),
  • 旧版 :使用传统 primarySwatch,颜色固定;
  • 新版 :采用 Material 3 的 ColorScheme.fromSeed,基于种子色(Indigo)自动生成完整调色板;
  • 效果:按钮、焦点边框、图标等自动协调,视觉更统一、现代;
  • 深色模式 :亮/暗主题均使用不同饱和度的种子色(0xFF6366F1 vs 0xFF818CF8),确保一致性。

🎨 这是向 动态色彩(Dynamic Color) 迈出的第一步,未来可轻松接入 Android 12+ 系统主题色。


二、交互体验:弹性动画与微交互动效

✅ 优化点:双动画控制器驱动结果呈现

dart 复制代码
_resultAnimationController → 弹性缩放(Curves.elasticOut)
_progressAnimationController → 仪表盘进度(Curves.easeInOut)
  • 旧版 :仅用 AnimatedContainer 实现淡入;
  • 新版
    • 结果卡片:以"弹性弹出"方式出现(模拟弹簧回弹),更具活力;
    • BMI 仪表盘 :弧形进度条随计算结果平滑增长,直观展示 BMI 在健康谱中的位置;
  • 技术亮点AnimatedBuilder + Transform.scale 实现高性能局部动画,避免整树重建。

💫 动画不仅是"好看",更是状态变化的视觉反馈,让用户感知"计算已完成"。


三、数据管理:结构化历史记录与本地存储准备

✅ 优化点:引入 BmiResult 模型类

dart 复制代码
class BmiResult {
  final DateTime timestamp;
  final double bmi, height, weight;
  final String category;
  
  Map<String, dynamic> toJson() => {...};
  factory BmiResult.fromJson(Map<String, dynamic> json) => ...;
}
  • 旧版:无历史记录;
  • 新版
    • 每次计算自动保存至 _history 列表(最多 10 条);
    • 支持 JSON 序列化/反序列化 ,为后续接入 shared_preferences 或数据库铺路;
    • 提供 时间格式化("今天 14:30"、"昨天 09:15"),提升可读性。

📚 虽未实现持久化,但架构已预留扩展点,只需几行代码即可保存到本地。


四、UI 组件:专业级可视化仪表盘(CustomPainter)

✅ 优化点:自定义 BmiGaugePainter

dart 复制代码
class BmiGaugePainter extends CustomPainter { ... }
  • 旧版:仅显示大号数字;
  • 新版
    • 弧形进度条:背景灰弧(18.5--28 区间) + 彩色进度弧(当前 BMI 位置);
    • 动态映射:BMI 值自动转换为 0--1 的进度比例;
    • 视觉锚点:数字 + 分类标签居中显示,信息层级清晰。

📊 将抽象数字转化为空间位置感知,用户一眼看出自己处于"偏瘦"还是"肥胖"区间。


五、功能增强:个性化健康建议与体重范围提示

✅ 优化点:动态计算健康体重区间

dart 复制代码
String _getWeightSuggestion() {
  final idealMin = 18.5 * h²;
  final idealMax = 24 * h²;
  return '健康体重范围: ${idealMin} - ${idealMax} kg';
}
  • 旧版:仅提供通用建议;
  • 新版
    • 根据用户输入的身高,实时计算理想体重范围
    • 以彩色提示框展示(颜色与 BMI 分类一致),强化关联性;
    • 例如:身高 175cm → "健康体重范围: 56.7 - 73.5 kg"。

❤️ 从"评判结果"转向"提供建设性方案",体现健康促进而非焦虑制造的理念。


六、布局系统:响应式设计适配多端设备

✅ 优化点:LayoutBuilder + 屏幕宽度判断

dart 复制代码
final isSmallScreen = constraints.maxWidth < 400;
// 动态调整 padding、字体、间距
  • 旧版:固定间距与字号;
  • 新版
    • 在手机(<400px)上使用紧凑布局(padding=16, fontSize=16);
    • 在平板/折叠屏上使用宽松布局(padding=24, fontSize=18);
    • 所有组件(输入框、按钮、卡片)均响应式调整。

📱 真正实现 "一次开发,多端适配",无需为不同设备写多套 UI。


七、用户体验细节:输入校验、空状态与引导

功能 旧版 新版
输入过滤 FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')) 仅允许数字和小数点
空状态设计 简单图标+文字 带边框的卡片式引导区,视觉更精致
历史记录入口 AppBar 右侧 Icons.history 按钮
历史记录界面 底部抽屉式列表,带分类色标、时间、清空功能
重置体验 清空输入 同时重置动画状态(_resultAnimationController.reset()

🔍 每一处细节都在降低用户认知负荷,提升操作流畅度。


八、技术架构升级总结

维度 旧版 新版
主题系统 基础亮/暗切换 Material 3 + Seed Color
动画系统 单一容器动画 双控制器 + 弹性曲线 + 自定义绘制
数据模型 无结构 BmiResult + JSON 支持
可视化 文字数字 仪表盘 + 色彩编码
功能深度 计算+解读 +历史记录+健康建议+响应式
代码组织 单一 build 方法 拆分为 _buildXXX 私有方法,结构清晰

九、未来可扩展方向

尽管当前版本已非常完善,但仍可进一步演进:

  1. 持久化存储 :使用 shared_preferences 保存 _history
  2. 图表分析 :用 fl_chart 绘制 BMI 趋势折线图;
  3. 分享功能 :生成结果图片(RepaintBoundary + toImage);
  4. 单位切换:支持英制(英寸/磅);
  5. 健康目标:设定目标 BMI,计算需减重多少。

结语:从工具到伙伴

这次升级不仅仅是代码量的增加,更是产品思维的跃迁

  • 旧版回答:"你的 BMI 是多少?"
  • 新版回答:"你的 BMI 是 XX(偏瘦/正常/超重/肥胖),健康体重应在 YY-ZZ kg 之间,这是你过去几次的记录。"

完整代码

bash 复制代码
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;

void main() {
  runApp(const BmiApp());
}

class BmiApp extends StatelessWidget {
  const BmiApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '📏 BMI 计算器',
      theme: ThemeData(
        brightness: Brightness.light,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6366F1),
          brightness: Brightness.light,
        ),
        useMaterial3: true,
        scaffoldBackgroundColor: const Color(0xFFF8FAFC),
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: Colors.white.withValues(alpha: 0.9),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(16),
            borderSide: BorderSide.none,
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(16),
            borderSide: BorderSide.none,
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(16),
            borderSide: const BorderSide(color: Color(0xFF6366F1), width: 2),
          ),
        ),
        cardTheme: CardTheme(
          elevation: 2,
          shape:
              RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
        ),
      ),
      darkTheme: ThemeData(
        brightness: Brightness.dark,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF818CF8),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
        scaffoldBackgroundColor: const Color(0xFF0F172A),
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: const Color(0xFF1E293B),
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(16),
            borderSide: BorderSide.none,
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(16),
            borderSide: BorderSide.none,
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(16),
            borderSide: const BorderSide(color: Color(0xFF818CF8), width: 2),
          ),
        ),
        cardTheme: CardTheme(
          elevation: 4,
          shape:
              RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
        ),
      ),
      themeMode: ThemeMode.system,
      home: const BmiCalculatorScreen(),
    );
  }
}

class BmiResult {
  final DateTime timestamp;
  final double bmi;
  final double height;
  final double weight;
  final String category;

  BmiResult({
    required this.timestamp,
    required this.bmi,
    required this.height,
    required this.weight,
    required this.category,
  });

  Map<String, dynamic> toJson() => {
        'timestamp': timestamp.toIso8601String(),
        'bmi': bmi,
        'height': height,
        'weight': weight,
        'category': category,
      };

  factory BmiResult.fromJson(Map<String, dynamic> json) => BmiResult(
        timestamp: DateTime.parse(json['timestamp'] as String),
        bmi: json['bmi'] as double,
        height: json['height'] as double,
        weight: json['weight'] as double,
        category: json['category'] as String,
      );
}

class BmiCalculatorScreen extends StatefulWidget {
  const BmiCalculatorScreen({super.key});

  @override
  State<BmiCalculatorScreen> createState() => _BmiCalculatorScreenState();
}

class _BmiCalculatorScreenState extends State<BmiCalculatorScreen>
    with TickerProviderStateMixin {
  final TextEditingController _heightController = TextEditingController();
  final TextEditingController _weightController = TextEditingController();

  double? _bmi;
  String _interpretation = '';
  Color _resultColor = Colors.blue;
  final List<BmiResult> _history = [];
  String _category = '';

  late AnimationController _resultAnimationController;
  late AnimationController _progressAnimationController;
  late Animation<double> _scaleAnimation;
  late Animation<double> _progressAnimation;

  @override
  void initState() {
    super.initState();
    _resultAnimationController = AnimationController(
      duration: const Duration(milliseconds: 800),
      vsync: this,
    );
    _progressAnimationController = AnimationController(
      duration: const Duration(milliseconds: 1200),
      vsync: this,
    );

    _scaleAnimation = CurvedAnimation(
      parent: _resultAnimationController,
      curve: Curves.elasticOut,
    );
    _progressAnimation = CurvedAnimation(
      parent: _progressAnimationController,
      curve: Curves.easeInOut,
    );
  }

  @override
  void dispose() {
    _resultAnimationController.dispose();
    _progressAnimationController.dispose();
    _heightController.dispose();
    _weightController.dispose();
    super.dispose();
  }

  void _calculateBMI() {
    final heightStr = _heightController.text.trim();
    final weightStr = _weightController.text.trim();

    if (heightStr.isEmpty || weightStr.isEmpty) {
      _showError('请输入身高和体重');
      return;
    }

    final height = double.tryParse(heightStr);
    final weight = double.tryParse(weightStr);

    if (height == null || weight == null || height <= 0 || weight <= 0) {
      _showError('请输入有效的正数');
      return;
    }

    final heightInMeters = height / 100;
    final bmi = weight / (heightInMeters * heightInMeters);

    String interpretation;
    String category;
    Color color;

    if (bmi < 18.5) {
      interpretation = '偏瘦\n建议增加营养摄入,保持规律作息。';
      category = '偏瘦';
      color = const Color(0xFFF59E0B);
    } else if (bmi < 24) {
      interpretation = '正常\n继续保持健康的生活方式!';
      category = '正常';
      color = const Color(0xFF10B981);
    } else if (bmi < 28) {
      interpretation = '超重\n建议适当运动,控制饮食。';
      category = '超重';
      color = const Color(0xFFF59E0B);
    } else {
      interpretation = '肥胖\n建议咨询医生,制定科学减重计划。';
      category = '肥胖';
      color = const Color(0xFFEF4444);
    }

    // 添加到历史记录
    final result = BmiResult(
      timestamp: DateTime.now(),
      bmi: bmi,
      height: height,
      weight: weight,
      category: category,
    );
    setState(() {
      _bmi = bmi;
      _interpretation = interpretation;
      _resultColor = color;
      _category = category;
      _history.insert(0, result);
      if (_history.length > 10) _history.removeLast();
    });

    _resultAnimationController.reset();
    _resultAnimationController.forward();
    _progressAnimationController.reset();
    _progressAnimationController.forward();
  }

  void _showError(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        behavior: SnackBarBehavior.floating,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      ),
    );
  }

  void _reset() {
    _heightController.clear();
    _weightController.clear();
    _resultAnimationController.reset();
    _progressAnimationController.reset();
    setState(() {
      _bmi = null;
      _interpretation = '';
      _resultColor = Colors.blue;
      _category = '';
    });
  }

  String _getWeightSuggestion() {
    if (_bmi == null) return '';

    final height = double.tryParse(_heightController.text);
    if (height == null) return '';

    final heightInMeters = height / 100;
    final idealBmiMin = 18.5 * heightInMeters * heightInMeters;
    final idealBmiMax = 24 * heightInMeters * heightInMeters;

    return '健康体重范围: ${idealBmiMin.toStringAsFixed(1)} - ${idealBmiMax.toStringAsFixed(1)} kg';
  }

  void _showHistory() {
    if (_history.isEmpty) {
      _showError('暂无历史记录');
      return;
    }

    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) => _buildHistorySheet(),
    );
  }

  Widget _buildHistorySheet() {
    final isDark = Theme.of(context).brightness == Brightness.dark;

    return Container(
      decoration: BoxDecoration(
        color: isDark ? const Color(0xFF1E293B) : Colors.white,
        borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            margin: const EdgeInsets.symmetric(vertical: 12),
            width: 40,
            height: 4,
            decoration: BoxDecoration(
              color: Colors.grey.withValues(alpha: 0.3),
              borderRadius: BorderRadius.circular(2),
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  '历史记录',
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                ),
                TextButton(
                  onPressed: () {
                    setState(() => _history.clear());
                    Navigator.pop(context);
                  },
                  child: const Text('清空'),
                ),
              ],
            ),
          ),
          const Divider(height: 1),
          Flexible(
            child: ListView.builder(
              shrinkWrap: true,
              itemCount: _history.length,
              itemBuilder: (context, index) {
                final record = _history[index];
                Color categoryColor;
                switch (record.category) {
                  case '偏瘦':
                    categoryColor = const Color(0xFFF59E0B);
                    break;
                  case '正常':
                    categoryColor = const Color(0xFF10B981);
                    break;
                  case '超重':
                    categoryColor = const Color(0xFFF59E0B);
                    break;
                  case '肥胖':
                    categoryColor = const Color(0xFFEF4444);
                    break;
                  default:
                    categoryColor = Colors.blue;
                }
                return ListTile(
                  leading: Container(
                    width: 40,
                    height: 40,
                    decoration: BoxDecoration(
                      color: categoryColor.withValues(alpha: 0.2),
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: Center(
                      child: Text(
                        record.bmi.toStringAsFixed(1),
                        style: TextStyle(
                          color: categoryColor,
                          fontWeight: FontWeight.bold,
                        ),
                      ),
                    ),
                  ),
                  title: Text('${record.height}cm / ${record.weight}kg'),
                  subtitle: Text(
                    '${_formatDate(record.timestamp)} · ${record.category}',
                    style: TextStyle(color: categoryColor),
                  ),
                  trailing: const Icon(Icons.chevron_right),
                );
              },
            ),
          ),
          const SizedBox(height: 20),
        ],
      ),
    );
  }

  String _formatDate(DateTime date) {
    final now = DateTime.now();
    final difference = now.difference(date);

    if (difference.inDays == 0) {
      return '今天 ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
    } else if (difference.inDays == 1) {
      return '昨天 ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
    } else {
      return '${date.month}/${date.day} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
    }
  }

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final primaryColor = Theme.of(context).colorScheme.primary;
    final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white;
    final backgroundColor =
        isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC);

    return Scaffold(
      appBar: AppBar(
        title: const Text(
          'BMI 健康计算器',
          style: TextStyle(fontWeight: FontWeight.bold),
        ),
        centerTitle: true,
        backgroundColor: backgroundColor,
        elevation: 0,
        actions: [
          IconButton(
            icon: const Icon(Icons.history),
            onPressed: _showHistory,
            tooltip: '历史记录',
          ),
        ],
      ),
      backgroundColor: backgroundColor,
      body: LayoutBuilder(
        builder: (context, constraints) {
          final isSmallScreen = constraints.maxWidth < 400;

          return SingleChildScrollView(
            padding: EdgeInsets.all(isSmallScreen ? 16 : 24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                _buildHeader(isDark),
                SizedBox(height: isSmallScreen ? 16 : 24),
                _buildInputSection(isSmallScreen),
                SizedBox(height: isSmallScreen ? 16 : 24),
                _buildButtons(isSmallScreen, primaryColor),
                SizedBox(height: isSmallScreen ? 24 : 32),
                if (_bmi != null)
                  _buildResultCard(cardColor, isDark, isSmallScreen),
                if (_bmi == null) _buildEmptyState(isDark),
                SizedBox(height: isSmallScreen ? 16 : 24),
                _buildBmiScale(isSmallScreen),
              ],
            ),
          );
        },
      ),
    );
  }

  Widget _buildHeader(bool isDark) {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: isDark
              ? [const Color(0xFF6366F1), const Color(0xFF8B5CF6)]
              : [
                  const Color(0xFF6366F1).withValues(alpha: 0.9),
                  const Color(0xFF8B5CF6).withValues(alpha: 0.9)
                ],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        borderRadius: BorderRadius.circular(24),
      ),
      child: Row(
        children: [
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.white.withValues(alpha: 0.2),
              borderRadius: BorderRadius.circular(16),
            ),
            child: const Icon(
              Icons.monitor_heart,
              size: 32,
              color: Colors.white,
            ),
          ),
          const SizedBox(width: 16),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const Text(
                  'BMI 指数',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 14,
                    fontWeight: FontWeight.w500,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  '健康体魄,从了解自己开始',
                  style: TextStyle(
                    color: Colors.white.withValues(alpha: 0.9),
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildInputSection(bool isSmallScreen) {
    return Column(
      children: [
        TextField(
          controller: _heightController,
          keyboardType: TextInputType.numberWithOptions(decimal: true),
          inputFormatters: [
            FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))
          ],
          decoration: InputDecoration(
            labelText: '身高',
            hintText: '例如:175',
            prefixIcon: const Icon(Icons.height, color: Color(0xFF6366F1)),
            floatingLabelBehavior: FloatingLabelBehavior.always,
          ),
        ),
        SizedBox(height: isSmallScreen ? 12 : 16),
        TextField(
          controller: _weightController,
          keyboardType: TextInputType.numberWithOptions(decimal: true),
          inputFormatters: [
            FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$'))
          ],
          decoration: InputDecoration(
            labelText: '体重',
            hintText: '例如:70',
            prefixIcon:
                const Icon(Icons.monitor_weight, color: Color(0xFF6366F1)),
            floatingLabelBehavior: FloatingLabelBehavior.always,
          ),
        ),
      ],
    );
  }

  Widget _buildButtons(bool isSmallScreen, Color primaryColor) {
    return Row(
      children: [
        Expanded(
          child: ElevatedButton.icon(
            onPressed: _calculateBMI,
            icon: const Icon(Icons.calculate, size: 20),
            label: Text(
              '计算 BMI',
              style: TextStyle(
                  fontSize: isSmallScreen ? 16 : 18,
                  fontWeight: FontWeight.bold),
            ),
            style: ElevatedButton.styleFrom(
              padding: EdgeInsets.symmetric(vertical: isSmallScreen ? 14 : 16),
              backgroundColor: primaryColor,
              foregroundColor: Colors.white,
              elevation: 2,
              shadowColor: primaryColor.withValues(alpha: 0.3),
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(16),
              ),
            ),
          ),
        ),
        SizedBox(width: isSmallScreen ? 12 : 16),
        OutlinedButton.icon(
          onPressed: _reset,
          icon: const Icon(Icons.refresh, size: 20),
          label: const Text('重置'),
          style: OutlinedButton.styleFrom(
            padding: EdgeInsets.symmetric(
                vertical: isSmallScreen ? 14 : 16, horizontal: 20),
            side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(16),
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildResultCard(Color cardColor, bool isDark, bool isSmallScreen) {
    final weightSuggestion = _getWeightSuggestion();

    return AnimatedBuilder(
      animation: _scaleAnimation,
      builder: (context, child) {
        return Transform.scale(
          scale: _scaleAnimation.value,
          child: Card(
            color: cardColor,
            elevation: 8,
            shadowColor: _resultColor.withValues(alpha: 0.3),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(24),
            ),
            child: Container(
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(24),
                gradient: LinearGradient(
                  begin: Alignment.topCenter,
                  end: Alignment.bottomCenter,
                  colors: [
                    _resultColor.withValues(alpha: 0.1),
                    _resultColor.withValues(alpha: 0.05),
                  ],
                ),
              ),
              padding: EdgeInsets.all(isSmallScreen ? 20 : 24),
              child: Column(
                children: [
                  _buildBmiGauge(isSmallScreen),
                  SizedBox(height: isSmallScreen ? 16 : 20),
                  Text(
                    _interpretation,
                    textAlign: TextAlign.center,
                    style: TextStyle(
                      fontSize: isSmallScreen ? 16 : 18,
                      height: 1.6,
                      color: isDark
                          ? Colors.white.withValues(alpha: 0.87)
                          : Colors.black.withValues(alpha: 0.87),
                    ),
                  ),
                  if (weightSuggestion.isNotEmpty) ...[
                    const SizedBox(height: 16),
                    Container(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 16, vertical: 12),
                      decoration: BoxDecoration(
                        color: _resultColor.withValues(alpha: 0.1),
                        borderRadius: BorderRadius.circular(12),
                        border: Border.all(
                            color: _resultColor.withValues(alpha: 0.3)),
                      ),
                      child: Row(
                        children: [
                          Icon(Icons.info_outline,
                              color: _resultColor, size: 20),
                          const SizedBox(width: 8),
                          Expanded(
                            child: Text(
                              weightSuggestion,
                              style: TextStyle(
                                fontSize: isSmallScreen ? 13 : 14,
                                color: _resultColor,
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ],
              ),
            ),
          ),
        );
      },
    );
  }

  Widget _buildBmiGauge(bool isSmallScreen) {
    final bmi = _bmi ?? 0;

    return SizedBox(
      height: isSmallScreen ? 160 : 200,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            size: Size(isSmallScreen ? 160 : 200, isSmallScreen ? 160 : 200),
            painter: BmiGaugePainter(_resultColor, _progressAnimation.value),
          ),
          Positioned(
            bottom: 20,
            child: Column(
              children: [
                Text(
                  bmi.toStringAsFixed(1),
                  style: TextStyle(
                    fontSize: isSmallScreen ? 42 : 52,
                    fontWeight: FontWeight.bold,
                    color: _resultColor,
                    height: 1,
                  ),
                ),
                Text(
                  _category,
                  style: TextStyle(
                    fontSize: isSmallScreen ? 16 : 18,
                    color: _resultColor,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildEmptyState(bool isDark) {
    return Container(
      padding: const EdgeInsets.all(40),
      decoration: BoxDecoration(
        color: Colors.grey.withValues(alpha: 0.05),
        borderRadius: BorderRadius.circular(24),
        border: Border.all(
          color: Colors.grey.withValues(alpha: 0.2),
          style: BorderStyle.solid,
        ),
      ),
      child: Column(
        children: [
          Icon(
            Icons.monitor_heart_outlined,
            size: 80,
            color: isDark ? Colors.grey[600] : Colors.grey[400],
          ),
          const SizedBox(height: 16),
          Text(
            '输入身高和体重',
            textAlign: TextAlign.center,
            style: TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.bold,
              color: isDark ? Colors.grey[400] : Colors.grey[600],
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '开始计算你的 BMI 指数',
            textAlign: TextAlign.center,
            style: TextStyle(
              fontSize: 16,
              color: isDark ? Colors.grey[500] : Colors.grey[500],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildBmiScale(bool isSmallScreen) {
    return Container(
      padding: EdgeInsets.all(isSmallScreen ? 16 : 20),
      decoration: BoxDecoration(
        color: Colors.grey.withValues(alpha: 0.05),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            'BMI 参考标准',
            style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 16),
          _buildScaleItem('偏瘦', '< 18.5', const Color(0xFFF59E0B)),
          const SizedBox(height: 12),
          _buildScaleItem('正常', '18.5 - 24', const Color(0xFF10B981)),
          const SizedBox(height: 12),
          _buildScaleItem('超重', '24 - 28', const Color(0xFFF59E0B)),
          const SizedBox(height: 12),
          _buildScaleItem('肥胖', '> 28', const Color(0xFFEF4444)),
        ],
      ),
    );
  }

  Widget _buildScaleItem(String label, String range, Color color) {
    return Row(
      children: [
        Container(
          width: 12,
          height: 12,
          decoration: BoxDecoration(
            color: color,
            shape: BoxShape.circle,
          ),
        ),
        const SizedBox(width: 12),
        Expanded(
          child: Text(
            label,
            style: const TextStyle(fontSize: 14),
          ),
        ),
        Text(
          range,
          style: TextStyle(
            fontSize: 14,
            color: color,
            fontWeight: FontWeight.w600,
          ),
        ),
      ],
    );
  }
}

class BmiGaugePainter extends CustomPainter {
  final Color color;
  final double progress;

  BmiGaugePainter(this.color, this.progress);

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = size.width / 2 - 20;

    // 背景弧线
    final bgPaint = Paint()
      ..color = Colors.grey.withValues(alpha: 0.2)
      ..strokeWidth = 20
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      math.pi * 0.8,
      math.pi * 0.4,
      false,
      bgPaint,
    );

    // 进度弧线
    final progressPaint = Paint()
      ..color = color
      ..strokeWidth = 20
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;
    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      math.pi * 0.8,
      math.pi * 0.4 * progress,
      false,
      progressPaint,
    );
  }

  @override
  bool shouldRepaint(covariant BmiGaugePainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.progress != progress;
  }
}
相关推荐
mCell6 小时前
如何零成本搭建个人站点
前端·程序员·github
mCell7 小时前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭7 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清7 小时前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
银烛木8 小时前
黑马程序员前端h5+css3
前端·css·css3
m0_607076608 小时前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
听海边涛声8 小时前
CSS3 图片模糊处理
前端·css·css3
IT、木易8 小时前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得08 小时前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化
anOnion8 小时前
构建无障碍组件之Dialog Pattern
前端·html·交互设计