Skip to content
Blog

Building a Weather App with Location Services

Build a Flutter weather app that reads the device location with the geolocator package and fetches live forecasts from the free Open-Meteo API, covering Android and iOS permissions, permission-denied flows, and a temperature UI.

Published on August 18, 2026

AI Assistant

Weather apps are the canonical introduction to location services: resolve where the device is, translate coordinates into context, and render useful data on screen. The hard parts are rarely the API call — they are the platform permission systems you have to navigate first. Android and iOS each require manifest declarations, runtime permission requests, and careful handling of every denial path before your app can read a single coordinate.

In this tutorial you will build a Flutter weather app end to end: request location permission through the geolocator package, resolve the current position, call the free Open-Meteo forecast API with the http package, and render the current temperature in a small Flutter UI. Key technologies: Flutter, geolocator, http, Open-Meteo.

Prerequisites

  • Flutter 3.x installed and a device or emulator with location services available
  • Android Studio or Xcode configured for your target platform
  • No API key required — Open-Meteo is free for non-commercial use

Core Content

Set up the project and dependencies

Create a Flutter project and add the two packages you need:

dependencies:
  flutter:
    sdk: flutter
  geolocator: ^14.0.3
  http: ^1.2.0

Run flutter pub get. The geolocator plugin is a federated package: it delegates to the native FusedLocationProviderClient (or LocationManager) on Android and CLLocationManager on iOS, so one Dart API covers both platforms.

Configure Android permissions

Open android/app/src/main/AndroidManifest.xml and add the location permissions as direct children of the <manifest> tag:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Declaring both coarse and fine is important since Android 12 (API 31): when you request only ACCESS_FINE_LOCATION, the system ignores the request and logs ACCESS_FINE_LOCATION must be requested with ACCESS_COARSE_LOCATION. Users can grant approximate location, which maps to coarse only. If your app needs background updates, add ACCESS_BACKGROUND_LOCATION (Android 10+) and, on Android 14+, FOREGROUND_SERVICE_LOCATION. For a foreground-only weather app, the two lines above are sufficient.

These are dangerous permissions, so since Android 6.0 they are granted at runtime, not install time — that is why the Dart code in the next step matters.

Configure iOS location keys

Open ios/Runner/Info.plist and add a usage description. iOS crashes your app if you call the location API without the matching key:

<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location is used to show the weather at your current position.</string>

If you only read location while the app is open, add the BYPASS_PERMISSION_LOCATION_ALWAYS=1 preprocessor flag to the geolocator_apple target (via a post_install hook in your Podfile) so the always-when-in-use key is not required. For background updates you would instead add NSLocationAlwaysAndWhenInUseUsageDescription plus a UIBackgroundModes array containing location, and be prepared to justify that to Apple reviewers.

Request permission and get the current position

Now the Dart that turns those platform declarations into a usable position. The flow is: check that location services are on, check the permission, request it if denied, and distinguish a plain denial (retryable) from deniedForever (must go to settings):

import 'package:geolocator/geolocator.dart';

Future<Position> determinePosition() async {
  bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
  if (!serviceEnabled) {
    return Future.error('Location services are disabled.');
  }

  LocationPermission permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      return Future.error('Location permissions are denied.');
    }
  }

  if (permission == LocationPermission.deniedForever) {
    return Future.error('Location permissions are permanently denied.');
  }

  return Geolocator.getCurrentPosition();
}

When permission comes back deniedForever, the system dialog will never appear again; route the user to the OS settings screen instead:

Future<void> openSettings() async {
  await Geolocator.openAppSettings();
}

For finer control over the fix, pass a LocationSettings object. On Android the accuracy maps to a FusedLocationProvider priority; high maps to PRIORITY_HIGH_ACCURACY (GPS + network), while on iOS high maps to kCLLocationAccuracyNearestTenMeters:

Position position = await Geolocator.getCurrentPosition(
  locationSettings: const LocationSettings(
    accuracy: LocationAccuracy.high,
    timeLimit: Duration(seconds: 10),
  ),
);

Model the weather response

Open-Meteo returns a small, predictable JSON envelope. The current parameter selects only the current-weather block, and timezone=auto resolves local time from the coordinates. Define models that mirror the response:

class Weather {
  final double temperatureC;
  final int weatherCode;

  const Weather({required this.temperatureC, required this.weatherCode});
}

