节日倒数日历 —— Flutter + OpenHarmony 鸿蒙风温暖实用工具

个人主页:ujainu

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

文章目录

前言

在快节奏的现代生活中,我们常常被工作与琐事淹没,忘记了那些值得期待的日子------春节团圆、中秋赏月、生日祝福、考试冲刺......这些节点不仅是时间的刻度,更是情感的锚点。

为此,我们基于 Flutter + OpenHarmony 平台,打造了一款兼具 实用性与情感温度节日倒数日历(Festival Countdown Calendar)。它不仅能自动追踪国家法定节日与传统佳节,还支持用户添加个性化纪念日,并在节日当天触发温馨庆祝动画,让每一个重要日子都不被遗忘。

本文将完整解析该应用的实现逻辑,涵盖 日期计算、本地存储、UI 构建与粒子动画 四大核心模块。全文包含详细代码讲解与可运行示例,适合 Flutter 中级开发者学习复用。


一、为什么做"节日倒数日历"?

1. 情感价值 > 功能价值

  • 期待感营造:倒数本身是一种心理激励机制(如"距离假期还有 12 天!")
  • 记忆辅助:避免错过亲友生日、纪念日等私人重要事件
  • 文化传承:通过预设春节、端午、中秋等节日,强化传统节日认知

2. 鸿蒙设计原则深度融入

  • 留白充足:主屏仅保留核心信息(节日名 + 倒数天数),无冗余元素
  • 字体层级清晰
    • 节日名称 → 28px,加粗
    • "还剩 X 天" → 64px,超大字号突出
    • 辅助说明 → 14px,浅灰色
  • 色彩情绪化
    • 春节 → 红色系
    • 中秋 → 金色/橙色
    • 国庆 → 红黄渐变
    • 自定义 → 柔和蓝紫(#6200EE 主色延伸)

核心功能清单

  • 预设 8+ 常见节日(含农历春节、中秋)
  • 自动计算距下一个节日天数
  • 支持添加自定义纪念日(名称 + 日期)
  • 节日当天播放彩带庆祝动画
  • 本地持久化存储(重启不丢失)

二、技术挑战:如何处理农历节日?

1. 公历 vs 农历问题

  • 国庆、元旦:固定公历日期(10月1日、1月1日)
  • 春节、端午、中秋:基于农历,每年公历日期不同

⚠️ 简化方案

为控制复杂度,本文采用 预置未来三年农历节日公历日期表 (实际项目可用 lunar 包动态计算)。例如:

dart 复制代码
final Map<String, List<DateTime>> _presetFestivals = {
  '春节': [
    DateTime(2025, 1, 29),
    DateTime(2026, 2, 17),
    DateTime(2027, 2, 6),
  ],
  '中秋': [
    DateTime(2025, 10, 6),
    DateTime(2026, 9, 26),
    DateTime(2027, 9, 15),
  ],
  // ...
};

2. 查找"下一个节日"算法

dart 复制代码
FestivalItem _findNextFestival() {
  final now = DateTime.now();
  FestivalItem? next;
  int minDays = 999;

  // 检查预设节日
  for (final entry in _presetFestivals.entries) {
    for (final date in entry.value) {
      if (date.isAfter(now)) {
        final diff = date.difference(now).inDays;
        if (diff < minDays) {
          minDays = diff;
          next = FestivalItem(entry.key, date, isCustom: false);
        }
      }
    }
  }

  // 检查自定义节日
  for (final custom in _customFestivals) {
    DateTime targetDate = custom.date;
    // 支持每年重复(如生日)
    if (targetDate.isBefore(now)) {
      targetDate = DateTime(now.year, custom.date.month, custom.date.day);
      if (targetDate.isBefore(now)) {
        targetDate = DateTime(now.year + 1, custom.date.month, custom.date.day);
      }
    }
    final diff = targetDate.difference(now).inDays;
    if (diff < minDays) {
      minDays = diff;
      next = FestivalItem(custom.name, targetDate, isCustom: true);
    }
  }

  return next ?? FestivalItem('元旦', DateTime(now.year + 1, 1, 1), isCustom: false);
}

🔍 关键逻辑

  • 自定义节日默认 每年重复(生日、纪念日)
  • 若所有节日已过,则返回 下一年元旦

三、本地存储:持久化自定义节日

使用 shared_preferences 存储用户添加的节日:

dart 复制代码
Future<void> _loadCustomFestivals() async {
  final prefs = await SharedPreferences.getInstance();
  final List<String>? saved = prefs.getStringList('custom_festivals');
  if (saved != null) {
    setState(() {
      _customFestivals = saved.map((item) {
        final parts = item.split('|');
        final dateParts = parts[1].split('-');
        return CustomFestival(
          name: parts[0],
          date: DateTime(
            int.parse(dateParts[0]),
            int.parse(dateParts[1]),
            int.parse(dateParts[2]),
          ),
        );
      }).toList();
    });
  }
}

Future<void> _saveCustomFestivals() async {
  final prefs = await SharedPreferences.getInstance();
  final toSave = _customFestivals.map((f) {
    return '${f.name}|${f.date.toIso8601String().substring(0, 10)}';
  }).toList();
  await prefs.setStringList('custom_festivals', toSave);
}

💾 存储格式"生日|2025-08-15",简洁高效


四、UI 实现:鸿蒙风界面构建

1. 主屏:极简信息展示

dart 复制代码
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Text(
      '距 ${nextFestival.name}',
      style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
    ),
    const SizedBox(height: 12),
    Text(
      '还剩 ${nextFestival.days} 天',
      style: const TextStyle(fontSize: 64, fontWeight: FontWeight.bold, height: 1.2),
    ),
    if (nextFestival.days == 0) ...[
      const SizedBox(height: 20),
      _buildCelebrationAnimation(),
    ],
  ],
)

