SunHongxun'S Site
计算生物学 · 药物发现
← 从侧边栏选择分类开始阅读

孙红勋和王乐怡的专属版本

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
    statusBarColor: Colors.transparent,
    statusBarIconBrightness: Brightness.dark,
  ));
  runApp(const MoleculeTimerApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Molecule Timer',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF4A90E2),
          surface: const Color(0xFFF5F7FA),
          surfaceContainer: Colors.white,
        ),
        scaffoldBackgroundColor: const Color(0xFFF5F7FA),
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.transparent,
          surfaceTintColor: Colors.transparent,
          elevation: 0,
          centerTitle: true,
          titleTextStyle: TextStyle(
            color: Color(0xFF2D3436),
            fontSize: 20,
            fontWeight: FontWeight.w600,
            letterSpacing: 0.5,
            fontFamily: 'Roboto',
          ),
        ),
      ),
      home: const TimerHomePage(),
    );
  }
}

// ---------------- 数据模型 ----------------
class TimerModel {
  String id;
  int initialSeconds;
  int remainingSeconds;
  bool isPaused;
  String label;

  TimerModel({
    required this.initialSeconds,
    required this.remainingSeconds,
    this.isPaused = false,
    this.label = "实验步骤",
  }) : id = DateTime.now().millisecondsSinceEpoch.toString() + 
            DateTime.now().microsecond.toString();
}

// ---------------- 主页面 ----------------
class TimerHomePage extends StatefulWidget {
  const TimerHomePage({super.key});

  @override
  State<TimerHomePage> createState() => _TimerHomePageState();
}

class _TimerHomePageState extends State<TimerHomePage> with TickerProviderStateMixin {
  final List<TimerModel> _timers = [];
  final List<int> _presets = [10, 20, 30, 60, 120, 150];
  Timer? _globalTicker;

  // 状态控制
  bool _isLoveExpanded = false;     // 爱意卡片默认收缩
  bool _isPanelVisible = true;      // 底部面板默认可见

  // 恋爱倒计时
  late DateTime _loveStartDate;
  late DateTime _loveEndDate;
  late Duration _loveRemaining;

  // 动画控制器
  late AnimationController _heartController;
  late Animation<double> _heartScaleAnimation;

  @override
  void initState() {
    super.initState();
    _presets.sort();

    // 恋爱时间逻辑
    _loveStartDate = DateTime(2022, 9, 17, 21, 00);
    _loveEndDate = DateTime(_loveStartDate.year + 100, _loveStartDate.month, _loveStartDate.day, _loveStartDate.hour, _loveStartDate.minute);
    _calculateLoveTime();

    // 心跳动画
    _heartController = AnimationController(
      vsync: this, 
      duration: const Duration(milliseconds: 1000)
    )..repeat(reverse: true);

    _heartScaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
      CurvedAnimation(parent: _heartController, curve: Curves.easeInOut)
    );

    // 全局计时器
    _globalTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
      _calculateLoveTime();
      bool needsSetState = false;
      if (_isLoveExpanded) needsSetState = true;

