在这里插入图片描述

家庭生活中有很多需要记住的日子,孩子的家长会、老人的体检、全家的聚餐。

今天来实现日历Tab,用来管理家庭的各种活动和日程。

功能规划

日历Tab需要这些功能:

  • 显示月历视图,可以切换月份
  • 点击某一天显示当天的活动
  • 有活动的日期要有标记
  • 支持添加新活动
  • 提供纪念日和待办事项的快捷入口

这个页面需要用到状态,所以用StatefulWidget。日历组件是页面的核心,需要处理日期选择、月份切换、活动标记等交互。活动列表根据选中日期动态更新,用户能快速查看某天的安排。整个页面的设计要简洁直观,让用户能轻松管理家庭日程。

页面状态定义

先定义页面需要的状态变量:

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

  
  State<CalendarTab> createState() => _CalendarTabState();
}

class _CalendarTabState extends State<CalendarTab> {
  CalendarFormat _calendarFormat = CalendarFormat.month;
  DateTime _focusedDay = DateTime.now();
  DateTime? _selectedDay;

_calendarFormat控制日历的显示格式,月视图还是周视图。

_focusedDay是当前聚焦的日期,用于控制日历显示哪个月。

_selectedDay是用户选中的日期,用于显示当天的活动列表。

CalendarFormat是table_calendar库提供的枚举类型,有month、twoWeeks、week三个值。month是月视图,显示整个月的日历;week是周视图,只显示一周;twoWeeks是两周视图,介于两者之间。_focusedDay用DateTime.now()初始化,表示当前日期。_selectedDay用可空类型,因为初始时可能没有选中日期,在initState里会设置为今天。

  
  void initState() {
    super.initState();
    _selectedDay = _focusedDay;
  }

初始化时把选中日期设为今天。

这样用户打开页面就能看到今天的活动。

initState是State的生命周期方法,在组件创建时调用一次。这里把_selectedDay设为_focusedDay,也就是今天。这样用户打开页面时,日历会高亮今天,下方的活动列表会显示今天的活动。这种默认行为符合用户预期,因为大多数情况下用户关心的是今天或最近几天的安排。

页面基础结构

搭建页面框架:

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('家庭日历'),
        actions: [
          IconButton(
            icon: const Icon(Icons.cake),
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const AnniversaryListScreen()),
            ),
          ),

标题叫"家庭日历",突出家庭属性。

右上角放纪念日入口,用蛋糕图标,点击跳转到纪念日列表。

纪念日包括家人生日、结婚纪念日这些重要日子。

AppBar的标题用"家庭日历"而不是简单的"日历",强调这是家庭共享的日程管理工具。actions数组包含两个IconButton,第一个是纪念日入口。蛋糕图标很直观,用户一看就知道是生日或纪念日相关的功能。点击后用Navigator.push跳转到AnniversaryListScreen,这个页面会列出所有的纪念日,用户可以查看、添加、编辑纪念日。

          IconButton(
            icon: const Icon(Icons.checklist),
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const TodoListScreen()),
            ),
          ),
        ],
      ),

待办事项入口用清单图标。

待办事项是一些需要完成的任务,比如准备家长会材料、预约体检。

第二个IconButton是待办事项入口,用checklist图标,表示清单或任务列表。点击跳转到TodoListScreen,用户可以查看、添加、完成待办事项。待办事项和日历活动是两个不同的概念,活动是有明确时间的事件,待办事项是需要完成的任务,可能没有具体时间。把两者分开管理,但都在日历Tab提供入口,方便用户快速访问。

主体布局

主体分上下两部分,上面是日历,下面是活动列表:

      body: Consumer<EventProvider>(
        builder: (context, provider, _) {
          return Column(
            children: [
              _buildCalendar(provider),
              Expanded(
                child: _buildEventList(provider),
              ),
            ],
          );
        },
      ),

Column垂直排列,日历在上,活动列表在下。

活动列表用Expanded占据剩余空间。

Consumer监听EventProvider,活动数据变化时自动刷新。

Consumer是Provider的核心组件,监听EventProvider的变化。当用户添加、删除、修改活动时,Provider会调用notifyListeners,Consumer收到通知后重新执行builder函数,UI就更新了。Column垂直排列日历和活动列表,日历的高度由内容决定,活动列表用Expanded占据剩余空间。这样无论屏幕多高,活动列表都能填满剩余区域,不会留下空白。

      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => AddEventScreen(selectedDate: _selectedDay)),
        ),
        child: const Icon(Icons.add),
      ),
    );
  }

悬浮按钮用来添加新活动。

把当前选中的日期传给添加页面,这样新活动默认就是这一天的。

FloatingActionButton是Material Design的悬浮操作按钮,通常用于页面的主要操作。这里用来添加新活动,点击后跳转到AddEventScreen。selectedDate参数传入_selectedDay,这样添加页面的日期选择器会默认选中这个日期,用户不需要再手动选择,减少操作步骤。如果_selectedDay是null,AddEventScreen应该默认选择今天。这种智能默认值的设计能显著提升用户体验。

日历组件实现

日历用table_calendar这个库:

  Widget _buildCalendar(EventProvider provider) {
    return TableCalendar(
      firstDay: DateTime.utc(2020, 1, 1),
      lastDay: DateTime.utc(2030, 12, 31),
      focusedDay: _focusedDay,
      calendarFormat: _calendarFormat,
      selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
      eventLoader: (day) => provider.getEventsByDate(day),

firstDaylastDay设置日历的范围,2020到2030年。

selectedDayPredicate判断某天是否被选中,用于高亮显示。

eventLoader返回某天的活动列表,有活动的日期会显示标记点。

TableCalendar是一个功能强大的日历组件,支持多种视图格式和自定义样式。firstDay和lastDay定义日历的可选范围,这里设为2020到2030年,覆盖了大部分使用场景。focusedDay是当前聚焦的日期,决定日历显示哪个月。selectedDayPredicate是个判断函数,接收一个日期,返回bool表示是否选中。isSameDay是table_calendar提供的工具函数,比较两个日期是否是同一天,忽略时分秒。eventLoader也是个函数,接收日期,返回该日期的活动列表。provider.getEventsByDate查询指定日期的活动,返回List。如果列表不为空,日历会在该日期下方显示标记点,提示用户这天有活动。

      onDaySelected: (selectedDay, focusedDay) {
        setState(() {
          _selectedDay = selectedDay;
          _focusedDay = focusedDay;
        });
      },
      onFormatChanged: (format) {
        setState(() {
          _calendarFormat = format;
        });
      },

onDaySelected处理日期点击,更新选中状态。

onFormatChanged处理格式切换,比如从月视图切到周视图。

都需要调用setState来触发界面刷新。

onDaySelected是日期点击回调,接收两个参数:selectedDay是用户点击的日期,focusedDay是日历应该聚焦的日期。大多数情况下两者相同,但在某些边界情况下可能不同,比如点击上个月或下个月的日期。setState更新_selectedDay和_focusedDay,触发UI重建。日历会高亮新选中的日期,下方的活动列表会显示新日期的活动。onFormatChanged是格式切换回调,用户点击日历头部的格式切换按钮时触发。setState更新_calendarFormat,日历会切换到新格式。这两个回调都需要调用setState,因为它们改变了State的状态,需要重新构建UI。

      calendarStyle: CalendarStyle(
        selectedDecoration: const BoxDecoration(
          color: Color(0xFFE91E63),
          shape: BoxShape.circle,
        ),
        todayDecoration: BoxDecoration(
          color: const Color(0xFFE91E63).withOpacity(0.3),
          shape: BoxShape.circle,
        ),
        markerDecoration: const BoxDecoration(
          color: Color(0xFFE91E63),
          shape: BoxShape.circle,
        ),
      ),

选中日期用粉色圆形背景。

今天用浅粉色,和选中状态区分开。

活动标记点也用粉色,保持颜色统一。

calendarStyle配置日历的视觉样式。selectedDecoration是选中日期的装饰,用粉色圆形背景,颜色是0xFFE91E63,这是Material Design的pink色系。shape设为circle,让背景是圆形而不是方形。todayDecoration是今天的装饰,用浅粉色,透明度30%,和选中状态区分开。如果今天被选中,会同时应用两个装饰,视觉上会更突出。markerDecoration是活动标记点的装饰,也用粉色圆形,尺寸比日期小,显示在日期下方。这种统一的颜色方案让界面协调美观,用户能快速识别不同状态的日期。

      headerStyle: const HeaderStyle(
        formatButtonVisible: true,
        titleCentered: true,
      ),
    );
  }

头部显示格式切换按钮,标题居中。

用户可以在月视图和周视图之间切换。

headerStyle配置日历头部的样式。formatButtonVisible设为true,显示格式切换按钮,用户可以点击切换月视图和周视图。titleCentered设为true,让月份标题居中显示,视觉上更平衡。头部还包含左右箭头按钮,用于切换月份。这些都是table_calendar内置的功能,不需要额外实现。

活动列表实现

根据选中日期显示活动列表:

  Widget _buildEventList(EventProvider provider) {
    final events = _selectedDay != null
        ? provider.getEventsByDate(_selectedDay!)
        : <FamilyEvent>[];

    if (events.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.event_available,
              size: 64.sp,
              color: Colors.grey[300],
            ),

先获取选中日期的活动列表。

如果没有活动,显示空状态提示。

用一个大图标加文字,比空白页面友好。

_buildEventList构建活动列表区域。首先判断_selectedDay是否为null,如果不为null,调用provider.getEventsByDate获取该日期的活动列表;如果为null,返回空列表。events.isEmpty检查是否有活动,如果没有,显示空状态。Center让内容居中显示,Column垂直排列图标和文字。event_available图标表示"有空闲时间",尺寸64像素,颜色用浅灰色。这种空状态设计友好,让用户知道不是出错了,只是这天没有安排活动。

            SizedBox(height: 16.h),
            Text(
              '当天没有活动',
              style: TextStyle(
                fontSize: 16.sp,
                color: Colors.grey,
              ),
            ),
          ],
        ),
      );
    }

提示文字用灰色,不抢眼但能看到。

提示文字"当天没有活动"说明当前状态,字号16像素,颜色用灰色。这种中性的提示不会让用户感到焦虑,只是陈述事实。如果想更友好一点,可以加个副标题,比如"点击右下角+号添加活动",引导用户下一步操作。

    return ListView.builder(
      padding: EdgeInsets.all(16.w),
      itemCount: events.length,
      itemBuilder: (context, index) {
        final event = events[index];
        return _buildEventCard(context, event);
      },
    );
  }

有活动时用ListView.builder展示。

每个活动用卡片的形式显示。

ListView.builder是懒加载的列表组件,只构建可见区域的item,性能好。padding给列表四周添加16像素的内边距,避免内容紧贴屏幕边缘。itemCount是活动数量,itemBuilder是构建函数,接收context和index,返回对应的Widget。这里调用_buildEventCard构建活动卡片,传入event对象。

活动卡片设计

每个活动用卡片展示:

  Widget _buildEventCard(BuildContext context, FamilyEvent event) {
    return Card(
      margin: EdgeInsets.only(bottom: 12.h),
      child: ListTile(
        leading: Container(
          width: 48.w,
          height: 48.w,
          decoration: BoxDecoration(
            color: _getColorForEventType(event.type).withOpacity(0.1),
            borderRadius: BorderRadius.circular(8.r),
          ),
          child: Icon(
            _getIconForEventType(event.type),
            color: _getColorForEventType(event.type),
          ),
        ),

左边放一个图标容器,根据活动类型显示不同图标和颜色。

学校活动用蓝色学校图标,聚会用橙色庆祝图标。

背景用10%透明度的主色。

Card提供Material Design的卡片效果,margin设为底部12像素,和下一个卡片保持间距。ListTile是列表项的标准组件,leading放左边的图标,title放标题,subtitle放副标题,trailing放右边的内容,onTap处理点击事件。leading用Container包装Icon,给图标加个背景色。背景色用活动类型对应的颜色,透明度10%,图标本身用该颜色。这种设计让不同类型的活动一眼就能区分,视觉上很清晰。

        title: Text(
          event.title,
          style: TextStyle(
            fontSize: 16.sp,
            fontWeight: FontWeight.w500,
          ),
        ),
        subtitle: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (event.location != null)
              Text(
                event.location!,
                style: TextStyle(fontSize: 12.sp),
              ),
            Text(
              DateFormat('HH:mm').format(event.date),
              style: TextStyle(
                fontSize: 12.sp,
                color: Colors.grey,
              ),
            ),
          ],
        ),

标题显示活动名称,加粗突出。

副标题显示地点和时间,地点可能为空所以加了判断。

时间用DateFormat格式化成"HH:mm"的形式。

title显示活动标题,字号16像素,fontWeight设为w500,稍微加粗,让标题更突出。subtitle是个Column,垂直排列地点和时间。地点可能为null,用if判断,只有不为null时才显示。Text的style设置字号12像素,比标题小。时间用DateFormat格式化,'HH:mm’表示24小时制的时分格式,比如"14:30"。颜色用灰色,作为辅助信息。这种层次分明的设计让信息易于阅读,用户能快速抓住重点。

        trailing: event.isReminder
            ? const Icon(Icons.notifications_active, color: Color(0xFFE91E63))
            : null,
        onTap: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => EventDetailScreen(event: event)),
        ),
      ),
    );
  }

