Skip to content

Build a Flutter Weather App — Complete Project Tutorial with Dart

DodaTech Updated 2026-06-28 12 min read

In this tutorial, you will learn about Build a Flutter Weather App. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete Flutter weather app using Dart that fetches live forecast data from a REST API, displays current conditions and a 7-day forecast with charts, and uses geolocation to auto-detect the user's city with a clean Material You design.

What You Will Learn

  • Making HTTP requests to a weather API (OpenWeatherMap)
  • Parsing JSON responses into Dart model classes
  • Requesting device location permission and geolocation
  • Displaying asynchronous data with loading and error states
  • Building custom charts and visualizations with fl_chart
  • Managing multiple data sources with ChangeNotifier
  • Handling API keys securely in a Flutter app

Why It Matters

The weather app project teaches the most common real-world mobile development pattern: fetch data from an API, parse it into models, display it in a UI, and handle all the edge cases that come with network communication — loading states, errors, empty responses, and permission denials. This same pattern applies to news apps, social media feeds, stock trackers, delivery trackers, and any app that consumes a REST API. Doda Browser's weather widget on the new tab page reuses this exact architecture internally.

Real-World Use

A logistics company uses a custom weather app to monitor conditions at delivery hubs. The app tracks temperature, wind speed, and precipitation probability at each hub location. When severe weather is forecast, the app sends a push notification to reroute deliveries. The same HTTP + geolocation + notification pattern appears in Durga Antivirus Pro's threat alert system, except instead of weather data, it fetches threat intelligence feeds.

Learning Path

flowchart LR
  A[Project Todo App] --> B[Project Weather App\nYou are here]
  B --> C[Project E-Commerce UI]
  style B fill:#f90,color:#fff

Project Setup

Create a new Flutter project:

flutter create weather_app
cd weather_app

Add dependencies to pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0
  geolocator: ^11.0.0
  geocoding: ^3.0.0
  provider: ^6.1.0
  fl_chart: ^0.68.0
  intl: ^0.19.0
  flutter_dotenv: ^5.1.0

The http package makes API calls, geolocator gets the device location, geocoding converts coordinates to city names, fl_chart draws temperature charts, and flutter_dotenv loads the API key from a .env file.

API Key Configuration

Sign up for a free account at OpenWeatherMap and get an API key. Create a .env file in the project root:

WEATHER_API_KEY=your_api_key_here

Add .env to .gitignore so the key is never committed:

*.env

Create a config loader:

// lib/services/config.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';

class Config {
  static String get weatherApiKey {
    return dotenv.env['WEATHER_API_KEY'] ?? '';
  }

  static String get baseUrl => 'https://api.openweathermap.org/data/2.5';
}

Data Models

Define models for the API response. The OpenWeatherMap API returns JSON with this structure:

// lib/models/weather.dart
class Weather {
  final String cityName;
  final String description;
  final String iconCode;
  final double temperature;
  final double feelsLike;
  final int humidity;
  final double windSpeed;
  final int pressure;
  final DateTime timestamp;

  Weather({
    required this.cityName,
    required this.description,
    required this.iconCode,
    required this.temperature,
    required this.feelsLike,
    required this.humidity,
    required this.windSpeed,
    required this.pressure,
    required this.timestamp,
  });

  factory Weather.fromJson(Map<String, dynamic> json, String cityName) {
    return Weather(
      cityName: cityName,
      description: json['weather'][0]['description'],
      iconCode: json['weather'][0]['icon'],
      temperature: (json['main']['temp'] as num).toDouble(),
      feelsLike: (json['main']['feels_like'] as num).toDouble(),
      humidity: json['main']['humidity'],
      windSpeed: (json['wind']['speed'] as num).toDouble(),
      pressure: json['main']['pressure'],
      timestamp: DateTime.fromMillisecondsSinceEpoch(json['dt'] * 1000),
    );
  }