2. 节日卡片列表

dart 复制代码
Card(
  margin: const EdgeInsets.symmetric(vertical: 8),
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
  color: _getFestivalColor(festival.name),
  child: ListTile(
    leading: Icon(_getFestivalIcon(festival.name), size: 28),
    title: Text(festival.name),
    subtitle: Text('${festival.date.month}月${festival.date.day}日'),
    trailing: festival.isCustom
        ? IconButton(
            icon: const Icon(Icons.delete, size: 20),
            onPressed: () => _deleteFestival(festival),
          )
        : null,
  ),
)

3. 添加自定义节日弹窗

dart 复制代码
showDialog(
  context: context,
  builder: (context) => AlertDialog(
    title: const Text('添加纪念日'),
    content: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        TextField(
          controller: nameController,
          decoration: const InputDecoration(hintText: '名称,如:妈妈生日'),
        ),
        const SizedBox(height: 16),
        OutlinedButton(
          onPressed: () async {
            final picked = await showDatePicker(
              context: context,
              initialDate: DateTime.now(),
              firstDate: DateTime(2020),
              lastDate: DateTime(2030),
            );
            if (picked != null) {
              setState(() {
                selectedDate = picked;
              });
            }
          },
          child: Text(selectedDate == null
              ? '选择日期'
              : '${selectedDate!.month}月${selectedDate!.day}日'),
        ),
      ],
    ),
    actions: [
      TextButton(onPressed: Navigator.of(context).pop, child: const Text('取消')),
      ElevatedButton(
        onPressed: () {
          if (nameController.text.isNotEmpty && selectedDate != null) {
            _addCustomFestival(nameController.text, selectedDate!);
            Navigator.of(context).pop();
          }
        },
        child: const Text('保存'),
      ),
    ],
  ),
);

五、节日庆祝动画:轻量级粒子效果

使用 AnimatedOpacity + Positioned 模拟彩带飘落:

dart 复制代码
Widget _buildCelebrationAnimation() {
  return SizedBox(
    height: 100,
    child: Stack(
      children: List.generate(8, (index) {
        return AnimatedOpacity(
          opacity: _animationController.value > 0.5 ? 1.0 : 0.0,
          duration: const Duration(milliseconds: 500),
          child: Positioned(
            left: Random().nextDouble() * MediaQuery.of(context).size.width,
            child: Transform.rotate(
              angle: Random().nextDouble() * pi,
              child: Container(
                width: 8,
                height: 40,
                color: Color.lerp(Colors.red, Colors.yellow, Random().nextDouble()),
              ),
            ),
          ),
        );
      }),
    ),
  );
}