      if (_timers.isNotEmpty) {
        for (var t in _timers) {
          if (!t.isPaused && t.remainingSeconds > 0) {
            t.remainingSeconds--;
            needsSetState = true;
          }
        }
      }
      if (needsSetState) setState(() {});
    });
  }

  void _calculateLoveTime() {
    final now = DateTime.now();
    if (now.isBefore(_loveEndDate)) {
      _loveRemaining = _loveEndDate.difference(now);
    } else {
      _loveRemaining = Duration.zero;
    }
  }

  @override
  void dispose() {
    _globalTicker?.cancel();
    _heartController.dispose();
    super.dispose();
  }

  // ---------------- 逻辑方法 ----------------
  void _addTimer(int minutes) {
    setState(() {
      int seconds = minutes * 60;
      _timers.add(TimerModel(
        initialSeconds: seconds,
        remainingSeconds: seconds,
        label: _formatLabel(minutes),
      ));
    });
  }

  String _formatLabel(int minutes) {
    if (minutes >= 60) {
      double hours = minutes / 60;
      return "${hours % 1 == 0 ? hours.toInt() : hours.toStringAsFixed(1)} 小时";
    }
    return "$minutes 分钟";
  }

  void _addNewPreset(int minutes) {
    if (!_presets.contains(minutes)) {
      setState(() {
        _presets.add(minutes);
        _presets.sort();
      });
    }
  }

  void _removePreset(int minutes) {
    setState(() {
      _presets.remove(minutes);
    });
  }

  Future<void> _showAddPresetDialog() async {
    final TextEditingController controller = TextEditingController();
    return showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text("添加新时长"),
          content: TextField(
            controller: controller,
            keyboardType: TextInputType.number,
            autofocus: true,
            decoration: const InputDecoration(hintText: "输入分钟数", suffixText: "分钟"),
          ),
          actions: [
            TextButton(onPressed: () => Navigator.pop(context), child: const Text("取消")),
            TextButton(
              onPressed: () {
                final int? mins = int.tryParse(controller.text);
                if (mins != null && mins > 0) {
                  _addNewPreset(mins);
                  Navigator.pop(context);
                }
              },
              child: const Text("添加"),
            ),
          ],
        );
      },
    );
  }

  void _togglePause(TimerModel timer) => setState(() => timer.isPaused = !timer.isPaused);
  void _deleteTimer(TimerModel timer) => setState(() => _timers.remove(timer));

  String _formatTime(int totalSeconds) {
    int h = totalSeconds ~/ 3600;
    int m = (totalSeconds % 3600) ~/ 60;
    int s = totalSeconds % 60;
    String minSec = "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
    return h > 0 ? "${h.toString().padLeft(2, '0')}:$minSec" : minSec;
  }

  // ---------------- UI 构建 ----------------
  @override
  Widget build(BuildContext context) {
    // 获取屏幕底部高度,用于动画计算
    final bottomPadding = MediaQuery.of(context).padding.bottom;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Molecule Timer'),
        // 左上角心跳入口
        leading: GestureDetector(
          onTap: () => setState(() => _isLoveExpanded = !_isLoveExpanded),
          child: ScaleTransition(
            scale: _heartScaleAnimation,
            child: Icon(
              _isLoveExpanded ? Icons.favorite : Icons.favorite_border,
              color: const Color(0xFFFF6B81),
              size: 24,
            ),
          ),
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.cleaning_services_outlined, color: Colors.grey),
            onPressed: () => setState(() => _timers.clear()),
            tooltip: "清空实验",
          )
        ],
      ),
      // 使用 Stack 实现层叠布局
      body: Stack(
        children: [
          Column(
            children: [
              // 1. 顶部:爱意卡片 (AnimatedSize)
              AnimatedSize(
                duration: const Duration(milliseconds: 300),
                curve: Curves.easeInOut,
                child: Container(
                  height: _isLoveExpanded ? null : 0,
                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                  child: ClipRect(child: _buildLoveCard()),
                ),
              ),

              // 2. 中间:计时器列表 (占满剩余空间)
              Expanded(
                child: _timers.isEmpty
                    ? _buildEmptyState()
                    : ListView.builder(
                        // 底部留出足够空间,防止被FAB遮挡
                        padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), 
                        itemCount: _timers.length,
                        itemBuilder: (context, index) {
                          return _buildTimerCard(_timers[index]);
                        },
                      ),
              ),
            ],
          ),

          // 3. 底部:完全可折叠的控制台 (AnimatedPositioned)
          AnimatedPositioned(
            duration: const Duration(milliseconds: 400),
            curve: Curves.easeInOutCubic,
            left: 0,
            right: 0,
            // 如果可见,距离底部0;如果不可见,移出屏幕外 (-300)
            bottom: _isPanelVisible ? 0 : -300, 
            child: Container(
              padding: EdgeInsets.fromLTRB(20, 20, 20, 30 + bottomPadding),
              decoration: BoxDecoration(
                color: Colors.white,
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.1),
                    blurRadius: 30,
                    offset: const Offset(0, -5),
                  )
                ],
                borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
              ),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  // 标题栏
                  Row(
                    children: [
                      const Icon(Icons.bolt_rounded, color: Colors.orangeAccent, size: 22),
                      const SizedBox(width: 8),
                      Text("快速预设", 
                        style: TextStyle(
                          color: Colors.grey[800], 
                          fontWeight: FontWeight.bold,
                          fontSize: 16
                        )
                      ),
                      const Spacer(),
                      // 关闭按钮
                      IconButton(
                        onPressed: () => setState(() => _isPanelVisible = false),
                        icon: const Icon(Icons.keyboard_arrow_down_rounded, color: Colors.grey),
                      )
                    ],
                  ),
                  const SizedBox(height: 10),
                  // 预设按钮区域
                  Wrap(
                    spacing: 12,
                    runSpacing: 12,
                    children: [
                      ..._presets.map((mins) => _buildPresetChip(mins)),
                      _buildAddButton(),
                    ],
                  ),
                ],
              ),
            ),
          ),

          // 4. 右下角:悬浮唤醒按钮 (FAB)
          // 只有当面板隐藏时才显示
          AnimatedPositioned(
            duration: const Duration(milliseconds: 300),
            curve: Curves.easeOut,
            right: 20,
            bottom: _isPanelVisible ? -100 : 30 + bottomPadding, // 面板显示时FAB藏到底部,面板隐藏时FAB浮上来
            child: FloatingActionButton(
              onPressed: () => setState(() => _isPanelVisible = true),
              backgroundColor: Colors.white,
              elevation: 4,
              shape: const CircleBorder(),
              child: const Icon(Icons.bolt_rounded, color: Colors.orangeAccent, size: 28),
            ),
          ),
        ],
      ),
    );
  }

  // Love Card
  Widget _buildLoveCard() {
    int years = (_loveRemaining.inDays / 365).floor();
    int days = _loveRemaining.inDays % 365;
    int hours = _loveRemaining.inHours % 24;

    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [Colors.white, const Color(0xFFFFF0F3)], 
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        borderRadius: BorderRadius.circular(24),
        border: Border.all(color: const Color(0xFFFFE3E3), width: 1),
        boxShadow: [
          BoxShadow(
            color: const Color(0xFFFFB8B8).withOpacity(0.15),
            blurRadius: 15,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Text("shx", style: TextStyle(fontFamily: 'Courier', fontWeight: FontWeight.bold, color: Color(0xFF555555), fontSize: 18)),
              const SizedBox(width: 8),
              const Icon(Icons.favorite, color: Color(0xFFFF6B81), size: 20),
              const SizedBox(width: 8),
              const Text("wly", style: TextStyle(fontFamily: 'Courier', fontWeight: FontWeight.bold, color: Color(0xFF555555), fontSize: 18)),
            ],
          ),
          const SizedBox(height: 8),
          const Text("100-Year Promise Countdown", style: TextStyle(fontSize: 10, color: Colors.grey, letterSpacing: 1)),
          const SizedBox(height: 4),
          Text(
            "$years 年 $days 天 $hours 小时",
            style: const TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.w300,
              color: Color(0xFF2D3436),
              fontFamily: 'Courier',
            ),
          ),
        ],
      ),
    );
  }

  // Timer Card
  Widget _buildTimerCard(TimerModel timer) {
    final isFinished = timer.remainingSeconds == 0;
    final progress = timer.initialSeconds == 0 ? 0.0 : timer.remainingSeconds / timer.initialSeconds;
    Color statusColor = isFinished ? const Color(0xFFFF6B6B) : (timer.isPaused ? const Color(0xFFFF9F43) : const Color(0xFF4A90E2));

    return Container(
      margin: const EdgeInsets.only(bottom: 16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(24),
        boxShadow: [
          BoxShadow(
            color: const Color(0xFF4A90E2).withOpacity(0.08),
            blurRadius: 15,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
        child: Row(
          children: [
            Stack(
              alignment: Alignment.center,
              children: [
                SizedBox(
                  width: 56, height: 56,
                  child: CircularProgressIndicator(
                    value: isFinished ? 1 : progress,
                    strokeWidth: 5,
                    backgroundColor: Colors.grey[100],
                    color: statusColor,
                    strokeCap: StrokeCap.round,
                  ),
                ),
                if (timer.isPaused && !isFinished)
                  const Icon(Icons.pause, size: 24, color: Color(0xFFFF9F43))
                else if (isFinished)
                  const Icon(Icons.check, size: 24, color: Color(0xFFFF6B6B)),
              ],
            ),
            const SizedBox(width: 20),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    _formatTime(timer.remainingSeconds),
                    style: TextStyle(
                      fontSize: 36, fontWeight: FontWeight.w700, fontFamily: 'Courier',
                      color: isFinished ? const Color(0xFFFF6B6B) : const Color(0xFF2D3436),
                      letterSpacing: -1,
                    ),
                  ),
                  Text(
                    isFinished ? "Time's up!" : "时长: ${timer.label}",
                    style: TextStyle(color: Colors.grey[500], fontSize: 13, fontWeight: FontWeight.w500),
                  ),
                ],
              ),
            ),
            if (!isFinished)
              IconButton(
                onPressed: () => _togglePause(timer),
                icon: Icon(timer.isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 32, color: timer.isPaused ? const Color(0xFF4A90E2) : Colors.grey[400]),
              ),
            IconButton(
              onPressed: () => _deleteTimer(timer),
              icon: Icon(Icons.close_rounded, size: 24, color: Colors.grey[300]),
            ),
          ],
        ),
      ),
    );
  }

  // Chips
  Widget _buildPresetChip(int minutes) {
    String label = minutes >= 60 ? "${(minutes / 60).toStringAsFixed(1).replaceAll('.0', '')}h" : "${minutes}m";
    return Material(
      color: Colors.transparent,
      child: InkWell(
        onTap: () => _addTimer(minutes),
        onLongPress: () {
          showDialog(
            context: context, 
            builder: (ctx) => AlertDialog(
              title: const Text("删除预设"),
              content: Text("确定要删除 $label 吗?"),
              actions: [
                TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("取消")),
                TextButton(
                  onPressed: () { _removePreset(minutes); Navigator.pop(ctx); }, 
                  child: const Text("删除", style: TextStyle(color: Colors.red))
                ),
              ],
            )
          );
        },
        borderRadius: BorderRadius.circular(16),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
          decoration: BoxDecoration(color: const Color(0xFFF0F4F8), borderRadius: BorderRadius.circular(16)),
          child: Text(label, style: const TextStyle(color: Color(0xFF4A90E2), fontWeight: FontWeight.w600, fontSize: 15)),
        ),
      ),
    );
  }

  Widget _buildAddButton() {
    return Material(
      color: Colors.transparent,
      child: InkWell(
        onTap: _showAddPresetDialog,
        borderRadius: BorderRadius.circular(16),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
          decoration: BoxDecoration(
            border: Border.all(color: const Color(0xFF4A90E2).withOpacity(0.3), width: 1.5),
            borderRadius: BorderRadius.circular(16),
          ),
          child: const Icon(Icons.add, size: 20, color: Color(0xFF4A90E2)),
        ),
      ),
    );
  }

  Widget _buildEmptyState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.science_outlined, size: 60, color: Colors.grey[200]),
          const SizedBox(height: 16),
          Text("Molecule Timer", style: TextStyle(color: Colors.grey[300], fontSize: 16, fontWeight: FontWeight.bold)),
        ],
      ),
    );
  }
}

💬 留言

✨ 暂无留言