  String get iconUrl => 'https://openweathermap.org/img/wn/$iconCode@2x.png';
}
// lib/models/forecast.dart
class Forecast {
  final DateTime date;
  final double tempMin;
  final double tempMax;
  final String description;
  final String iconCode;

  Forecast({
    required this.date,
    required this.tempMin,
    required this.tempMax,
    required this.description,
    required this.iconCode,
  });

  factory Forecast.fromJson(Map<String, dynamic> json) {
    return Forecast(
      date: DateTime.fromMillisecondsSinceEpoch(json['dt'] * 1000),
      tempMin: (json['temp']['min'] as num).toDouble(),
      tempMax: (json['temp']['max'] as num).toDouble(),
      description: json['weather'][0]['description'],
      iconCode: json['weather'][0]['icon'],
    );
  }

  String get iconUrl => 'https://openweathermap.org/img/wn/$iconCode@2x.png';
}

class ForecastResponse {
  final List<Forecast> forecasts;

  ForecastResponse({required this.forecasts});

  factory ForecastResponse.fromJson(Map<String, dynamic> json) {
    final list = json['list'] as List<dynamic>;
    final daily = <Forecast>[];
    final seen = <String>{};

    for (final item in list) {
      final forecast = Forecast.fromJson(item as Map<String, dynamic>);
      final dateKey = forecast.date.toIso8601String().split('T')[0];
      if (!seen.contains(dateKey)) {
        seen.add(dateKey);
        daily.add(forecast);
      }
    }

    return ForecastResponse(forecasts: daily.take(7).toList());
  }
}

The ForecastResponse.fromJson deduplicates by date and returns the next 7 days.

Weather Service

Create the API service class:

// lib/services/weather_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/weather.dart';
import '../models/forecast.dart';
import 'config.dart';

class WeatherService {
  final http.Client _client;

  WeatherService({http.Client? client}) : _client = client ?? http.Client();

  Future<Weather> getCurrentWeather({
    required double latitude,
    required double longitude,
    String? cityName,
  }) async {
    final url = Uri.parse(
      '${Config.baseUrl}/weather'
      '?lat=$latitude&lon=$longitude'
      '&appid=${Config.weatherApiKey}'
      '&units=metric',
    );

    final response = await _client.get(url);

    if (response.statusCode != 200) {
      throw WeatherException('Failed to fetch weather: ${response.statusCode}');
    }

    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return Weather.fromJson(json, cityName ?? json['name'] as String);
  }

  Future<ForecastResponse> getForecast({
    required double latitude,
    required double longitude,
  }) async {
    final url = Uri.parse(
      '${Config.baseUrl}/forecast'
      '?lat=$latitude&lon=$longitude'
      '&appid=${Config.weatherApiKey}'
      '&units=metric',
    );

    final response = await _client.get(url);

    if (response.statusCode != 200) {
      throw WeatherException('Failed to fetch forecast: ${response.statusCode}');
    }

    final json = jsonDecode(response.body) as Map<String, dynamic>;
    return ForecastResponse.fromJson(json);
  }

  void dispose() {
    _client.close();
  }
}

class WeatherException implements Exception {
  final String message;
  WeatherException(this.message);

  @override
  String toString() => message;
}

The service accepts an optional http.Client parameter, making it testable with a mock client.

Location Service

Create a service to get the device location and reverse-geocode it:

// lib/services/location_service.dart
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';

class LocationService {
  Future<Position> getCurrentPosition() async {
    bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
    if (!serviceEnabled) {
      throw LocationException('Location services are disabled');
    }

    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
      if (permission == LocationPermission.denied) {
        throw LocationException('Location permission denied');
      }
    }

    if (permission == LocationPermission.deniedForever) {
      throw LocationException('Location permission permanently denied');
    }

    return await Geolocator.getCurrentPosition();
  }

  Future<String> getCityName(Position position) async {
    final placemarks = await placemarkFromCoordinates(
      position.latitude,
      position.longitude,
    );

    if (placemarks.isNotEmpty) {
      return placemarks.first.locality ?? placemarks.first.subAdministrativeArea ?? 'Unknown';
    }

    return 'Unknown';
  }
}

