鸿蒙版天气预报应用引入Flutter三方库table_calendar实现个性化天气日历

📚 目录

  • [1. 概述](#1. 概述)
  • [2. 引入三方库](#2. 引入三方库)
  • [3. 目录结构](#3. 目录结构)
  • [4. 从零开始实现](#4. 从零开始实现)
  • [5. 核心代码详解](#5. 核心代码详解)
  • [6. 常见错误与解决方案](#6. 常见错误与解决方案)
  • [7. 进阶功能](#7. 进阶功能)
  • [8. 总结](#8. 总结)

1. 概述

1.1 什么是 Table Calendar?

table_calendar 是一个功能强大的 Flutter 日历组件库,提供了:

  • ✅ 美观的日历界面(Material Design 风格)
  • ✅ 日期选择功能
  • ✅ 自定义标记和样式
  • ✅ 多格式支持(月视图、周视图、2周视图)
  • ✅ 事件标记和自定义构建器

1.2 在本项目中的应用

我们使用 table_calendar 实现一个个性化天气日历,功能包括:

  • 📅 查看历史天气:显示近30天的历史天气数据
  • 🔮 查看未来预报:显示未来15天的天气预报
  • 🎯 智能标注:自动标注降水💧、高温🔥、预警🚨等特殊天气
  • 🔍 详情查看:点击日期查看详细天气信息
  • 🌍 多城市支持:自动读取当前城市,支持切换城市

1.3 功能流程图

📱 用户打开天气日历
📍 加载当前城市
📡 加载天气数据
🔮 获取未来15天预报
📜 获取历史30天数据
⚠️ 获取天气预警
💾 存储到缓存
🏷️ 生成日历标注
📅 显示日历和标记
👆 用户选择日期
📊 显示天气详情
显示温度/天气/标注/预警


2. 引入三方库

2.1 添加依赖

pubspec.yaml 文件的 dependencies 部分添加:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  
  # 日历组件
  table_calendar: ^3.1.2
  
  # # 用于存储城市信息,需要引入鸿蒙化的依赖
  shared_preferences: 
    git:
      url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
      path: "packages/shared_preferences/shared_preferences"

2.2 安装依赖

在项目根目录运行:

bash 复制代码
flutter pub get

2.3 依赖说明

依赖包 版本 用途
table_calendar ^3.1.2 日历组件核心库,提供日历UI和交互功能
shared_preferences ^2.2.2 用于读取当前城市信息

3. 目录结构

3.1 项目结构

复制代码
lib/
├── screens/
│   └── weather_calendar_page.dart    # 天气日历页面(主要文件)
├── models/
│   ├── weather_models.dart           # 天气数据模型
│   └── weather_alert_model.dart      # 预警数据模型
├── services/
│   ├── weather_service.dart          # 天气服务(API调用)
│   └── weather_alert_service.dart    # 预警服务
└── main.dart                         # 应用入口

3.2 文件说明

  • weather_calendar_page.dart:天气日历页面的主要实现文件

    • 包含日历组件
    • 天气数据加载和显示
    • 日历标注功能
    • 预警详情显示
  • weather_models.dart:天气数据模型

    • Daily:每日天气预报数据
    • DailyForecastResponse:天气预报响应
  • weather_alert_model.dart:预警数据模型

    • WeatherAlert:天气预警数据
    • MarkerType:标注类型枚举
    • CalendarMarker:日历标注数据
  • weather_service.dart:天气服务

    • getDailyForecast():获取天气预报
    • 支持 3d、7d、10d、15d、30d 预报
  • weather_alert_service.dart:预警服务

    • getWeatherAlerts():获取天气预警列表

4. 从零开始实现

4.1 步骤1:创建页面文件

创建 lib/screens/weather_calendar_page.dart 文件,先搭建基础结构:

dart 复制代码
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/weather_service.dart';
import '../models/weather_models.dart';
import '../models/weather_alert_model.dart';
import '../services/weather_alert_service.dart';

/// 天气日历页面
class WeatherCalendarPage extends StatefulWidget {
  const WeatherCalendarPage({super.key});

  @override
  State<WeatherCalendarPage> createState() => _WeatherCalendarPageState();
}

class _WeatherCalendarPageState extends State<WeatherCalendarPage> {
  // 服务实例
  final WeatherService _weatherService = WeatherService();
  final WeatherAlertService _alertService = WeatherAlertService();
  
  // 日历状态
  late ValueNotifier<List<DateTime>> _selectedDays;
  DateTime _focusedDay = DateTime.now();
  DateTime _selectedDay = DateTime.now();
  CalendarFormat _calendarFormat = CalendarFormat.month;
  
  // 当前城市
  String _currentCityId = '101010100';
  String _currentCityName = '北京';
  
  // 数据缓存
  final Map<String, DailyForecastResponse> _forecastCache = {};
  final Map<String, Map<String, dynamic>> _historicalCache = {};
  final Map<String, List<WeatherAlert>> _alertCache = {};
  
  // 标注数据
  final Map<DateTime, List<CalendarMarker>> _markers = {};
  
  // 加载状态
  bool _isLoading = false;

  @override
  void initState() {
    super.initState();
    _selectedDays = ValueNotifier([]);
    _weatherService.init();
    _loadCurrentCity();
    _loadWeatherData();
  }

  @override
  void dispose() {
    _selectedDays.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('📅 天气日历 - $_currentCityName'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: _loadWeatherData,
            tooltip: '刷新天气数据',
          ),
        ],
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : RefreshIndicator(
              onRefresh: _loadWeatherData,
              child: Column(
                children: [
                  _buildCalendar(),
                  const Divider(),
                  Expanded(child: _buildWeatherDetail()),
                ],
              ),
            ),
    );
  }

  // 后续步骤会逐步实现这些方法
  Widget _buildCalendar() {
    return const SizedBox.shrink();
  }

  Widget _buildWeatherDetail() {
    return const SizedBox.shrink();
  }
}

💡 新手提示:

  • StatefulWidget 用于需要状态管理的页面
  • initState() 在页面创建时调用,适合初始化数据
  • dispose() 在页面销毁时调用,用于释放资源

4.2 步骤2:实现城市加载

添加加载当前城市的方法:

dart 复制代码
/// 加载当前城市
Future<void> _loadCurrentCity() async {
  try {
    final prefs = await SharedPreferences.getInstance();
    final cityId = prefs.getString('current_location_id');
    final cityName = prefs.getString('current_city_name');
    
    if (cityId != null && cityName != null) {
      setState(() {
        _currentCityId = cityId;
        _currentCityName = cityName;
      });
    }
  } catch (e) {
    debugPrint('加载当前城市失败: $e');
  }
}

💡 新手提示:

  • SharedPreferences 用于存储简单的键值对数据
  • setState() 用于更新UI,必须调用才能看到变化
  • debugPrint() 用于调试输出,不会影响生产环境

4.3 步骤3:实现数据加载

添加天气数据加载方法:

dart 复制代码
/// 加载天气数据(主方法)
Future<void> _loadWeatherData() async {
  setState(() {
    _isLoading = true;
  });
  
  try {
    // 并行加载三种数据
    await Future.wait([
      _loadForecast(),           // 未来15天预报
      _loadHistoricalWeather(),  // 历史30天数据
      _loadWeatherAlerts(),      // 天气预警
    ]);
    
    // 生成标注
    _generateMarkers();
  } catch (e) {
    debugPrint('加载天气数据失败: $e');
  } finally {
    setState(() {
      _isLoading = false;
    });
  }
}

/// 加载未来15天预报
Future<void> _loadForecast() async {
  try {
    final forecast = await _weatherService.getDailyForecast(
      _currentCityId,
      days: '15d',
    );
    
    setState(() {
      _forecastCache[_currentCityId] = forecast;
    });
  } catch (e) {
    debugPrint('加载预报失败: $e');
  }
}

/// 加载近30天历史天气
Future<void> _loadHistoricalWeather() async {
  try {
    // 注意:和风天气API可能需要付费版本才能获取历史天气
    // 这里使用模拟数据作为示例
    final now = DateTime.now();
    final historicalData = <String, Map<String, dynamic>>{};
    
    for (int i = 1; i <= 30; i++) {
      final date = now.subtract(Duration(days: i));
      final dateKey = _formatDate(date);
      
      // 模拟历史天气数据
      historicalData[dateKey] = {
        'tempMax': 20 + (i % 10),
        'tempMin': 10 + (i % 8),
        'textDay': ['晴', '多云', '阴', '小雨'][i % 4],
        'precip': i % 5 == 0 ? 5.0 + (i % 3) : 0.0,
      };
    }
    
    setState(() {
      _historicalCache[_currentCityId] = historicalData;
    });
  } catch (e) {
    debugPrint('加载历史天气失败: $e');
  }
}

/// 加载天气预警
Future<void> _loadWeatherAlerts() async {
  try {
    final alerts = await _alertService.getWeatherAlerts(_currentCityId);
    setState(() {
      _alertCache[_currentCityId] = alerts;
    });
    debugPrint('加载天气预警成功: ${alerts.length} 条');
  } catch (e) {
    debugPrint('加载天气预警失败: $e');
  }
}

/// 格式化日期为字符串(YYYY-MM-DD)
String _formatDate(DateTime date) {
  return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}

💡 新手提示:

  • Future.wait() 可以并行执行多个异步操作,提高效率
  • 使用 try-catch 捕获错误,避免应用崩溃
  • finally 块确保无论成功失败都会执行(如关闭加载状态)

4.4 步骤4:实现日历组件

实现 _buildCalendar() 方法:

dart 复制代码
/// 构建日历组件
Widget _buildCalendar() {
  return TableCalendar(
    // 设置日历显示范围(前后各一年)
    firstDay: DateTime.now().subtract(const Duration(days: 365)),
    lastDay: DateTime.now().add(const Duration(days: 365)),
    
    // 当前聚焦的日期
    focusedDay: _focusedDay,
    
    // 判断日期是否被选中
    selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
    
    // 日历格式(月/周/2周)
    calendarFormat: _calendarFormat,
    
    // 格式切换回调
    onFormatChanged: (format) {
      setState(() {
        _calendarFormat = format;
      });
    },
    
    // 日期选择回调
    onDaySelected: (selectedDay, focusedDay) {
      setState(() {
        _selectedDay = selectedDay;
        _focusedDay = focusedDay;
      });
    },
    
    // 页面切换回调
    onPageChanged: (focusedDay) {
      setState(() {
        _focusedDay = focusedDay;
      });
    },
    
    // 自定义标记构建器
    calendarBuilders: CalendarBuilders(
      markerBuilder: (context, date, events) {
        final markers = _markers[date];
        if (markers == null || markers.isEmpty) {
          return null; // 没有标记时返回 null
        }
        
        // 显示标记(小圆点)
        return Positioned(
          bottom: 1,
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: markers.map((marker) {
              // 根据标记类型选择颜色
              Color markerColor;
              switch (marker.type) {
                case MarkerType.precipitation:
                  markerColor = Colors.blue;  // 蓝色:降水
                  break;
                case MarkerType.highTemperature:
                  markerColor = Colors.red;   // 红色:高温
                  break;
                case MarkerType.alert:
                  markerColor = Colors.orange; // 橙色:预警
                  break;
              }
              
              return Container(
                margin: const EdgeInsets.symmetric(horizontal: 1),
                width: 6,
                height: 6,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: markerColor,
                ),
              );
            }).toList(),
          ),
        );
      },
    ),
    
    // 日历样式
    calendarStyle: CalendarStyle(
      todayDecoration: BoxDecoration(
        color: Colors.blue.shade100,
        shape: BoxShape.circle,
      ),
      selectedDecoration: BoxDecoration(
        color: Colors.blue,
        shape: BoxShape.circle,
      ),
      markerDecoration: const BoxDecoration(
        color: Colors.transparent,
      ),
    ),
    
    // 头部样式
    headerStyle: HeaderStyle(
      formatButtonVisible: true,  // 显示格式切换按钮
      titleCentered: true,         // 标题居中
      formatButtonShowsNext: false,
      formatButtonDecoration: BoxDecoration(
        color: Colors.blue.shade100,
        borderRadius: BorderRadius.circular(8),
      ),
      formatButtonTextStyle: const TextStyle(
        color: Colors.blue,
        fontWeight: FontWeight.bold,
      ),
    ),
  );
}

💡 新手提示:

  • isSameDay()table_calendar 提供的工具函数,用于比较两个日期是否为同一天
  • Positioned 用于在日历单元格中定位标记
  • markerBuilder 返回 null 表示该日期没有标记

4.5 步骤5:实现标注生成

添加 _generateMarkers() 方法:

dart 复制代码
/// 生成日历标注
void _generateMarkers() {
  _markers.clear(); // 清空现有标注
  
  // 1. 处理预报数据
  final forecast = _forecastCache[_currentCityId];
  if (forecast?.daily != null) {
    for (final day in forecast!.daily!) {
      if (day.fxDate == null) continue;
      
      try {
        final date = DateTime.parse(day.fxDate!);
        final markers = <CalendarMarker>[];
        
        // 检查降水
        if (day.precip != null && day.precip!.isNotEmpty) {
          final precipValue = double.tryParse(day.precip!);
          if (precipValue != null && precipValue > 0) {
            markers.add(CalendarMarker(
              type: MarkerType.precipitation,
              date: date,
            ));
          }
        }
        
        // 检查高温(温度 > 35度)
        if (day.tempMax != null && day.tempMax!.isNotEmpty) {
          final tempMaxValue = int.tryParse(day.tempMax!);
          if (tempMaxValue != null && tempMaxValue > 35) {
            markers.add(CalendarMarker(
              type: MarkerType.highTemperature,
              date: date,
            ));
          }
        }
        
        if (markers.isNotEmpty) {
          _markers[date] = markers;
        }
      } catch (e) {
        debugPrint('解析日期失败: ${day.fxDate}, error: $e');
      }
    }
  }
  
  // 2. 处理历史数据(逻辑类似)
  final historical = _historicalCache[_currentCityId];
  if (historical != null) {
    historical.forEach((dateKey, data) {
      try {
        final date = DateTime.parse(dateKey);
        final markers = <CalendarMarker>[];
        
        if (data['precip'] != null && data['precip'] > 0) {
          markers.add(CalendarMarker(
            type: MarkerType.precipitation,
            date: date,
          ));
        }
        
        if (data['tempMax'] != null && data['tempMax'] > 35) {
          markers.add(CalendarMarker(
            type: MarkerType.highTemperature,
            date: date,
          ));
        }
        
        if (markers.isNotEmpty) {
          _markers[date] = markers;
        }
      } catch (e) {
        debugPrint('解析历史日期失败: $dateKey, error: $e');
      }
    });
  }
  
  // 3. 处理预警数据
  final alerts = _alertCache[_currentCityId] ?? [];
  for (final alert in alerts) {
    // 预警可能跨多天,需要标记预警期间的所有日期
    final startDate = DateTime(
      alert.startTime.year,
      alert.startTime.month,
      alert.startTime.day,
    );
    final endDate = DateTime(
      alert.endTime.year,
      alert.endTime.month,
      alert.endTime.day,
    );
    
    var currentDate = startDate;
    while (!currentDate.isAfter(endDate)) {
      if (!_markers.containsKey(currentDate)) {
        _markers[currentDate] = [];
      }
      
      // 检查是否已有预警标记
      final hasAlert = _markers[currentDate]!.any(
        (m) => m.type == MarkerType.alert,
      );
      
      if (!hasAlert) {
        _markers[currentDate]!.add(CalendarMarker(
          type: MarkerType.alert,
          date: currentDate,
          alert: alert, // 保存预警信息
        ));
      }
      
      currentDate = currentDate.add(const Duration(days: 1));
    }
  }
  
  setState(() {}); // 更新UI
}

💡 新手提示:

  • 标注生成逻辑分为三部分:预报数据、历史数据、预警数据
  • 预警可能跨多天,需要用循环标记所有日期
  • 最后调用 setState() 更新UI显示

4.6 步骤6:实现天气详情显示

实现 _buildWeatherDetail() 方法:

dart 复制代码
/// 获取指定日期的天气数据
Map<String, dynamic>? _getWeatherForDate(DateTime date) {
  final dateKey = _formatDate(date);
  final now = DateTime.now();
  
  // 未来日期或今天:从预报中获取
  if (date.isAfter(now) || isSameDay(date, now)) {
    final forecast = _forecastCache[_currentCityId];
    if (forecast?.daily != null) {
      for (final day in forecast!.daily!) {
        if (day.fxDate == dateKey) {
          return {
            'tempMax': day.tempMax ?? '--',
            'tempMin': day.tempMin ?? '--',
            'textDay': day.textDay ?? '未知',
            'precip': day.precip ?? '0',
          };
        }
      }
    }
  } else {
    // 历史日期:从历史数据中获取
    final historical = _historicalCache[_currentCityId];
    if (historical != null && historical.containsKey(dateKey)) {
      return historical[dateKey];
    }
  }
  
  return null;
}

/// 构建天气详情
Widget _buildWeatherDetail() {
  final weatherData = _getWeatherForDate(_selectedDay);
  
  if (weatherData == null) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.info_outline, size: 48, color: Colors.grey),
          const SizedBox(height: 16),
          Text(
            '暂无 ${_formatDate(_selectedDay)} 的天气数据',
            style: TextStyle(color: Colors.grey.shade600),
          ),
        ],
      ),
    );
  }
  
  final markers = _markers[_selectedDay] ?? [];
  final isFuture = _selectedDay.isAfter(DateTime.now());
  
  return SingleChildScrollView(
    padding: const EdgeInsets.all(16),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 日期标题
        Text(
          _formatDate(_selectedDay),
          style: const TextStyle(
            fontSize: 24,
            fontWeight: FontWeight.bold,
          ),
        ),
        const SizedBox(height: 8),
        Text(
          isFuture ? '📅 预报' : '📜 历史',
          style: TextStyle(
            fontSize: 14,
            color: Colors.grey.shade600,
          ),
        ),
        const SizedBox(height: 24),
        
        // 天气卡片
        Card(
          elevation: 2,
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                // 天气状况
                Row(
                  children: [
                    Text(
                      weatherData['textDay'] ?? '未知',
                      style: const TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(width: 16),
                    _getWeatherIcon(weatherData['textDay'] ?? ''),
                  ],
                ),
                const SizedBox(height: 16),
                
                // 温度
                Row(
                  children: [
                    Text(
                      '${weatherData['tempMax'] ?? '--'}°',
                      style: const TextStyle(
                        fontSize: 32,
                        fontWeight: FontWeight.bold,
                        color: Colors.orange,
                      ),
                    ),
                    const SizedBox(width: 8),
                    Text(
                      '/ ${weatherData['tempMin'] ?? '--'}°',
                      style: TextStyle(
                        fontSize: 24,
                        color: Colors.grey.shade600,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 16),
                
                // 标注信息(Chip标签)
                if (markers.isNotEmpty) ...[
                  const Divider(),
                  const SizedBox(height: 8),
                  Wrap(
                    spacing: 8,
                    runSpacing: 8,
                    children: markers.map((marker) {
                      IconData icon;
                      String label;
                      Color color;
                      Color backgroundColor;
                      
                      switch (marker.type) {
                        case MarkerType.precipitation:
                          icon = Icons.water_drop;
                          label = '🌧️ 有降水';
                          color = Colors.blue;
                          backgroundColor = Colors.blue.shade50;
                          break;
                        case MarkerType.highTemperature:
                          icon = Icons.wb_sunny;
                          label = '🌡️ 高温';
                          color = Colors.red;
                          backgroundColor = Colors.red.shade50;
                          break;
                        case MarkerType.alert:
                          icon = Icons.warning;
                          label = '⚠️ 天气预警';
                          color = Colors.orange;
                          backgroundColor = Colors.orange.shade50;
                          break;
                      }
                      
                      return Chip(
                        avatar: Icon(icon, size: 18, color: color),
                        label: Text(label, style: const TextStyle(fontSize: 12)),
                        backgroundColor: backgroundColor,
                      );
                    }).toList(),
                  ),
                ],
                
                // 降水量
                if (weatherData['precip'] != null) ...[
                  Builder(
                    builder: (context) {
                      final precipValue = double.tryParse(weatherData['precip'].toString());
                      if (precipValue != null && precipValue > 0) {
                        return Column(
                          children: [
                            const SizedBox(height: 16),
                            Row(
                              children: [
                                const Icon(Icons.water_drop, color: Colors.blue),
                                const SizedBox(width: 8),
                                Text(
                                  '降水量: ${precipValue.toStringAsFixed(1)}mm',
                                  style: const TextStyle(fontSize: 14),
                                ),
                              ],
                            ),
                          ],
                        );
                      }
                      return const SizedBox.shrink();
                    },
                  ),
                ],
                
                // 预警信息(详细)
                if (markers.any((m) => m.type == MarkerType.alert)) ...[
                  Builder(
                    builder: (context) {
                      // 获取当天的预警
                      final alerts = _alertCache[_currentCityId] ?? [];
                      final dayAlerts = alerts.where((alert) {
                        final alertStart = DateTime(
                          alert.startTime.year,
                          alert.startTime.month,
                          alert.startTime.day,
                        );
                        final alertEnd = DateTime(
                          alert.endTime.year,
                          alert.endTime.month,
                          alert.endTime.day,
                        );
                        final selected = DateTime(
                          _selectedDay.year,
                          _selectedDay.month,
                          _selectedDay.day,
                        );
                        return !selected.isBefore(alertStart) && 
                               !selected.isAfter(alertEnd);
                      }).toList();
                      
                      if (dayAlerts.isEmpty) {
                        return const SizedBox.shrink();
                      }
                      
                      return Column(
                        children: [
                          const SizedBox(height: 16),
                          const Divider(),
                          const SizedBox(height: 8),
                          const Text(
                            '⚠️ 天气预警',
                            style: TextStyle(
                              fontSize: 16,
                              fontWeight: FontWeight.bold,
                              color: Colors.orange,
                            ),
                          ),
                          const SizedBox(height: 8),
                          ...dayAlerts.map((alert) {
                            return Container(
                              margin: const EdgeInsets.only(bottom: 8),
                              padding: const EdgeInsets.all(12),
                              decoration: BoxDecoration(
                                color: alert.isUrgent
                                    ? Colors.red.shade50
                                    : Colors.orange.shade50,
                                borderRadius: BorderRadius.circular(8),
                                border: Border.all(
                                  color: alert.isUrgent
                                      ? Colors.red.shade300
                                      : Colors.orange.shade300,
                                  width: 1,
                                ),
                              ),
                              child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Row(
                                    children: [
                                      Text(alert.icon, style: const TextStyle(fontSize: 20)),
                                      const SizedBox(width: 8),
                                      Expanded(
                                        child: Text(
                                          alert.title,
                                          style: const TextStyle(
                                            fontSize: 14,
                                            fontWeight: FontWeight.bold,
                                          ),
                                        ),
                                      ),
                                      Container(
                                        padding: const EdgeInsets.symmetric(
                                          horizontal: 8,
                                          vertical: 4,
                                        ),
                                        decoration: BoxDecoration(
                                          color: alert.isUrgent ? Colors.red : Colors.orange,
                                          borderRadius: BorderRadius.circular(4),
                                        ),
                                        child: Text(
                                          alert.levelColor,
                                          style: const TextStyle(
                                            color: Colors.white,
                                            fontSize: 12,
                                            fontWeight: FontWeight.bold,
                                          ),
                                        ),
                                      ),
                                    ],
                                  ),
                                  const SizedBox(height: 8),
                                  Text(alert.content, style: const TextStyle(fontSize: 12)),
                                  const SizedBox(height: 4),
                                  Text(
                                    '${_formatDate(alert.startTime)} 至 ${_formatDate(alert.endTime)}',
                                    style: TextStyle(
                                      fontSize: 11,
                                      color: Colors.grey.shade600,
                                    ),
                                  ),
                                ],
                              ),
                            );
                          }).toList(),
                        ],
                      );
                    },
                  ),
                ],
              ],
            ),
          ),
        ),
      ],
    ),
  );
}

/// 获取天气图标(Emoji)
Widget _getWeatherIcon(String text) {
  final iconMap = {
    '晴': '☀️',
    '多云': '⛅',
    '阴': '☁️',
    '小雨': '🌦️',
    '中雨': '🌧️',
    '大雨': '⛈️',
    '雪': '❄️',
  };
  
  return Text(
    iconMap[text] ?? '🌤️',
    style: const TextStyle(fontSize: 32),
  );
}

💡 新手提示:

  • SingleChildScrollView 用于可滚动内容
  • Builder 用于在条件渲染中声明变量
  • ... 展开运算符用于将列表展开为多个子元素

4.7 步骤7:添加城市切换监听

添加 didChangeDependencies() 方法监听城市变化:

dart 复制代码
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  // 当页面重新显示时,检查城市是否有变化
  _checkCityChange();
}

/// 检查城市变化
Future<void> _checkCityChange() async {
  try {
    final prefs = await SharedPreferences.getInstance();
    final cityId = prefs.getString('current_location_id');
    final cityName = prefs.getString('current_city_name');
    
    if (cityId != null && 
        cityName != null && 
        (cityId != _currentCityId || cityName != _currentCityName)) {
      // 城市发生变化,重新加载数据
      await _loadCurrentCity();
      await _loadWeatherData();
    }
  } catch (e) {
    debugPrint('检查城市变化失败: $e');
  }
}

💡 新手提示:

  • didChangeDependencies() 在依赖变化时调用,适合监听外部数据变化
  • 比较城市ID和名称,避免重复加载

4.8 步骤8:添加标注类型定义

在文件末尾添加标注类型定义:

dart 复制代码
/// 日历标注类型
enum MarkerType {
  precipitation,    // 降水 💧
  highTemperature, // 高温 🔥
  alert,           // 预警 🚨
}

/// 日历标注数据
class CalendarMarker {
  final MarkerType type;
  final DateTime date;
  final WeatherAlert? alert; // 预警信息(仅当type为alert时有效)
  
  CalendarMarker({
    required this.type,
    required this.date,
    this.alert,
  });
}

4.9 步骤9:集成到导航栏

lib/screens/main_navigation_page.dart 中:

dart 复制代码
import 'weather_calendar_page.dart';

// 在 _buildScreens() 中添加
List<Widget> _buildScreens() {
  return [
    const HomePage(),
    const CityManagePage(),
    const WeatherDetailPage(),
    const WeatherCalendarPage(), // 新增
    const ProfilePage(),
  ];
}

// 在 _navBarsItems() 中添加
PersistentBottomNavBarItem(
  icon: const Icon(Icons.calendar_today),
  title: "天气日历",
  activeColorPrimary: Colors.teal,
  inactiveColorPrimary: Colors.grey,
  iconSize: 26,
),

5. 核心代码详解

5.1 数据加载流程图

💾 缓存 ⚠️ 预警服务 🌐 天气服务 📱 页面 👤 用户 💾 缓存 ⚠️ 预警服务 🌐 天气服务 📱 页面 👤 用户 打开天气日历 initState() _loadCurrentCity() getDailyForecast(15d) 返回预报数据 存储到_forecastCache _loadHistoricalWeather() 存储到_historicalCache getWeatherAlerts() 返回预警数据 存储到_alertCache _generateMarkers() 更新_markers 显示日历和标注 选择日期 _getWeatherForDate() 查询缓存数据 返回天气数据 显示天气详情

5.2 标注生成逻辑







开始生成标注
清空现有标注
处理预报数据
检查降水
添加蓝色标记
检查高温
添加红色标记
跳过
处理历史数据
检查降水/高温
添加标记
跳过
处理预警数据
遍历预警列表
计算预警日期范围
标记所有日期
添加橙色标记
更新UI

5.3 关键代码说明

5.3.1 日历组件配置
dart 复制代码
TableCalendar(
  firstDay: DateTime.now().subtract(const Duration(days: 365)),
  lastDay: DateTime.now().add(const Duration(days: 365)),
  // ... 其他配置
)

说明:

  • firstDaylastDay 定义了日历的可显示范围
  • 设置为前后各一年,足够查看历史天气和未来预报
5.3.2 标记颜色映射
dart 复制代码
switch (marker.type) {
  case MarkerType.precipitation:
    markerColor = Colors.blue;   // 💧 蓝色:降水
    break;
  case MarkerType.highTemperature:
    markerColor = Colors.red;    // 🔥 红色:高温
    break;
  case MarkerType.alert:
    markerColor = Colors.orange; // 🚨 橙色:预警
    break;
}

说明:

  • 使用不同颜色区分不同类型的标记
  • 颜色选择符合用户直觉(蓝色=水,红色=热,橙色=警告)
5.3.3 预警跨天处理
dart 复制代码
var currentDate = startDate;
while (!currentDate.isAfter(endDate)) {
  // 标记每一天
  _markers[currentDate]!.add(CalendarMarker(...));
  currentDate = currentDate.add(const Duration(days: 1));
}

说明:

  • 预警可能持续多天,需要标记预警期间的所有日期
  • 使用循环逐日标记

6. 常见错误与解决方案

6.1 错误:TableCalendar 显示异常

错误信息:

复制代码
RenderFlex overflowed by XX pixels

问题分析:

日历组件高度超出容器范围,导致布局溢出。

解决方案:

dart 复制代码
// ✅ 正确:使用 Column + Expanded
Column(
  children: [
    _buildCalendar(),  // 日历组件
    const Divider(),
    Expanded(          // 使用 Expanded 包裹详情区域
      child: _buildWeatherDetail(),
    ),
  ],
)

// ❌ 错误:直接使用 Column
Column(
  children: [
    _buildCalendar(),
    _buildWeatherDetail(), // 会导致溢出
  ],
)

其他注意事项:

  • 确保 firstDaylastDay 设置合理(不要跨度太大)
  • 检查日历的 headerStylecalendarStyle 是否设置了过大的 padding

6.2 错误:日期选择无响应

可能原因:

  • onDaySelected 回调未正确设置
  • selectedDayPredicate 逻辑错误
  • 忘记调用 setState()

解决方案:

dart 复制代码
// ✅ 正确:完整的选择逻辑
onDaySelected: (selectedDay, focusedDay) {
  setState(() {  // 必须调用 setState
    _selectedDay = selectedDay;
    _focusedDay = focusedDay;
  });
},

selectedDayPredicate: (day) {
  return isSameDay(_selectedDay, day);  // 使用 isSameDay 比较
},

6.3 错误:标记不显示

可能原因:

  • markerBuilder 返回 null
  • 标记数据未正确生成
  • 忘记调用 setState()

解决方案:

dart 复制代码
// ✅ 正确:确保标记数据已生成并更新UI
void _generateMarkers() {
  _markers.clear();
  // ... 生成标记逻辑
  setState(() {}); // 重要:调用 setState 更新UI
}

// ✅ 正确:markerBuilder 返回 Widget
markerBuilder: (context, date, events) {
  final markers = _markers[date];
  if (markers == null || markers.isEmpty) {
    return null; // 没有标记时返回 null
  }
  
  return Positioned(
    bottom: 1,
    child: Row(
      children: markers.map((marker) => _buildMarker(marker)).toList(),
    ),
  );
},

6.4 错误:日期格式化失败

错误信息:

复制代码
FormatException: Invalid date format

解决方案:

dart 复制代码
// ✅ 正确:使用安全的日期格式化
String _formatDate(DateTime date) {
  return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}

// ✅ 正确:解析日期时添加错误处理
try {
  final date = DateTime.parse(day.fxDate!);
  // 使用日期
} catch (e) {
  debugPrint('解析日期失败: ${day.fxDate}, error: $e');
  continue; // 跳过这条数据
}

6.5 错误:Builder 中无法声明变量

错误信息:

复制代码
Error: Expected an identifier, but got 'final'.

问题分析:

在条件渲染的 ... 展开运算符中直接声明 final 变量会报错。

解决方案:

dart 复制代码
// ✅ 正确:使用 Builder 包裹
if (weatherData['precip'] != null) ...[
  Builder(
    builder: (context) {
      final precipValue = double.tryParse(weatherData['precip'].toString());
      if (precipValue != null && precipValue > 0) {
        return Column(
          children: [
            // ... 显示降水量
          ],
        );
      }
      return const SizedBox.shrink();
    },
  ),
]

// ❌ 错误:直接在展开运算符中声明变量
if (weatherData['precip'] != null) ...[
  final precipValue = ...; // 会报错
]

7. 进阶功能

7.1 自定义日历样式

dart 复制代码
calendarStyle: CalendarStyle(
  todayDecoration: BoxDecoration(
    color: Colors.blue.shade100,
    shape: BoxShape.circle,
  ),
  selectedDecoration: BoxDecoration(
    color: Colors.blue,
    shape: BoxShape.circle,
  ),
  weekendDecoration: BoxDecoration(
    color: Colors.grey.shade100,
    shape: BoxShape.circle,
  ),
  outsideDecoration: const BoxDecoration(
    shape: BoxShape.circle,
  ),
  markerDecoration: const BoxDecoration(
    color: Colors.transparent,
  ),
),

7.2 优化性能

使用缓存避免重复请求:

dart 复制代码
// 检查缓存中是否已有数据
if (_forecastCache.containsKey(_currentCityId)) {
  // 使用缓存数据
  _generateMarkers();
} else {
  // 加载新数据
  await _loadForecast();
}

使用 Future.wait 并行加载:

dart 复制代码
await Future.wait([
  _loadForecast(),
  _loadHistoricalWeather(),
  _loadWeatherAlerts(),
]);

7.3 添加加载状态

dart 复制代码
bool _isLoading = false;

Future<void> _loadWeatherData() async {
  setState(() {
    _isLoading = true;
  });
  
  try {
    // 加载数据
  } finally {
    setState(() {
      _isLoading = false;
    });
  }
}

// 在 build 中显示加载状态
body: _isLoading
    ? const Center(child: CircularProgressIndicator())
    : RefreshIndicator(...),

8. 总结

8.1 实现的功能

本教程详细介绍了如何使用 table_calendar 实现天气日历功能,已完成的功能包括:

  1. 引入三方库 :添加 table_calendar 依赖
  2. 创建页面:实现天气日历页面组件
  3. 数据加载:加载历史天气、未来预报、天气预警
  4. 日历标注:实现降水💧、高温🔥、预警🚨标注
  5. 日期选择:实现日期选择和天气详情显示
  6. 预警详情:显示预警的详细信息
  7. 集成导航:添加到底部导航栏
  8. 城市切换:自动监听城市变化并重新加载数据
  9. 错误处理:常见错误及解决方案

8.2 核心功能

  • 📅 日历显示:支持月/周/2周视图切换,美观的 Material Design 风格
  • 📊 数据展示:显示近30天历史天气和未来15天天气预报
  • 🎯 智能标注 :自动标注降水💧、高温🔥、预警🚨等特殊天气
    • 降水标注:蓝色圆点,当降水量 > 0 时显示
    • 高温标注:红色圆点,当最高温度 > 35°C 时显示
    • 预警标注:橙色圆点,当有天气预警时显示
  • 🔍 详情查看 :点击日期查看详细天气信息
    • 温度信息(最高/最低温度)
    • 天气状况(晴、多云、雨等)
    • 降水量(如有)
    • 预警详情(如有预警,显示预警标题、内容、等级、时间范围)
  • 🔄 数据刷新:支持下拉刷新和手动刷新天气数据
  • 🌍 多城市支持:自动读取当前城市,支持切换城市查看不同地区的天气日历

8.5 完整功能流程图

🌧️ 有降水
🌡️ 高温>35°C
⚠️ 有预警
未来
历史
📱 应用启动
打开天气日历
📍 读取当前城市
📡 加载天气数据
🔮 获取未来15天预报
📜 获取历史30天数据
⚠️ 获取天气预警数据
💾 存储到预报缓存
💾 存储到历史缓存
💾 存储到预警缓存
🏷️ 生成标注数据
检查天气类型
添加蓝色标记
添加红色标记
添加橙色标记
📅 更新日历显示
👆 用户选择日期
日期类型
从预报缓存获取
从历史缓存获取
📊 显示天气详情
显示温度/天气/标注/预警


9. 参考资料


10. 功能演示流程图

💾 数据缓存 🌐 天气服务 📅 日历组件 📱 应用 👤 用户 💾 数据缓存 🌐 天气服务 📅 日历组件 📱 应用 👤 用户 打开天气日历 初始化日历 请求未来15天预报 返回预报数据 缓存预报数据 请求历史30天数据 返回历史数据 缓存历史数据 请求天气预警 返回预警数据 缓存预警数据 生成标注 显示日历和标注 选择日期 触发日期选择事件 查询天气数据 返回天气数据 显示天气详情 下拉刷新 重新请求数据 返回最新数据 更新日历显示 刷新完成


🎉 祝你开发顺利! 🚀
欢迎加入开源鸿蒙跨平台社区

相关推荐
向哆哆5 小时前
构建健康档案管理快速入口:Flutter × OpenHarmony 跨端开发实战
flutter·开源·鸿蒙·openharmony·开源鸿蒙
2601_949593655 小时前
基础入门 React Native 鸿蒙跨平台开发:BackHandler 返回键控制
react native·react.js·harmonyos
mocoding5 小时前
使用Flutter强大的图标库fl_chart优化鸿蒙版天气预报温度、降水量、湿度展示
flutter·华为·harmonyos
向哆哆6 小时前
构建智能健康档案管理与预约挂号系统:Flutter × OpenHarmony 跨端开发实践
flutter·开源·鸿蒙·openharmony·开源鸿蒙
Cobboo6 小时前
i单词上架鸿蒙应用市场之路:一次从 Android 到 HarmonyOS 的完整实战
android·华为·harmonyos
Swift社区6 小时前
Flutter 路由系统,对比 RN / Web / iOS 有什么本质不同?
前端·flutter·ios
kirk_wang6 小时前
Flutter艺术探索-Flutter依赖注入:get_it与provider组合使用
flutter·移动开发·flutter教程·移动开发教程
2601_949593656 小时前
高级进阶 React Native 鸿蒙跨平台开发:LinearGradient 动画渐变效果
react native·react.js·harmonyos
向哆哆6 小时前
Flutter × OpenHarmony:打造校园勤工俭学个人中心界面实战
flutter·开源·鸿蒙·openharmony
2601_949833396 小时前
flutter_for_openharmony口腔护理app实战+我的实现
开发语言·javascript·flutter