Flutter for OpenHarmony智能天气APP实战DAY6:OpenHarmony智能天气APP开发之App UI 视觉优化设计与实现

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

前言

基于 Flutter for OpenHarmony 开发的天气预报应用,已完成自动 IP 定位、多城市切换、实时天气、未来 7 天预报等核心业务功能。但初始版本 UI 界面设计偏基础朴素,存在卡片无层次、视觉区分度低、日期展示不友好、加载空状态简陋等问题,整体美观度与鸿蒙系统原生设计质感差距较大。

为提升项目完成度、适配 OpenHarmony 扁平化轻奢设计规范,本文从卡片视觉质感、温度数据色彩可视化、日期人文格式化、全局状态页定制美化四大维度进行深度 UI 视觉优化。
全程不修改原有业务逻辑、不新增第三方依赖,仅通过原生 Flutter 组件实现高阶界面效果,适配鸿蒙模拟器与真机

一、优化目标

  1. 给 7 天预报卡片、实时信息卡片添加渐变色 + 阴影,提升悬浮质感,适配鸿蒙设计风格;
  2. 最高温、最低温采用冷暖色调区分,数据一目了然;
  3. 未来 7 天预报日期由年月日改为周一、周二… 中文星期格式;
  4. 美化加载中、网络异常、无数据空状态,替换默认简陋样式,增加图标与友好文案。

二、卡片样式升级:渐变 + 阴影 鸿蒙质感优化
核心代码实现

Container(
  width: 115,
  margin: const EdgeInsets.only(right: 10),
  padding: const EdgeInsets.all(12),
  decoration: BoxDecoration(
    // 圆角适配鸿蒙圆润设计
    borderRadius: BorderRadius.circular(14),
    // 半透明白色渐变
    gradient: LinearGradient(
      colors: [
        Colors.white.withValues(alpha: 0.25),
        Colors.white.withValues(alpha: 0.05)
      ],
    ),
    // 卡片阴影 营造悬浮立体效果
    boxShadow: [
      BoxShadow(
        color: Colors.black12,
        blurRadius: 6,
        offset: Offset(1, 2),
      ),
    ],
  ),
  child: // 卡片内部布局内容
)

优化亮点

  • 渐变柔光过渡,不刺眼、高级感强;
  • 阴影轻微虚化,实现卡片悬浮效果;
  • 统一圆角半径,和鸿蒙系统 UI 风格保持一致。

三、温度颜色区分:冷暖色调可视化
核心代码实现

// 最高温 - 暖色调橙色
Text(
  "${day['max']}°",
  style: TextStyle(
    color: Colors.orangeAccent,
    fontWeight: FontWeight.bold,
    fontSize: 15,
  ),
),

// 最低温 - 冷色调浅蓝色
Text(
  "${day['min']}°",
  style: TextStyle(
    color: Colors.lightBlueAccent,
    fontWeight: FontWeight.w500,
    fontSize: 14,
  ),
),

优化亮点

  • 不用仔细看数字,看颜色就能分清高温、低温,符合大众视觉认知习惯。

四、日期显示优化:转为中文星期格式
核心工具方法

// 日期字符串 转 中文星期
String getWeekDay(String dateStr) {
  DateTime date = DateTime.parse(dateStr);
  List<String> weekList = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
  return weekList[date.weekday - 1];
}

页面调用展示

Text(
  getWeekDay(day['date']),
  style: TextStyle(
    color: Colors.white,
    fontWeight: FontWeight.bold,
    fontSize: 16,
  ),
)

优化亮点

  • 界面更简洁清爽,用户一眼识别是周几的天气,符合日常使用习惯。

五、加载与空状态全局美化
5.1 加载中状态美化

isLoading
    ? Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const [
            CircularProgressIndicator(
              color: Colors.white,
              strokeWidth: 3,
            ),
            SizedBox(height: 20),
            Text(
              "正在获取天气数据...",
              style: TextStyle(color: Colors.white, fontSize: 16),
            ),
          ],
        )

5.2 网络异常状态美化

errorMsg.isNotEmpty
    ? Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.wifi_off, color: Colors.white, size: 60),
            const SizedBox(height: 15),
            Text(
              errorMsg,
              style: const TextStyle(color: Colors.white, fontSize: 16),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                // 重新请求天气逻辑
              },
              child: const Text("重新加载"),
            ),
          ],
        )

5.3 无预报空状态美化

dailyForecast == null
    ? const Text(
        "暂无预报数据",
        style: TextStyle(color: Colors.white, fontSize: 16),
      )

优化亮点

  • 全场景状态页面视觉统一,有图标、有文案、有操作入口,不再生硬简陋,极大提升用户体验。