如果开启了提醒,右边显示一个铃铛图标。

点击卡片跳转到活动详情页。

trailing是ListTile右边的内容,这里根据isReminder字段决定是否显示铃铛图标。如果开启了提醒,显示notifications_active图标,颜色用粉色;如果没开启,trailing设为null,不显示任何内容。onTap处理点击事件,跳转到EventDetailScreen查看活动详情。详情页可以显示活动的完整信息,包括描述、参与人员、提醒设置等,用户还可以编辑或删除活动。

类型颜色和图标映射

根据活动类型返回对应的颜色:

  Color _getColorForEventType(String type) {
    switch (type) {
      case '学校':
        return Colors.blue;
      case '聚会':
        return Colors.orange;
      case '医疗':
        return Colors.red;
      case '户外':
        return Colors.green;
      case '探亲':
        return Colors.purple;
      default:
        return Colors.grey;
    }
  }

不同类型用不同颜色,一眼就能区分。

医疗用红色比较醒目,户外用绿色代表自然。

_getColorForEventType根据活动类型返回对应的颜色。switch语句匹配type字符串,返回相应的颜色。学校活动用蓝色,代表知识和学习;聚会用橙色,代表欢乐和热情;医疗用红色,代表紧急和重要;户外用绿色,代表自然和健康;探亲用紫色,代表亲情和温馨。default返回灰色,处理未知类型。这种颜色映射让活动类型一目了然,用户不需要读文字就能大致判断活动性质。

根据活动类型返回对应的图标:

  IconData _getIconForEventType(String type) {
    switch (type) {
      case '学校':
        return Icons.school;
      case '聚会':
        return Icons.celebration;
      case '医疗':
        return Icons.local_hospital;
      case '户外':
        return Icons.park;
      case '探亲':
        return Icons.home;
      default:
        return Icons.event;
    }
  }

图标选择也很直观,学校用学校图标,医疗用医院图标。

默认用通用的日历图标。

_getIconForEventType根据活动类型返回对应的图标。图标选择很直观,学校用school图标,聚会用celebration图标,医疗用local_hospital图标,户外用park图标,探亲用home图标。default返回event图标,这是个通用的日历图标。图标和颜色配合使用,让活动类型的识别度更高。用户看到蓝色学校图标,立即知道这是学校相关的活动;看到红色医院图标,知道这是医疗相关的活动。这种视觉设计能显著提升用户体验,减少认知负担。

小结

日历Tab的核心是TableCalendar组件,配合活动列表展示。

选中日期后显示当天的活动,有活动的日期会有标记点。

不同类型的活动用不同颜色和图标区分,视觉上很清晰。

悬浮按钮提供快速添加活动的入口,默认选中当前日期,减少操作步骤。AppBar提供纪念日和待办事项的快捷入口,方便用户管理不同类型的日程。空状态设计友好,让用户知道当前状态,不会产生困惑。整个页面的交互流畅,功能完整,是家庭相册App的重要组成部分。


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

月份切换动画

为月份切换添加平滑动画效果:

PageController _pageController = PageController();

Widget _buildCalendarWithAnimation() {
  return AnimatedSwitcher(
    duration: const Duration(milliseconds: 300),
    transitionBuilder: (Widget child, Animation<double> animation) {
      return FadeTransition(
        opacity: animation,
        child: SlideTransition(
          position: Tween<Offset>(
            begin: const Offset(0.1, 0),
            end: Offset.zero,
          ).animate(animation),
          child: child,
        ),
      );
    },
    child: TableCalendar(
      key: ValueKey(_focusedDay),
      firstDay: DateTime(2020),
      lastDay: DateTime(2030),
      focusedDay: _focusedDay,
      selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
      onDaySelected: _onDaySelected,
      onPageChanged: (focusedDay) {
        setState(() => _focusedDay = focusedDay);
      },
    ),
  );
}

AnimatedSwitcher组件在月份切换时提供淡入淡出和滑动效果。FadeTransition控制透明度变化,SlideTransition控制位置移动。Tween定义从右侧0.1的偏移量滑动到原位,创造出平滑的切换动画。

这种动画效果让月份切换更加流畅自然,提升了用户体验。300毫秒的动画时长既不会太快让用户感觉突兀,也不会太慢影响操作效率。ValueKey确保每次月份变化时都会触发动画。

活动统计卡片

显示当月活动统计信息:

Widget _buildMonthStatistics() {
  final monthEvents = _getMonthEvents(_focusedDay);
  final eventsByType = <String, int>{};
  
  for (var event in monthEvents) {
    eventsByType[event.type] = (eventsByType[event.type] ?? 0) + 1;
  }
  
  return Container(
    margin: EdgeInsets.all(16.w),
    padding: EdgeInsets.all(16.w),
    decoration: BoxDecoration(
      gradient: const LinearGradient(
        colors: [Color(0xFF2196F3), Color(0xFF64B5F6)],
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
      ),
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Text(
              '${_focusedDay.month}月统计',
              style: TextStyle(
                fontSize: 16.sp,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
            Text(
              '共${monthEvents.length}个活动',
              style: TextStyle(
                fontSize: 14.sp,
                color: Colors.white.withOpacity(0.9),
              ),
            ),
          ],
        ),
        SizedBox(height: 12.h),
        Wrap(
          spacing: 8.w,
          runSpacing: 8.h,
          children: eventsByType.entries.map((entry) {
            return Container(
              padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
              decoration: BoxDecoration(
                color: Colors.white.withOpacity(0.2),
                borderRadius: BorderRadius.circular(16.r),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(
                    _getIconForEventType(entry.key),
                    size: 14.sp,
                    color: Colors.white,
                  ),
                  SizedBox(width: 4.w),
                  Text(
                    '${entry.key} ${entry.value}',
                    style: TextStyle(fontSize: 12.sp, color: Colors.white),
                  ),
                ],
              ),
            );
          }).toList(),
        ),
      ],
    ),
  );
}

List<Event> _getMonthEvents(DateTime month) {
  return _events.where((event) {
    return event.date.year == month.year && event.date.month == month.month;
  }).toList();
}

统计卡片使用蓝色渐变背景,显示当月活动总数和各类型活动数量。使用Map统计每种类型的活动数量,然后用Wrap组件展示。每个类型标签显示图标和数量,使用半透明白色背景。

这个功能让用户能够快速了解当月的活动分布情况,比如学校活动有几个、聚会有几个等。统计信息帮助用户更好地规划时间,避免某类活动过于集中。

快速日期跳转

提供快速跳转到特定日期的功能:

Widget _buildQuickJumpButton() {
  return IconButton(
    icon: const Icon(Icons.today),
    onPressed: _showDatePicker,
    tooltip: '跳转到指定日期',
  );
}

Future<void> _showDatePicker() async {
  final picked = await showDatePicker(
    context: context,
    initialDate: _selectedDay ?? DateTime.now(),
    firstDate: DateTime(2020),
    lastDate: DateTime(2030),
    builder: (context, child) {
      return Theme(
        data: Theme.of(context).copyWith(
          colorScheme: const ColorScheme.light(
            primary: Color(0xFF2196F3),
            onPrimary: Colors.white,
            surface: Colors.white,
            onSurface: Colors.black,
          ),
        ),
        child: child!,
      );
    },
  );
  
  if (picked != null) {
    setState(() {
      _selectedDay = picked;
      _focusedDay = picked;
    });
  }
}

快速跳转按钮调用系统日期选择器,用户可以直接选择任意日期。选择后日历自动跳转到该日期并选中。Theme配置确保日期选择器使用应用的主题色。

这个功能在用户需要查看很久之前或很久之后的活动时特别有用,避免了一个月一个月地翻页。比如要查看半年后的活动安排,直接跳转比翻页快得多。

活动提醒设置

为活动设置提前提醒:

Widget _buildReminderSetting(Event event) {
  return ListTile(
    leading: Container(
      padding: EdgeInsets.all(8.w),
      decoration: BoxDecoration(
        color: Colors.orange.withOpacity(0.1),
        shape: BoxShape.circle,
      ),
      child: const Icon(Icons.notifications, color: Colors.orange),
    ),
    title: const Text('活动提醒'),
    subtitle: Text(
      event.reminderEnabled ? '提前${event.reminderMinutes}分钟提醒' : '未设置提醒',
    ),
    trailing: Switch(
      value: event.reminderEnabled,
      onChanged: (value) {
        if (value) {
          _showReminderDialog(event);
        } else {
          _disableReminder(event);
        }
      },
      activeColor: Colors.orange,
    ),
  );
}

void _showReminderDialog(Event event) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('设置提醒时间'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [15, 30, 60, 120].map((minutes) {
          return ListTile(
            title: Text('提前$minutes分钟'),
            onTap: () {
              _setReminder(event, minutes);
              Navigator.pop(context);
            },
          );
        }).toList(),
      ),
    ),
  );
}

void _setReminder(Event event, int minutes) {
  setState(() {
    event.reminderEnabled = true;
    event.reminderMinutes = minutes;
  });
  
  // 这里应该调用本地通知服务设置提醒
  Get.snackbar(
    '提醒已设置',
    '将在活动开始前$minutes分钟提醒您',
    snackPosition: SnackPosition.BOTTOM,
  );
}

提醒设置功能让用户可以为每个活动单独设置提前提醒时间。开关控制是否启用提醒,点击开关弹出时间选择对话框。提供15分钟、30分钟、1小时、2小时四个选项。

这个功能确保用户不会错过重要活动。比如医疗预约需要提前出发,可以设置提前30分钟提醒;学校活动可能需要准备,可以设置提前1小时提醒。

活动搜索功能

支持搜索历史活动:

Widget _buildSearchButton() {
  return IconButton(
    icon: const Icon(Icons.search),
    onPressed: _showSearchDialog,
  );
}

void _showSearchDialog() {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('搜索活动'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            decoration: const InputDecoration(
              hintText: '输入活动标题或描述',
              prefixIcon: Icon(Icons.search),
              border: OutlineInputBorder(),
            ),
            onChanged: (value) {
              _searchEvents(value);
            },
          ),
          SizedBox(height: 16.h),
          SizedBox(
            height: 200.h,
            child: _buildSearchResults(),
          ),
        ],
      ),
    ),
  );
}

List<Event> _searchResults = [];

void _searchEvents(String query) {
  if (query.isEmpty) {
    setState(() => _searchResults = []);
    return;
  }
  
  setState(() {
    _searchResults = _events.where((event) {
      return event.title.toLowerCase().contains(query.toLowerCase()) ||
          (event.description?.toLowerCase().contains(query.toLowerCase()) ?? false);
    }).toList();
  });
}

Widget _buildSearchResults() {
  if (_searchResults.isEmpty) {
    return const Center(child: Text('暂无搜索结果'));
  }
  
  return ListView.builder(
    itemCount: _searchResults.length,
    itemBuilder: (context, index) {
      final event = _searchResults[index];
      return ListTile(
        leading: Icon(
          _getIconForEventType(event.type),
          color: _getColorForEventType(event.type),
        ),
        title: Text(event.title),
        subtitle: Text(DateFormat('yyyy-MM-dd').format(event.date)),
        onTap: () {
          Navigator.pop(context);
          setState(() {
            _selectedDay = event.date;
            _focusedDay = event.date;
          });
        },
      );
    },
  );
}

搜索功能支持按标题或描述搜索活动。输入框实时搜索,结果列表显示匹配的活动。点击搜索结果自动跳转到对应日期。

这个功能在活动很多时特别有用。比如用户记得几个月前有个重要聚会但忘了具体日期,可以搜索"聚会"快速找到。搜索不区分大小写,提高了匹配成功率。

活动导出功能

支持导出活动到日历文件:

Widget _buildExportButton() {
  return IconButton(
    icon: const Icon(Icons.file_download),
    onPressed: _exportEvents,
  );
}

Future<void> _exportEvents() async {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (context) => const Center(child: CircularProgressIndicator()),
  );
  
  try {
    final buffer = StringBuffer();
    buffer.writeln('BEGIN:VCALENDAR');
    buffer.writeln('VERSION:2.0');
    buffer.writeln('PRODID:-//家庭相册//日历//CN');
    
    for (var event in _events) {
      buffer.writeln('BEGIN:VEVENT');
      buffer.writeln('UID:${event.id}@familyalbum.com');
      buffer.writeln('DTSTAMP:${_formatDateTime(DateTime.now())}');
      buffer.writeln('DTSTART:${_formatDateTime(event.date)}');
      buffer.writeln('SUMMARY:${event.title}');
      if (event.description != null) {
        buffer.writeln('DESCRIPTION:${event.description}');
      }
      buffer.writeln('END:VEVENT');
    }
    
    buffer.writeln('END:VCALENDAR');
    
    final directory = await getApplicationDocumentsDirectory();
    final file = File('${directory.path}/events.ics');
    await file.writeAsString(buffer.toString());
    
    Navigator.pop(context);
    
    Get.snackbar(
      '导出成功',
      '活动已导出到: ${file.path}',
      snackPosition: SnackPosition.BOTTOM,
      duration: const Duration(seconds: 3),
    );
    
    // 可以调用系统分享功能分享文件
    Share.shareFiles([file.path], text: '我的家庭活动日历');
  } catch (e) {
    Navigator.pop(context);
    Get.snackbar('导出失败', e.toString(), snackPosition: SnackPosition.BOTTOM);
  }
}

String _formatDateTime(DateTime dt) {
  return '${dt.year}${dt.month.toString().padLeft(2, '0')}${dt.day.toString().padLeft(2, '0')}T${dt.hour.toString().padLeft(2, '0')}${dt.minute.toString().padLeft(2, '0')}00';
}

导出功能将所有活动导出为标准的iCalendar格式(.ics文件)。这种格式可以被大多数日历应用识别和导入。导出后可以通过系统分享功能发送给家人或导入到其他设备。

iCalendar格式包含活动的标题、日期、描述等信息。VCALENDAR是日历容器,VEVENT是单个活动。UID是活动的唯一标识符,DTSTAMP是时间戳,DTSTART是开始时间,SUMMARY是标题,DESCRIPTION是描述。

周视图切换

提供周视图和月视图的切换:

CalendarFormat _calendarFormat = CalendarFormat.month;

Widget _buildFormatToggle() {
  return IconButton(
    icon: Icon(_calendarFormat == CalendarFormat.month ? Icons.view_week : Icons.view_module),
    onPressed: () {
      setState(() {
        _calendarFormat = _calendarFormat == CalendarFormat.month
            ? CalendarFormat.week
            : CalendarFormat.month;
      });
    },
    tooltip: _calendarFormat == CalendarFormat.month ? '切换到周视图' : '切换到月视图',
  );
}

周视图只显示一周的日期,占用空间更少,适合查看近期活动。月视图显示整月日期,适合查看全月安排。用户可以根据需要自由切换。

在TableCalendar组件中设置calendarFormat属性即可切换视图模式。图标也会相应变化,月视图用view_module图标,周视图用view_week图标,让用户清楚当前的视图模式。

总结

日历Tab通过TableCalendar组件实现了完整的日历功能,支持日期选择、活动展示、月份切换等基础功能。扩展功能包括活动统计、快速跳转、提醒设置、搜索导出等,大大提升了实用性。

月份切换动画让交互更流畅,统计卡片让用户了解活动分布,快速跳转节省翻页时间,提醒设置避免错过重要活动,搜索功能快速定位历史活动,导出功能支持数据迁移和分享,周视图切换满足不同查看需求。

整个页面的设计既美观又实用,是家庭相册App中管理家庭活动的重要工具。通过日历视图,用户可以清晰地看到家庭的活动安排,合理规划时间,记录美好时光。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