动画逻辑

  • 启动时 _animationController 从 0 → 1
  • 彩带随机位置、颜色、旋转角度
  • 半透明淡入,模拟"从天而降"

六、完整可运行代码

以下为整合所有功能的完整实现,可直接在 Flutter + OpenHarmony 环境中运行:

dart 复制代码
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

const Color kPrimaryColor = Color(0xFF6200EE);
const Color kBackgroundColor = Color(0xFFF9F9FB);

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '节日倒数日历',
      theme: ThemeData(
        primarySwatch: Colors.purple,
        primaryColor: kPrimaryColor,
        scaffoldBackgroundColor: kBackgroundColor,
        appBarTheme: const AppBarTheme(backgroundColor: kPrimaryColor),
      ),
      home: const FestivalCountdownApp(),
    );
  }
}

class FestivalItem {
  final String name;
  final DateTime date;
  final bool isCustom;
  final int days;

  FestivalItem(this.name, DateTime targetDate, {this.isCustom = false})
      : date = targetDate,
        days = targetDate.difference(DateTime.now()).inDays;
}

class CustomFestival {
  final String name;
  final DateTime date;

  CustomFestival(this.name, this.date);
}

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

  @override
  State<FestivalCountdownApp> createState() => _FestivalCountdownAppState();
}