class WeatherResponse {
  final double latitude;
  final double longitude;
  final Weather current;

  const WeatherResponse({
    required this.latitude,
    required this.longitude,
    required this.current,
  });

  factory WeatherResponse.fromJson(Map<String, dynamic> json) {
    final current = json['current'] as Map<String, dynamic>;
    return WeatherResponse(
      latitude: (json['latitude'] as num).toDouble(),
      longitude: (json['longitude'] as num).toDouble(),
      current: Weather(
        temperatureC: (current['temperature_2m'] as num).toDouble(),
        weatherCode: current['weather_code'] as int,
      ),
    );
  }
}

Build the Open-Meteo API client

The client builds a URL from the position and parses the response. The weather code is a WMO code (0 means clear sky, 61 means light rain, and so on), which you can map to an icon or label later:

import 'dart:convert';

import 'package:http/http.dart' as http;

class WeatherApi {
  static const String _endpoint = 'https://api.open-meteo.com/v1/forecast';

  Future<WeatherResponse> fetchWeather({required double latitude, required double longitude}) async {
    final uri = Uri.parse(_endpoint).replace(queryParameters: {
      'latitude': latitude.toString(),
      'longitude': longitude.toString(),
      'current': 'temperature_2m,weather_code',
      'timezone': 'auto',
    });

    final response = await http.get(uri);
    if (response.statusCode != 200) {
      throw Exception('Weather API failed with status ${response.statusCode}');
    }

    return WeatherResponse.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  }
}

No authentication headers are needed for non-commercial use. The endpoint supports up to 16 days of hourly forecast data, so this client can grow into a full forecast screen without changing the auth story.

Wire the location and weather calls together

The UI needs a single method that chains the two concerns: resolve the position, then fetch weather for those coordinates. Wrap the errors so the UI can react to permission problems distinctly from network problems:

Future<Weather> loadWeather() async {
  final position = await determinePosition();
  final response = await WeatherApi().fetchWeather(
    latitude: position.latitude,
    longitude: position.longitude,
  );
  return response.current;
}

Render a simple temperature UI

A minimal stateful screen that fetches on load and shows a loading state, the temperature, and an inline error when anything goes wrong:

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: WeatherScreen()));

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

  @override
  State<WeatherScreen> createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen> {
  late Future<Weather> _weather;

  @override
  void initState() {
    super.initState();
    _weather = loadWeather();
  }

  void _retry() {
    setState(() => _weather = loadWeather());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Weather')),
      body: Center(
        child: FutureBuilder<Weather>(
          future: _weather,
          builder: (context, snapshot) {
            if (snapshot.connectionState != ConnectionState.done) {
              return const CircularProgressIndicator();
            }
            if (snapshot.hasError) {
              return Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(snapshot.error.toString()),
                  const SizedBox(height: 12),
                  FilledButton(onPressed: _retry, child: const Text('Retry')),
                ],
              );
            }
            final weather = snapshot.requireData;
            return Text(
              '${weather.temperatureC.toStringAsFixed(1)} C',
              style: Theme.of(context).textTheme.displayMedium,
            );
          },
        ),
      ),
    );
  }
}

The FutureBuilder keeps the state machine explicit: pending, error, or data. The retry button resets the future so permission-denied users can resume after fixing their settings.

Putting It All Together

The full data flow is: isLocationServiceEnabled -> checkPermission/requestPermission -> getCurrentPosition -> Open-Meteo current request -> Weather.fromJson -> UI. The three layers are deliberately separated — the permission gate lives in its own function, the network call lives in its own client, and the widget only knows about a Future<Weather>. This makes it easy to swap Open-Meteo for another provider or to add a settings screen that deep-links into OS settings when permission is permanently denied.

Conclusion & Next Steps

You now have a working location-aware weather app with correct Android and iOS permission handling, a graceful denial flow, and a free API for live forecasts. From here you can add hourly forecast charts (Open-Meteo returns 168 hours of hourly data by default), map WMO weather codes to icons, cache the last known position with getLastKnownPosition() to avoid a GPS fix on every launch, or listen for location changes with getPositionStream to keep the forecast fresh as the user moves.

The full source for this tutorial is small enough to live in three files: location_service.dart, weather_api.dart, and weather_screen.dart. Keep the permission logic isolated, treat every denial path as a first-class UI state, and the rest of the app will stay simple.