class LocationException implements Exception {
  final String message;
  LocationException(this.message);

  @override
  String toString() => message;
}

State Management

Create the provider that coordinates location, API calls, and UI state:

// lib/providers/weather_provider.dart
import 'package:flutter/foundation.dart';
import '../models/weather.dart';
import '../models/forecast.dart';
import '../services/weather_service.dart';
import '../services/location_service.dart';

class WeatherProvider extends ChangeNotifier {
  final WeatherService _weatherService = WeatherService();
  final LocationService _locationService = LocationService();

  Weather? _currentWeather;
  ForecastResponse? _forecast;
  bool _isLoading = false;
  String? _errorMessage;
  String _searchQuery = '';

  Weather? get currentWeather => _currentWeather;
  ForecastResponse? get forecast => _forecast;
  bool get isLoading => _isLoading;
  String? get errorMessage => _errorMessage;
  String get searchQuery => _searchQuery;

  Future<void> loadWeatherForCurrentLocation() async {
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();

    try {
      final position = await _locationService.getCurrentPosition();
      final cityName = await _locationService.getCityName(position);

      final weather = await _weatherService.getCurrentWeather(
        latitude: position.latitude,
        longitude: position.longitude,
        cityName: cityName,
      );
      final forecast = await _weatherService.getForecast(
        latitude: position.latitude,
        longitude: position.longitude,
      );

      _currentWeather = weather;
      _forecast = forecast;
    } on WeatherException catch (e) {
      _errorMessage = e.message;
    } on LocationException catch (e) {
      _errorMessage = e.message;
    } catch (e) {
      _errorMessage = 'An unexpected error occurred';
    }

    _isLoading = false;
    notifyListeners();
  }

  Future<void> searchCity(String query) async {
    _searchQuery = query;
    _isLoading = true;
    _errorMessage = null;
    notifyListeners();

    try {
      final results = await GeocodingLocationHelper.getCoordinates(query);
      if (results == null) {
        _errorMessage = 'City not found';
        _isLoading = false;
        notifyListeners();
        return;
      }

      final weather = await _weatherService.getCurrentWeather(
        latitude: results.latitude,
        longitude: results.longitude,
        cityName: query,
      );
      final forecast = await _weatherService.getForecast(
        latitude: results.latitude,
        longitude: results.longitude,
      );

      _currentWeather = weather;
      _forecast = forecast;
    } catch (e) {
      _errorMessage = 'Could not find weather for $query';
    }

    _isLoading = false;
    notifyListeners();
  }
}

// Helper class for geocoding search
class GeocodingLocationHelper {
  static Future<({double latitude, double longitude})?> getCoordinates(String city) async {
    try {
      final locations = await locationFromAddress(city);
      if (locations.isNotEmpty) {
        return (latitude: locations.first.latitude, longitude: locations.first.longitude);
      }
    } catch (_) {}
    return null;
  }
}

UI Screens

Build the main weather display:

// lib/screens/weather_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/weather_provider.dart';
import '../widgets/forecast_chart.dart';