class _FestivalCountdownAppState extends State<FestivalCountdownApp>
    with TickerProviderStateMixin {
  late List<CustomFestival> _customFestivals;
  late AnimationController _animationController;

  // 预设节日(含未来三年农历节日公历日期)
  final Map<String, List<DateTime>> _presetFestivals = {
    '元旦': [for (int y = 2025; y <= 2027; y++) DateTime(y, 1, 1)],
    '春节': [
      DateTime(2025, 1, 29),
      DateTime(2026, 2, 17),
      DateTime(2027, 2, 6),
    ],
    '清明': [
      DateTime(2025, 4, 4),
      DateTime(2026, 4, 4),
      DateTime(2027, 4, 4),
    ],
    '劳动节': [for (int y = 2025; y <= 2027; y++) DateTime(y, 5, 1)],
    '端午': [
      DateTime(2025, 5, 31),
      DateTime(2026, 5, 20),
      DateTime(2027, 5, 9),
    ],
    '中秋': [
      DateTime(2025, 10, 6),
      DateTime(2026, 9, 26),
      DateTime(2027, 9, 15),
    ],
    '国庆': [for (int y = 2025; y <= 2027; y++) DateTime(y, 10, 1)],
  };

  // 使用非 late 的变量,并设置默认值
  FestivalItem? _nextFestival; // ← 改为可为空,避免 LateInitializationError

  @override
  void initState() {
    super.initState();
    _customFestivals = [];
    _animationController = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    _loadData(); // 异步加载数据
  }

  Future<void> _loadData() async {
    await _loadCustomFestivals();
    _calculateNextFestival(); // 这里会触发 setState
    if (_nextFestival?.days == 0) {
      _animationController.repeat();
    }
  }

  Future<void> _loadCustomFestivals() async {
    final prefs = await SharedPreferences.getInstance();
    final List<String>? saved = prefs.getStringList('custom_festivals');
    if (saved != null) {
      setState(() {
        _customFestivals = saved.map((item) {
          final parts = item.split('|');
          final dateStr = parts[1];
          final y = int.parse(dateStr.substring(0, 4));
          final m = int.parse(dateStr.substring(5, 7));
          final d = int.parse(dateStr.substring(8, 10));
          return CustomFestival(parts[0], DateTime(y, m, d));
        }).toList();
      });
    }
  }

  Future<void> _saveCustomFestivals() async {
    final prefs = await SharedPreferences.getInstance();
    final toSave = _customFestivals.map((f) {
      return '${f.name}|${f.date.toIso8601String().substring(0, 10)}';
    }).toList();
    await prefs.setStringList('custom_festivals', toSave);
  }

  void _calculateNextFestival() {
    final now = DateTime.now();
    FestivalItem? next;
    int minDays = 9999;

    // Check preset festivals
    for (final entry in _presetFestivals.entries) {
      for (final date in entry.value) {
        if (date.isAfter(now)) {
          final diff = date.difference(now).inDays;
          if (diff < minDays) {
            minDays = diff;
            next = FestivalItem(entry.key, date, isCustom: false);
          }
        }
      }
    }

    // Check custom festivals (annual recurrence)
    for (final custom in _customFestivals) {
      DateTime targetDate = DateTime(now.year, custom.date.month, custom.date.day);
      if (targetDate.isBefore(now)) {
        targetDate = DateTime(now.year + 1, custom.date.month, custom.date.day);
      }
      final diff = targetDate.difference(now).inDays;
      if (diff < minDays) {
        minDays = diff;
        next = FestivalItem(custom.name, targetDate, isCustom: true);
      }
    }

    // Fallback to next New Year
    if (next == null) {
      next = FestivalItem('元旦', DateTime(now.year + 1, 1, 1), isCustom: false);
    }

    setState(() {
      _nextFestival = next; // ← 现在安全了
    });

    // Trigger celebration if today
    if (next?.days == 0) {
      _animationController.repeat();
    } else {
      _animationController.stop();
    }
  }

  Color _getFestivalColor(String name) {
    switch (name) {
      case '春节':
        return Colors.red[100]!;
      case '中秋':
        return Colors.orange[100]!;
      case '国庆':
        return Colors.amber[100]!;
      default:
        return kPrimaryColor.withOpacity(0.1);
    }
  }

  IconData _getFestivalIcon(String name) {
    switch (name) {
      case '春节':
        return Icons.cake;
      case '中秋':
        return Icons.brightness_1;
      case '国庆':
        return Icons.flag;
      case '元旦':
        return Icons.calendar_today;
      default:
        return Icons.event;
    }
  }

  void _addCustomFestival(String name, DateTime date) {
    setState(() {
      _customFestivals.add(CustomFestival(name, date));
    });
    _saveCustomFestivals();
    _calculateNextFestival();
  }

  void _deleteFestival(FestivalItem item) {
    setState(() {
      _customFestivals.removeWhere((f) => f.name == item.name);
    });
    _saveCustomFestivals();
    _calculateNextFestival();
  }

  @override
  Widget build(BuildContext context) {
    // 如果 _nextFestival 尚未初始化,显示占位符
    final festival = _nextFestival ?? FestivalItem('加载中...', DateTime.now(), isCustom: false);

    return Scaffold(
      appBar: AppBar(
        title: const Text('节日倒数日历'),
        actions: [
          IconButton(
            icon: const Icon(Icons.add),
            onPressed: () => _showAddDialog(),
          ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: () async {
          _calculateNextFestival();
        },
        child: ListView(
          padding: const EdgeInsets.all(24),
          children: [
            // Main countdown display
            Center(
              child: Column(
                children: [
                  Text(
                    '距 ${festival.name}',
                    style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 12),
                  Text(
                    '还剩 ${festival.days} 天',
                    style: const TextStyle(fontSize: 64, fontWeight: FontWeight.bold, height: 1.2),
                  ),
                  if (festival.days == 0) ...[
                    const SizedBox(height: 20),
                    _buildCelebrationAnimation(),
                  ],
                ],
              ),
            ),
            const SizedBox(height: 40),

            // Festival list header
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: const [
                Text('所有节日', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
                Text('点击可删除自定义节日', style: TextStyle(color: Colors.grey, fontSize: 12)),
              ],
            ),
            const SizedBox(height: 16),

            // Festival list
            ..._getAllFestivals().map((festival) {
              return Card(
                margin: const EdgeInsets.symmetric(vertical: 6),
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
                color: _getFestivalColor(festival.name),
                child: ListTile(
                  leading: Icon(_getFestivalIcon(festival.name), size: 28),
                  title: Text(festival.name),
                  subtitle: Text('${festival.date.month}月${festival.date.day}日'),
                  trailing: festival.isCustom
                      ? IconButton(
                          icon: const Icon(Icons.delete, size: 20, color: Colors.red),
                          onPressed: () => _deleteFestival(festival),
                        )
                      : null,
                ),
              );
            }),
          ],
        ),
      ),
    );
  }

  List<FestivalItem> _getAllFestivals() {
    final now = DateTime.now();
    final List<FestivalItem> all = [];

    // Add preset festivals (next occurrence only)
    for (final entry in _presetFestivals.entries) {
      for (final date in entry.value) {
        if (date.isAfter(now)) {
          all.add(FestivalItem(entry.key, date, isCustom: false));
          break;
        }
      }
    }

    // Add custom festivals
    for (final custom in _customFestivals) {
      DateTime targetDate = DateTime(now.year, custom.date.month, custom.date.day);
      if (targetDate.isBefore(now)) {
        targetDate = DateTime(now.year + 1, custom.date.month, custom.date.day);
      }
      all.add(FestivalItem(custom.name, targetDate, isCustom: true));
    }

    // Sort by date
    all.sort((a, b) => a.date.compareTo(b.date));
    return all;
  }

  void _showAddDialog() {
    final nameController = TextEditingController();
    DateTime? selectedDate;

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('添加纪念日'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
              controller: nameController,
              decoration: const InputDecoration(hintText: '名称,如:妈妈生日'),
            ),
            const SizedBox(height: 16),
            OutlinedButton(
              onPressed: () async {
                final picked = await showDatePicker(
                  context: context,
                  initialDate: DateTime.now(),
                  firstDate: DateTime(2020),
                  lastDate: DateTime(2030),
                );
                if (picked != null) {
                  setState(() {
                    selectedDate = picked;
                  });
                }
              },
              child: Text(selectedDate == null
                  ? '选择日期'
                  : '${selectedDate!.month}月${selectedDate!.day}日'),
            ),
          ],
        ),
        actions: [
          TextButton(onPressed: Navigator.of(context).pop, child: const Text('取消')),
          ElevatedButton(
            onPressed: () {
              if (nameController.text.isNotEmpty && selectedDate != null) {
                _addCustomFestival(nameController.text, selectedDate!);
                Navigator.of(context).pop();
              }
            },
            child: const Text('保存'),
          ),
        ],
      ),
    );
  }

  Widget _buildCelebrationAnimation() {
    return SizedBox(
      height: 100,
      child: Stack(
        children: List.generate(8, (index) {
          return AnimatedBuilder(
            animation: _animationController,
            builder: (context, child) {
              return Positioned(
                left: (Random().nextDouble() * MediaQuery.of(context).size.width) - 20,
                top: _animationController.value * 100,
                child: Transform.rotate(
                  angle: Random().nextDouble() * pi,
                  child: Container(
                    width: 8,
                    height: 40,
                    color: Color.lerp(Colors.red, Colors.yellow, Random().nextDouble())!,
                  ),
                ),
              );
            },
          );
        }),
      ),
    );
  }
}

