Flutter for OpenHarmony智能天气APP实战DAY5:OpenHarmony智能天气APP开发之新增未来7天天气预报展示

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

前言

在前两篇教程中,我们已为Flutter for OpenHarmony天气预报App依次实现了「城市下拉选择」「自动定位」功能,实现了手动切换城市、自动获取当前城市并查看实时天气的核心需求。本次将单独新增「未来7天天气预报展示」功能,采用横向滑动卡片布局,清晰展示每日日期、天气图标、最高温/最低温,全程保持鸿蒙环境零报错、零新增依赖,完美适配OpenHarmony 5.1.0(18)版本,新手可直接复制代码复用,不影响原有所有功能。

本文核心:基于原有“自动定位+城市切换”功能,扩展未来7天天气预报逻辑,重点解决3个关键问题——天气接口扩展(获取7天预报数据)、UI布局适配(横向滑动卡片)、天气图标统一(与实时天气图标风格一致),同时保证鸿蒙环境兼容,不添加任何第三方插件,纯Flutter原生组件实现。

一、功能需求与UI设计
功能需求

  • 在原有实时天气展示下方,新增“未来7天天气预报”模块,标题清晰可见;
  • 每个预报卡片展示3个核心信息:日期(MM/dd格式)、天气图标(与实时天气图标风格统一)、最高温度/最低温度;
  • 采用横向滑动布局,支持左右滑动查看7天预报,适配鸿蒙模拟器屏幕尺寸,无溢出问题;
  • 数据联动:切换城市、重新定位后,7天预报数据同步刷新,与实时天气数据保持一致;
  • 容错处理:当预报数据请求失败或无数据时,显示“暂无预报数据”提示,不崩溃、不报错;
  • 保持原有所有功能(自动定位、城市切换、实时天气)不变,UI风格统一、简洁美观。

二、实现步骤
本次功能实现无需修改任何配置文件(pubspec.yaml、build-profile.json5、module.json5等),仅需修改main.dart文件,基于原有“自动定位+城市切换”功能扩展,核心分为5步,全程不影响原有功能,新手可按步骤修改,也可直接复制完整代码替换。

2.1 第一步:新增7天预报相关状态变量
在WeatherPage类中,新增用于存储7天预报数据的变量,用于UI渲染和数据判断,与原有状态变量统一管理

// 新增:存储未来7天预报数据(数组形式)
List<dynamic>? dailyForecast;

// 原有状态变量(保持不变)
Map<String, dynamic>? weatherData;
bool isLoading = true;
String errorMsg = '';
String currentCityName = "定位中...";
final List<Map<String, dynamic>> cities = [/* 原有城市列表 */];
late Map<String, dynamic> selectedCity;

2.2 第二步:修改天气接口,新增7天预报参数
修改原有apiUrl getter方法,在接口中新增daily参数(指定获取的预报字段)和forecast_days=8参数(获取8天数据),确保接口返回7天预报数据。

// 修改后:新增7天预报相关参数
String get apiUrl {
  return 'https://api.open-meteo.com/v1/forecast?latitude=${selectedCity['lat']}&longitude=${selectedCity['lon']}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=Asia/Shanghai&forecast_days=8';
}

2.3 第三步:修改天气请求方法,解析7天预报数据
修改原有fetchWeatherData()方法,在解析实时天气数据的同时,解析daily字段,将7天预报数据封装为数组,存储到dailyForecast变量中,包含容错处理(无数据时设为null)。

Future<void> fetchWeatherData() async {
  try {
    final response = await get(Uri.parse(apiUrl));
    if (response.statusCode == 200) {
      final data = json.decode(response.body);
      setState(() {
        weatherData = data['current']; // 原有:实时天气数据
        // 新增:解析7天预报数据(过滤当前日,取后面7天)
        dailyForecast = data['daily']?['time'] != null
            ? List.generate(data['daily']['time'].length, (index) {
                return {
                  "date": data['daily']['time'][index], // 日期
                  "max": data['daily']['temperature_2m_max'][index], // 最高温
                  "min": data['daily']['temperature_2m_min'][index], // 最低温
                  "code": data['daily']['weathercode'][index], // 天气代码(用于显示图标)
                };
              })
            : null;
        isLoading = false;
      });
    } else {
      setState(() {
        errorMsg = "请求失败";
        isLoading = false;
      });
    }
  } catch (e) {
    setState(() {
      errorMsg = "网络异常";
      isLoading = false;
    });
  }
}

2.4 第四步:复用/优化天气图标方法,统一风格
原有天气图标方法(getWeatherIcon)仅用于实时天气,本次优化方法名称为weatherIcon,增加尺寸参数,使其可复用用于7天预报卡片(小尺寸图标)和实时天气(大尺寸图标),保持图标风格统一。

// 优化后:可复用的天气图标方法(支持自定义尺寸)
Widget weatherIcon(int code, double size) {
  IconData icon;
  Color color;

  if (code == 0) {
    icon = Icons.wb_sunny;
    color = Colors.orange;
  } else if (code >= 1 &amp;&amp; code <= 3) {
    icon = Icons.cloud;
    color = Colors.grey;
  } else if (code >= 45 &amp;&amp; code <= 48) {
    icon = Icons.foggy;
    color = Colors.blueGrey;
  } else if (code >= 51 &&amp; code <= 67) {
    icon = Icons.water_drop;
    color = Colors.blue;
  } else if (code >= 71 &amp;&amp; code <= 77) {
    icon = Icons.snowing;
    color = Colors.lightBlue;
  } else if (code >= 80 && code <= 82) {
    icon = Icons.thunderstorm;
    color = Colors.deepPurple;
  } else {
    icon = Icons.cloud;
    color = Colors.grey;
  }

  return Icon(icon, color: color, size: size);
}

2.5 第五步:新增7天预报UI模块,实现横向滑动卡片
在原有UI布局中,新增“未来7天天气预报”模块,放在“湿度/体感温度”卡片下方、“重新定位”按钮上方,采用横向ListView实现滑动卡片布局,渲染7天预报数据,包含容错处理(无数据时显示提示)。

// 新增:未来7天天气预报模块(放在湿度/体感温度卡片下方)
const SizedBox(height: 25),
const Text(
  "📅 未来7天天气预报",
  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),

// 7天预报横向滑动卡片
dailyForecast == null
    ? const Text("暂无预报数据")
    : SizedBox(
        height: 160, // 固定卡片高度,避免滑动时变形
        child: ListView.builder(
          scrollDirection: Axis.horizontal, // 横向滑动
          itemCount: dailyForecast!.length - 1, // 过滤当前日,取后面7天
          itemBuilder: (context, index) {
            final day = dailyForecast![index + 1]; // 从第2天开始(跳过当前日)
            return Container(
              width: 110, // 每个卡片固定宽度
              margin: const EdgeInsets.only(right: 8), // 卡片之间的间距
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(12), // 圆角设计,与原有卡片统一
              ),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  // 日期(MM/dd格式)
                  Text(
                    DateFormat('MM/dd').format(DateTime.parse(day['date'])),
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 6),
                  // 天气图标(小尺寸)
                  weatherIcon(day['code'], 28),
                  const SizedBox(height: 6),
                  // 最高温/最低温
                  Text("${day['max']}° / ${day['min']}°"),
                ],
              ),
            );
          },
        ),
      ),

三、完整修改后的main.dart代码(可直接复制)
以下是新增未来7天天气预报功能后的完整代码,整合了之前的自动定位、城市切换功能,无需修改任何配置文件,直接替换原有main.dart即可,确保鸿蒙模拟器零报错运行,所有功能正常联动。

import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'dart:convert';
import 'package:intl/intl.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '鸿蒙天气预报',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        brightness: Brightness.light,
      ),
      home: const WeatherPage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

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

  @override
  State<WeatherPage> createState() => _WeatherPageState();
}

class _WeatherPageState extends State<WeatherPage> {
  Map<String, dynamic>? weatherData;
  List<dynamic>? dailyForecast;
  bool isLoading = true;
  String errorMsg = '';
  String currentCityName = "定位中...";

  final List<Map<String, dynamic>> cities = [
    {"name": "北京", "lat": 39.9042, "lon": 116.4074},
    {"name": "上海", "lat": 31.2304, "lon": 121.4737},
    {"name": "广州", "lat": 23.1291, "lon": 113.2644},
    {"name": "深圳", "lat": 22.5431, "lon": 114.0579},
    {"name": "杭州", "lat": 30.2741, "lon": 120.1551},
    {"name": "成都", "lat": 30.5723, "lon": 104.0665},
    {"name": "重庆", "lat": 29.5630, "lon": 106.5516},
  ];

  late Map<String, dynamic> selectedCity;

  @override
  void initState() {
    super.initState();
    selectedCity = cities[0];
    getLocationAndWeather();
  }