六、完整修改后的main.dart代码(可直接复制)
以下是优化后的完整代码,无需修改任何配置文件,直接替换原有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);
  }

  String getWeekDay(String dateStr) {
    DateTime date = DateTime.parse(dateStr);
    List<String> weeks = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
    return weeks[date.weekday - 1];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            colors: [Color(0xFF64B5F6), Color(0xFFBBDEFB)],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
          ),
        ),
        child: isLoading
            ? Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: const [
              CircularProgressIndicator(
                color: Colors.white,
                strokeWidth: 3,
              ),
              SizedBox(height: 20),
              Text(
                "正在获取天气数据...",
                style: TextStyle(color: Colors.white, fontSize: 16),
              ),
            ],
          ),
        )
            : errorMsg.isNotEmpty
            ? Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(Icons.wifi_off, color: Colors.white, size: 60),
              const SizedBox(height: 15),
              Text(
                errorMsg,
                style: const TextStyle(color: Colors.white, fontSize: 16),
              ),
              const SizedBox(height: 20),
              ElevatedButton(
                onPressed: () {
                  setState(() {
                    isLoading = true;
                    errorMsg = '';
                  });
                  getLocationAndWeather();
                },
                child: const Text("重新加载"),
              ),
            ],
          ),
        )
            : SingleChildScrollView(
          padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 50),
          child: Column(
            children: [
              const Text(
                "鸿蒙天气预报",
                style: TextStyle(
                  fontSize: 26,
                  fontWeight: FontWeight.bold,
                  color: Colors.white,
                ),
              ),
              const SizedBox(height: 10),
              Text(
                "当前城市:$currentCityName",
                style: const TextStyle(
                  fontSize: 17,
                  color: Colors.white70,
                  fontWeight: FontWeight.w500,
                ),
              ),
              const SizedBox(height: 15),

              // 修复 withOpacity 警告:改用 withValues
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                decoration: BoxDecoration(
                  color: Colors.white.withValues(alpha: 0.2),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: DropdownButtonHideUnderline(
                  child: DropdownButton<Map<String, dynamic>>(
                    dropdownColor: Colors.blue[400],
                    style: const TextStyle(color: Colors.white, fontSize: 16),
                    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: 25),
              Text(
                DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now()),
                style: const TextStyle(color: Colors.white70),
              ),

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

              const SizedBox(height: 20),
              Container(
                padding: const EdgeInsets.all(20),
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(16),
                  gradient: LinearGradient(
                    colors: [
                      Colors.white.withValues(alpha: 0.3),
                      Colors.white.withValues(alpha: 0.1)
                    ],
                  ),
                  boxShadow: [
                    BoxShadow(
                      color: Colors.black12,
                      blurRadius: 10,
                      offset: const Offset(2, 4),
                    ),
                  ],
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceAround,
                  children: [
                    _infoItem("湿度", "${weatherData!['relative_humidity_2m']}%"),
                    _infoItem("体感温度", "${weatherData!['apparent_temperature']}°C"),
                  ],
                ),
              ),

              const SizedBox(height: 30),
              const Text(
                "📅 未来7天天气预报",
                style: TextStyle(
                  fontSize: 20,
                  fontWeight: FontWeight.bold,
                  color: Colors.white,
                ),
              ),
              const SizedBox(height: 15),

              dailyForecast == null
                  ? const Text(
                "暂无预报数据",
                style: TextStyle(color: Colors.white),
              )
                  : SizedBox(
                height: 170,
                child: ListView.builder(
                  scrollDirection: Axis.horizontal,
                  itemCount: dailyForecast!.length - 1,
                  itemBuilder: (context, index) {
                    final day = dailyForecast![index + 1];
                    return _forecastItem(day);
                  },
                ),
              ),

              const SizedBox(height: 40),
              ElevatedButton(
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.white,
                  foregroundColor: Colors.blue,
                  padding: const EdgeInsets.symmetric(horizontal: 35, vertical: 14),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(30),
                  ),
                ),
                onPressed: () {
                  setState(() {
                    isLoading = true;
                    currentCityName = "定位中...";
                  });
                  getLocationAndWeather();
                },
                child: const Text("重新定位", style: TextStyle(fontSize: 16)),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _infoItem(String title, String value) {
    return Column(
      children: [
        Text(
          title,
          style: const TextStyle(color: Colors.white70, fontSize: 15),
        ),
        const SizedBox(height: 5),
        Text(
          value,
          style: const TextStyle(
            color: Colors.white,
            fontSize: 18,
            fontWeight: FontWeight.bold,
          ),
        ),
      ],
    );
  }

  Widget _forecastItem(Map day) {
    return Container(
      width: 115,
      margin: const EdgeInsets.only(right: 10),
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(14),
        gradient: LinearGradient(
          colors: [
            Colors.white.withValues(alpha: 0.25),
            Colors.white.withValues(alpha: 0.05)
          ],
        ),
        boxShadow: [
          BoxShadow(
            color: Colors.black12,
            blurRadius: 6,
            offset: const Offset(1, 2),
          ),
        ],
      ),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            getWeekDay(day['date']),
            style: const TextStyle(
              color: Colors.white,
              fontWeight: FontWeight.bold,
              fontSize: 16,
            ),
          ),
          const SizedBox(height: 8),
          weatherIcon(day['code'], 30),
          const SizedBox(height: 8),
          Text(
            "${day['max']}°",
            style: const TextStyle(
              color: Colors.orangeAccent,
              fontWeight: FontWeight.bold,
              fontSize: 15,
            ),
          ),
          Text(
            "${day['min']}°",
            style: const TextStyle(
              color: Colors.lightBlueAccent,
              fontWeight: FontWeight.w500,
              fontSize: 14,
            ),
          ),
        ],
      ),
    );
  }
}

优化后的效果图:
在这里插入图片描述

整体优化总结

本次 UI 视觉优化完全基于原有业务逻辑,不改动接口、不改动功能,只做界面质感升级:
1.卡片渐变 + 阴影,复刻鸿蒙悬浮轻奢风格,界面层次感拉满;
2.高低温冷暖配色,数据可视化,阅读更直观;
3.日期转为中文星期,简洁贴合国人使用习惯;
4.加载、异常、空状态全面美化,交互体验更完整。

所有代码低侵入、可直接复用,适配 Flutter for OpenHarmony 鸿蒙项目,可直接嵌入原有天气预报项目中使用。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