运行界面

结语

这款节日倒数日历,实现了日期计算、本地存储、情感化 UI 与轻量动画 四大能力,完美诠释了 Flutter 的跨平台效率OpenHarmony 的人文关怀

它不仅是一个工具,更是一份提醒:在奔忙的日子里,别忘了那些值得期待与庆祝的时刻。正如鸿蒙所倡导的:"科技应服务于人的情感与记忆。"

相关推荐
钛态4 小时前
Flutter for OpenHarmony:mockito 单元测试的替身演员,轻松模拟复杂依赖(测试驱动开发必备) 深度解析与鸿蒙适配指南
服务器·驱动开发·安全·flutter·华为·单元测试·harmonyos
念格7 小时前
Flutter 弹窗 UI 不刷新?用 StatefulBuilder 解决
flutter
程序员老刘9 小时前
2026春招Flutter岗位为何变少?我看到的3个招聘逻辑变化
flutter·ai编程·客户端
念格10 小时前
Flutter 实现点击任意位置收起键盘的最佳实践
flutter
念格10 小时前
Flutter ListView Physics 滚动物理效果详解
flutter
国医中兴10 小时前
ClickHouse的数据模型设计:从理论到实践
flutter·harmonyos·鸿蒙·openharmony
特立独行的猫a11 小时前
OpenHarmony海思WS63星闪平台:LVGL UI框架底层显示驱动移植指南
ui·lvgl·移植·openharmony·驱动·ws63
国医中兴12 小时前
ClickHouse数据导入导出最佳实践:从性能到可靠性
flutter·harmonyos·鸿蒙·openharmony
国医中兴13 小时前
大数据处理的性能优化技巧:从理论到实践
flutter·harmonyos·鸿蒙·openharmony
●VON14 小时前
Flutter 入门指南:从基础组件到状态管理核心机制
前端·学习·flutter·von