class WeatherScreen extends StatefulWidget {
  @override
  State<WeatherScreen> createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen> {
  final _searchController = TextEditingController();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<WeatherProvider>().loadWeatherForCurrentLocation();
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Weather'),
        bottom: PreferredSize(
          preferredSize: Size.fromHeight(56),
          child: Padding(
            padding: EdgeInsets.all(8),
            child: TextField(
              controller: _searchController,
              decoration: InputDecoration(
                hintText: 'Search city...',
                prefixIcon: Icon(Icons.search),
                suffixIcon: IconButton(
                  icon: Icon(Icons.my_location),
                  onPressed: () => context.read<WeatherProvider>().loadWeatherForCurrentLocation(),
                ),
                filled: true,
                fillColor: Theme.of(context).colorScheme.surfaceVariant,
                border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
              ),
              onSubmitted: (query) {
                if (query.isNotEmpty) {
                  context.read<WeatherProvider>().searchCity(query);
                }
              },
            ),
          ),
        ),
      ),
      body: Consumer<WeatherProvider>(
        builder: (context, provider, child) {
          if (provider.isLoading) {
            return Center(child: CircularProgressIndicator());
          }

          if (provider.errorMessage != null) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.cloud_off, size: 64, color: Colors.grey),
                  SizedBox(height: 16),
                  Text(provider.errorMessage!, textAlign: TextAlign.center),
                  SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => provider.loadWeatherForCurrentLocation(),
                    child: Text('Retry'),
                  ),
                ],
              ),
            );
          }

          final weather = provider.currentWeather;
          if (weather == null) return SizedBox.shrink();

          return RefreshIndicator(
            onRefresh: () => provider.loadWeatherForCurrentLocation(),
            child: ListView(
              padding: EdgeInsets.all(16),
              children: [
                _CurrentWeatherCard(weather: weather),
                SizedBox(height: 16),
                if (provider.forecast != null)
                  ForecastChart(forecasts: provider.forecast!.forecasts),
              ],
            ),
          );
        },
      ),
    );
  }
}

Current weather card widget:

// lib/widgets/current_weather_card.dart
import 'package:flutter/material.dart';
import '../models/weather.dart';

class _CurrentWeatherCard extends StatelessWidget {
  final Weather weather;

  const _CurrentWeatherCard({required this.weather});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(24),
        child: Column(
          children: [
            Text(weather.cityName, style: Theme.of(context).textTheme.headlineMedium),
            SizedBox(height: 8),
            Image.network(weather.iconUrl, width: 100, height: 100),
            Text(
              '${weather.temperature.round()}°C',
              style: Theme.of(context).textTheme.displaySmall,
            ),
            Text(weather.description, style: Theme.of(context).textTheme.titleMedium),
            SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                _WeatherInfo(label: 'Feels Like', value: '${weather.feelsLike.round()}°C'),
                _WeatherInfo(label: 'Humidity', value: '${weather.humidity}%'),
                _WeatherInfo(label: 'Wind', value: '${weather.windSpeed} m/s'),
                _WeatherInfo(label: 'Pressure', value: '${weather.pressure} hPa'),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

class _WeatherInfo extends StatelessWidget {
  final String label;
  final String value;

  const _WeatherInfo({required this.label, required this.value});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(value, style: Theme.of(context).textTheme.titleLarge),
        Text(label, style: Theme.of(context).textTheme.bodySmall),
      ],
    );
  }
}

Forecast chart using fl_chart:

// lib/widgets/forecast_chart.dart
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../models/forecast.dart';

class ForecastChart extends StatelessWidget {
  final List<Forecast> forecasts;