  Future<void> getLocationAndWeather() async {
    try {
      final ipResponse = await get(Uri.parse('https://api.ipify.org?format=json'));
      final ip = json.decode(ipResponse.body)['ip'];
      final locResponse = await get(Uri.parse('http://ip-api.com/json/$ip?lang=zh-CN'));
      final data = json.decode(locResponse.body);

      double lat = data['lat'] ?? 39.9042;
      double lon = data['lon'] ?? 116.4074;
      String city = data['city'] ?? "北京";

      setState(() {
        currentCityName = city;
        selectedCity = {"name": city, "lat": lat, "lon": lon};
      });

      fetchWeatherData();
    } catch (e) {
      setState(() {
        currentCityName = "北京";
        selectedCity = cities[0];
      });
      fetchWeatherData();
    }
  }

  String get apiUrl {
    return 'https://api.open-meteo.com/v1/forecast?latitude=${selectedCity['lat']}&longitude=${selectedCity['lon']}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=Asia/Shanghai&forecast_days=8';
  }

  void changeCity(Map<String, dynamic> city) {
    setState(() {
      selectedCity = city;
      currentCityName = city['name'];
      isLoading = true;
      errorMsg = '';
    });
    fetchWeatherData();
  }

  Future<void> fetchWeatherData() async {
    try {
      final response = await get(Uri.parse(apiUrl));
      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        setState(() {
          weatherData = data['current'];
          dailyForecast = data['daily']?['time'] != null
              ? List.generate(data['daily']['time'].length, (index) {
            return {
              "date": data['daily']['time'][index],
              "max": data['daily']['temperature_2m_max'][index],
              "min": data['daily']['temperature_2m_min'][index],
              "code": data['daily']['weathercode'][index],
            };
          })
              : null;
          isLoading = false;
        });
      } else {
        setState(() {
          errorMsg = "请求失败";
          isLoading = false;
        });
      }
    } catch (e) {
      setState(() {
        errorMsg = "网络异常";
        isLoading = false;
      });
    }
  }

  Widget weatherIcon(int code, double size) {
    IconData icon;
    Color color;

    if (code == 0) {
      icon = Icons.wb_sunny;
      color = Colors.orange;
    } else if (code >= 1 && code <= 3) {
      icon = Icons.cloud;
      color = Colors.grey;
    } else if (code >= 45 && code <= 48) {
      icon = Icons.foggy;
      color = Colors.blueGrey;
    } else if (code >= 51 && code <= 67) {
      icon = Icons.water_drop;
      color = Colors.blue;
    } else if (code >= 71 && code <= 77) {
      icon = Icons.snowing;
      color = Colors.lightBlue;
    } else if (code >= 80 && code <= 82) {
      icon = Icons.thunderstorm;
      color = Colors.deepPurple;
    } else {
      icon = Icons.cloud;
      color = Colors.grey;
    }

    return Icon(icon, color: color, size: size);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.lightBlue[50],
      body: isLoading
          ? const Center(child: CircularProgressIndicator())
          : errorMsg.isNotEmpty
          ? Center(child: Text(errorMsg))
          : SingleChildScrollView(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            const SizedBox(height: 40),
            const Text(
              "鸿蒙天气预报",
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 10),
            Text(
              "当前城市:$currentCityName",
              style: const TextStyle(fontSize: 16, color: Colors.blue, fontWeight: FontWeight.w500),
            ),
            const SizedBox(height: 10),

            Container(
              padding: const EdgeInsets.symmetric(horizontal: 14),
              decoration: BoxDecoration(
                border: Border.all(color: Colors.blue),
                borderRadius: BorderRadius.circular(12),
              ),
              child: DropdownButtonHideUnderline(
                child: DropdownButton<Map<String, dynamic>>(
                  value: selectedCity,
                  items: cities.map((c) {
                    return DropdownMenuItem(
                      value: c,
                      child: Text(c['name']),
                    );
                  }).toList(),
                  onChanged: (v) {
                    if (v != null) changeCity(v);
                  },
                ),
              ),
            ),

            const SizedBox(height: 20),
            Text(
              DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()),
              style: const TextStyle(color: Colors.grey),
            ),

            const SizedBox(height: 20),
            weatherIcon(weatherData!['weather_code'], 100),
            const SizedBox(height: 10),
            Text(
              '${weatherData!['temperature_2m']}°C',
              style: const TextStyle(fontSize: 42, fontWeight: FontWeight.bold),
            ),

            const SizedBox(height: 15),
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(15),
              ),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: [
                  Column(
                    children: [
                      const Text("湿度"),
                      Text("${weatherData!['relative_humidity_2m']}%"),
                    ],
                  ),
                  Column(
                    children: [
                      const Text("体感"),
                      Text("${weatherData!['apparent_temperature']}°C"),
                    ],
                  ),
                ],
              ),
            ),

            const SizedBox(height: 25),
            const Text(
              "📅 未来7天天气预报",
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 10),

            dailyForecast == null
                ? const Text("暂无预报数据")
                : SizedBox(
              height: 160,
              child: ListView.builder(
                scrollDirection: Axis.horizontal,
                itemCount: dailyForecast!.length - 1,
                itemBuilder: (context, index) {
                  final day = dailyForecast![index + 1];
                  return Container(
                    width: 110,
                    margin: const EdgeInsets.only(right: 8),
                    padding: const EdgeInsets.all(10),
                    decoration: BoxDecoration(
                      color: Colors.white,
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Text(
                          DateFormat('MM/dd').format(DateTime.parse(day['date'])),
                          style: const TextStyle(fontWeight: FontWeight.bold),
                        ),
                        const SizedBox(height: 6),
                        weatherIcon(day['code'], 28),
                        const SizedBox(height: 6),
                        Text("${day['max']}° / ${day['min']}°"),
                      ],
                    ),
                  );
                },
              ),
            ),

            const SizedBox(height: 30),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  isLoading = true;
                  currentCityName = "定位中...";
                });
                getLocationAndWeather();
              },
              child: const Text("重新定位"),
            ),
            const SizedBox(height: 20),
          ],
        ),
      ),
    );
  }
}

四、鸿蒙适配与功能测试
5.1 测试步骤(无需额外配置)

  1. 替换main.dart文件后,在Android Studio终端执行 flutter pub get(无需新增任何依赖,仅确认原有依赖正常);
  2. 打开DevEco Studio,单独打开ohos目录,启动鸿蒙模拟器(OpenHarmony 5.1.0(18));
  3. 点击运行按钮,等待应用编译安装,启动后自动定位当前城市,同步加载实时天气和7天预报数据;
  4. 测试场景:左右滑动查看7天预报、切换城市(验证预报数据同步刷新)、重新定位(验证预报数据同步更新)、网络异常(验证无数据提示)。
    在这里插入图片描述

五、常见问题解决(新手必看)
问题1:7天预报无数据,显示“暂无预报数据”?
原因:接口请求参数错误,或网络异常导致daily字段未返回数据;或forecast_days参数设置错误。
解决方案:检查apiUrl中的参数,确保包含daily=temperature_2m_max,temperature_2m_min,weathercode和forecast_days=8;检查模拟器网络连接,重启模拟器后重新运行。

问题2:7天预报卡片显示异常、溢出屏幕?
原因:卡片宽度未固定,或ListView未设置固定高度,导致自适应异常。
解决方案:确保Container设置固定宽度(如110),SizedBox设置固定高度(如160),避免自适应导致的溢出问题。

问题3:天气图标显示异常,与实时天气风格不一致?
原因:未复用统一的weatherIcon方法,或天气代码判断逻辑不一致。
解决方案:确保实时天气和7天预报均使用weatherIcon方法,天气代码判断逻辑保持一致,不单独修改。

问题4:切换城市后,7天预报数据未刷新?
原因:changeCity()方法中未调用fetchWeatherData(),或fetchWeatherData()方法未重新解析dailyForecast数据。
解决方案:检查changeCity()方法,确保调用fetchWeatherData();检查fetchWeatherData()方法,确保每次请求都重新解析dailyForecast。

总结

本次新增的未来7天天气预报功能,基于原有Flutter for OpenHarmony天气预报App,仅修改main.dart文件,无需调整任何配置,实现了横向滑动卡片展示7天预报数据的核心需求,同时保持了与原有功能(自动定位、城市切换)的完美联动。
核心亮点在于“零新增依赖、接口扩展实现、UI风格统一”,通过扩展原有天气接口参数,避免了新增接口带来的兼容性问题;采用Flutter原生横向ListView组件,确保鸿蒙环境下滑动流畅、UI适配;完善的容错处理,确保App在各种场景下(无数据、网络异常)均能正常运行,不崩溃、不报错。
对于Flutter for OpenHarmony开发新手来说,本次开发再次验证了“复用现有逻辑、优先使用原生组件”的适配原则,无需复杂配置,仅通过简单的接口扩展和UI渲染,就能实现实用功能。本文代码已在OpenHarmony 5.1.0模拟器上验证通过,可直接复制到项目中使用,后续可根据需求继续扩展,提升App体验!

Logo

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

更多推荐