  const ForecastChart({required this.forecasts});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('7-Day Forecast', style: Theme.of(context).textTheme.titleLarge),
            SizedBox(height: 16),
            SizedBox(
              height: 200,
              child: LineChart(
                LineChartData(
                  gridData: FlGridData(show: true),
                  titlesData: FlTitlesData(
                    leftTitles: AxisTitles(
                      sideTitles: SideTitles(
                        showTitles: true,
                        reservedSize: 40,
                        getTitlesWidget: (value, meta) => Text('${value.round()}°'),
                      ),
                    ),
                    bottomTitles: AxisTitles(
                      sideTitles: SideTitles(
                        showTitles: true,
                        getTitlesWidget: (value, meta) {
                          final index = value.toInt();
                          if (index >= 0 && index < forecasts.length) {
                            return Text(DateFormat.E().format(forecasts[index].date));
                          }
                          return Text('');
                        },
                      ),
                    ),
                    topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
                    rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
                  ),
                  borderData: FlBorderData(show: false),
                  minY: forecasts.map((f) => f.tempMin).reduce((a, b) => a < b ? a : b) - 2,
                  maxY: forecasts.map((f) => f.tempMax).reduce((a, b) => a > b ? a : b) + 2,
                  lineBarsData: [
                    LineChartBarData(
                      spots: forecasts.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.tempMax)).toList(),
                      color: Colors.red,
                      barWidth: 2,
                      dotData: FlDotData(show: true),
                    ),
                    LineChartBarData(
                      spots: forecasts.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.tempMin)).toList(),
                      color: Colors.blue,
                      barWidth: 2,
                      dotData: FlDotData(show: true),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Common Mistakes

  1. Exposing the API key in client-side code: API keys in mobile apps can be extracted from the binary. Use a backend proxy or environment variable service for production. The .env file approach works for development but is not secure for release builds.

  2. Not handling location permission denial gracefully: Users can deny location permission at the system level. Show a clear error message and let them search for a city manually instead of crashing or showing a blank screen.

  3. Parsing weather dates incorrectly: OpenWeatherMap returns timestamps in Unix UTC. Always use DateTime.fromMillisecondsSinceEpoch(json['dt'] * 1000) and convert to local time for display.

  4. Ignoring rate limits: Free API tiers have rate limits (typically 60 requests per minute). Cache responses and add a minimum interval between requests to avoid 429 errors.

  5. Not accounting for metric vs imperial units: The API defaults to Kelvin. Always specify units=metric or units=imperial in the query to get human-readable temperatures.

  6. Hard-coding city coordinates: Some tutorials hard-code coordinates for a specific city, making the app unusable elsewhere. Always use geolocation or a city search to determine coordinates dynamically.

  7. Blocking the UI during API calls: HTTP requests are async in Dart, but if you forget await, the UI tries to render before data arrives. Always use async/await and show a loading indicator.

Practice Questions

  1. Why should you use flutter_dotenv instead of hard-coding the API key in Dart source?
  2. How does the http.Client injection make the service testable?
  3. What changes would you make to support both Celsius and Fahrenheit toggles?
  4. How would you cache weather data to support offline viewing?
  5. Challenge: Add push notifications for severe weather alerts. Use the <a href="/apis/firebase/">Firebase</a>_messaging package and configure a cloud function that checks the forecast daily and sends a notification if extreme temperatures or storms are predicted.

Mini Project

Extend the weather app with these features:

  • Multi-city support: save favorite cities and switch between them with a bottom navigation
  • Hourly forecast view with a horizontal scrollable bar chart
  • Weather maps layer showing precipitation radar (using OpenWeatherMap tile layer)
  • Settings screen for units (metric/imperial), theme (light/dark), and notification preferences
  • Widget for the home screen showing current temperature and condition
  • History screen showing the past 7 days of observed weather with a comparison chart

FAQ

Can I use a different weather API instead of OpenWeatherMap?

Yes. The architecture is API-agnostic. Replace the WeatherService methods to call Weatherstack, WeatherAPI, or AccuWeather. Only the JSON parsing in the factory constructors needs to change.

How do I handle the case when the user denies location permission?

Catch LocationException in the provider and set an error message. The UI shows the error and prompts the user to search for a city manually. You can also show a dialog directing them to system settings.

Is fl_chart the only charting option?

No, you can also use syncfusion_flutter_charts or build custom charts with CustomPainter. fl_chart is chosen here because it is lightweight, well-documented, and supports the chart types most weather apps need.

How do I test the HTTP layer without hitting the real API?

Inject a mock http.Client using MockClient from the http testing package. Return predefined JSON responses and verify that the service parses them into the correct Dart model objects.

Does this app work on the web?

Geolocator and geocoding require native platform APIs and do not work on the web. For a web version, use navigator.geolocation via JavaScript interop or implement a city-only search UI.

What is Next

Proceed to Project E-Commerce UI to build a complex multi-screen UI with product listings, cart management, and navigation. Then explore Project Game with Flame for Game Development with the Flame engine.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro