Project 3 — Weather API App
Project 3 — Weather API App
In this project we go online for the first time! We'll build a CLI weather app that fetches real, live weather data from the internet using Dart's http package. You'll learn how to make HTTP requests, parse JSON responses, and handle network errors gracefully.
🎯 What We're Building
A command-line weather app that:
- Accepts a city name from the user
- Uses the Open-Meteo API (100% free, no API key required)
- Uses the Geocoding API to convert city → coordinates
- Displays temperature, wind speed, and weather conditions
- Handles network errors and invalid city names
🌐 API Overview
We'll use two free APIs:
| API | Purpose | URL |
|---|---|---|
| Open-Meteo Geocoding | City name → latitude/longitude | geocoding-api.open-meteo.com |
| Open-Meteo Weather | Coordinates → weather data | api.open-meteo.com |
Why Open-Meteo? It's completely free, requires no API key, has no rate limits for reasonable usage, and returns clean JSON. Perfect for learning!
🧠 Concepts Used
- ✅ HTTP requests with the
httppackage - ✅
async/awaitandFuture - ✅ JSON parsing with
dart:convert - ✅ Model classes with
fromJsonconstructors - ✅ Exception handling for network failures
- ✅
Uri.https()for building safe URLs - ✅ Enums for weather condition codes
📁 Project Structure
weather_app/
├── bin/
│ └── main.dart ← Entry point & CLI
├── lib/
│ ├── models/
│ │ ├── location.dart ← Location/city model
│ │ └── weather_data.dart ← Weather model
│ └── services/
│ └── weather_service.dart ← API calls
└── pubspec.yaml
Step 1 — Set Up the Project
bashdart create weather_app cd weather_app
Add the http package to pubspec.yaml:
yamldependencies: http: ^1.2.0
Then fetch it:
bashdart pub get
Step 2 — The Location Model
Create lib/models/location.dart:
dart// lib/models/location.dart class Location { final String name; // City name as returned by the API final String country; // Country code (e.g., "US", "IN") final double latitude; final double longitude; Location({ required this.name, required this.country, required this.latitude, required this.longitude, }); // Parse from the Open-Meteo Geocoding API response factory Location.fromJson(Map<String, dynamic> json) { return Location( name: json['name'] as String, country: json['country'] as String? ?? 'Unknown', latitude: (json['latitude'] as num).toDouble(), longitude: (json['longitude'] as num).toDouble(), ); } String toString() => '$name, $country ($latitude, $longitude)'; }
Step 3 — The WeatherData Model
Create lib/models/weather_data.dart:
dart// lib/models/weather_data.dart class WeatherData { final double temperatureCelsius; final double feelsLikeCelsius; final double windSpeedKmh; final int weatherCode; // WMO weather interpretation code final String condition; // Human-readable description final String emoji; // Visual indicator WeatherData({ required this.temperatureCelsius, required this.feelsLikeCelsius, required this.windSpeedKmh, required this.weatherCode, }) : condition = _codeToCondition(weatherCode), emoji = _codeToEmoji(weatherCode); // Parse from the Open-Meteo weather API response factory WeatherData.fromJson(Map<String, dynamic> json) { final current = json['current'] as Map<String, dynamic>; return WeatherData( temperatureCelsius: (current['temperature_2m'] as num).toDouble(), feelsLikeCelsius: (current['apparent_temperature'] as num).toDouble(), windSpeedKmh: (current['wind_speed_10m'] as num).toDouble(), weatherCode: current['weather_code'] as int, ); } double get temperatureFahrenheit => (temperatureCelsius * 9 / 5) + 32; // ─── WMO Weather Code Interpretation ───────────────────────── // Full code table: https://open-meteo.com/en/docs static String _codeToCondition(int code) { return switch (code) { 0 => 'Clear Sky', 1 => 'Mainly Clear', 2 => 'Partly Cloudy', 3 => 'Overcast', 45 || 48 => 'Foggy', 51 || 53 || 55 => 'Drizzle', 61 || 63 || 65 => 'Rain', 71 || 73 || 75 => 'Snow', 80 || 81 || 82 => 'Rain Showers', 95 => 'Thunderstorm', 96 || 99 => 'Thunderstorm with Hail', _ => 'Unknown', }; } static String _codeToEmoji(int code) { return switch (code) { 0 => '☀️', 1 || 2 => '🌤️', 3 => '☁️', 45 || 48 => '🌫️', 51 || 53 || 55 => '🌦️', 61 || 63 || 65 => '🌧️', 71 || 73 || 75 => '❄️', 80 || 81 || 82 => '🌦️', 95 || 96 || 99 => '⛈️', _ => '🌡️', }; } }
💡 Pattern Matching: Notice the switch expression with the || pattern — this is Dart 3's pattern matching. case 1 || 2 => ... matches either 1 or 2. Very clean!
Step 4 — The WeatherService Class
Create lib/services/weather_service.dart:
dart// lib/services/weather_service.dart import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/location.dart'; import '../models/weather_data.dart'; class WeatherService { // Base hosts static const _geoHost = 'geocoding-api.open-meteo.com'; static const _weatherHost = 'api.open-meteo.com'; // ─── PUBLIC API ─────────────────────────────────────────────── /// High-level method: takes a city name, returns WeatherData. /// Throws [WeatherException] on any error. Future<({Location location, WeatherData weather})> fetchWeather( String cityName) async { final location = await _geocodeCity(cityName); final weather = await _fetchWeatherForLocation(location); return (location: location, weather: weather); } // ─── PRIVATE HELPERS ────────────────────────────────────────── /// Step 1: Convert city name → lat/lon using Geocoding API Future<Location> _geocodeCity(String cityName) async { final uri = Uri.https( _geoHost, '/v1/search', { 'name': cityName, 'count': '1', // We only need the top result 'language': 'en', 'format': 'json', }, ); final response = await _get(uri); final json = jsonDecode(response.body) as Map<String, dynamic>; final results = json['results'] as List<dynamic>?; if (results == null || results.isEmpty) { throw WeatherException('City "$cityName" not found. ' 'Try a different spelling or a nearby city.'); } return Location.fromJson(results.first as Map<String, dynamic>); } /// Step 2: Fetch weather for given coordinates Future<WeatherData> _fetchWeatherForLocation( Location location) async { final uri = Uri.https( _weatherHost, '/v1/forecast', { 'latitude': location.latitude.toString(), 'longitude': location.longitude.toString(), 'current': [ 'temperature_2m', 'apparent_temperature', 'weather_code', 'wind_speed_10m', ].join(','), 'timezone': 'auto', }, ); final response = await _get(uri); final json = jsonDecode(response.body) as Map<String, dynamic>; return WeatherData.fromJson(json); } /// Shared GET request with timeout and status code checking Future<http.Response> _get(Uri uri) async { try { final response = await http .get(uri) .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { throw WeatherException( 'API error: HTTP ${response.statusCode}'); } return response; } on http.ClientException catch (e) { throw WeatherException( 'Network error: ${e.message}. ' 'Please check your internet connection.'); } on Exception catch (e) { // Catches TimeoutException and other unexpected errors throw WeatherException('Unexpected error: $e'); } } } /// Custom exception for weather-related errors class WeatherException implements Exception { final String message; WeatherException(this.message); String toString() => message; }
💡 Named Records: Future<({Location location, WeatherData weather})> uses Dart 3 named records as a return type. This is a clean way to return multiple values without creating a new class!
Step 5 — The CLI Entry Point
Create bin/main.dart:
dart// bin/main.dart import 'dart:io'; import '../lib/models/weather_data.dart'; import '../lib/models/location.dart'; import '../lib/services/weather_service.dart'; final _service = WeatherService(); Future<void> main() async { printBanner(); while (true) { final cityInput = prompt('\n🏙️ Enter city name (or "quit" to exit)'); if (cityInput == null || cityInput.trim().isEmpty) { print('⚠️ Please enter a city name.'); continue; } final city = cityInput.trim(); if (city.toLowerCase() == 'quit' || city.toLowerCase() == 'exit') { print('\n👋 Stay warm out there!\n'); exit(0); } await getAndDisplayWeather(city); } } Future<void> getAndDisplayWeather(String city) async { print('\n⏳ Fetching weather for "$city"...'); try { final result = await _service.fetchWeather(city); displayWeather(result.location, result.weather); } on WeatherException catch (e) { // Our custom exception — friendly message print('\n❌ $e'); } catch (e) { // Truly unexpected error print('\n❌ Something went wrong: $e'); } } void displayWeather(Location location, WeatherData weather) { final tempC = weather.temperatureCelsius.toStringAsFixed(1); final tempF = weather.temperatureFahrenheit.toStringAsFixed(1); final feelsC = weather.feelsLikeCelsius.toStringAsFixed(1); final wind = weather.windSpeedKmh.toStringAsFixed(1); print(''' ╔════════════════════════════════════════╗ ║ ${weather.emoji} Weather in ${location.name}, ${location.country} ╠════════════════════════════════════════╣ ║ 🌡️ Temperature: ${tempC}°C / ${tempF}°F ║ 🤔 Feels Like: ${feelsC}°C ║ 💨 Wind Speed: ${wind} km/h ║ ☁️ Condition: ${weather.condition} ╚════════════════════════════════════════╝ '''); } String? prompt(String message) { stdout.write(message + ': '); return stdin.readLineSync(); } void printBanner() { print(''' ╔════════════════════════════════════════╗ ║ ⛅ DART WEATHER CLI APP ║ ║ Powered by Open-Meteo (free!) ║ ╚════════════════════════════════════════╝'''); }
Step 6 — Run the App
bashdart run bin/main.dart
Sample session:
⛅ DART WEATHER CLI APP
Powered by Open-Meteo (free!)
🏙️ Enter city name (or "quit" to exit): London
⏳ Fetching weather for "London"...
╔════════════════════════════════════════╗
║ 🌧️ Weather in London, GB
╠════════════════════════════════════════╣
║ 🌡️ Temperature: 12.4°C / 54.3°F
║ 🤔 Feels Like: 9.1°C
║ 💨 Wind Speed: 23.5 km/h
║ ☁️ Condition: Rain
╚════════════════════════════════════════╝
🔍 Understanding the HTTP Flow
User types "Mumbai"
│
▼
1. Geocoding API
GET geocoding-api.open-meteo.com/v1/search?name=Mumbai
← Returns: { "results": [{ "latitude": 19.07, "longitude": 72.87, ... }] }
│
▼
2. Weather API
GET api.open-meteo.com/v1/forecast?latitude=19.07&longitude=72.87¤t=...
← Returns: { "current": { "temperature_2m": 31.2, "weather_code": 0, ... } }
│
▼
3. Display formatted output to user
🛡️ Error Handling Strategy
| Error Type | How We Handle It |
|---|---|
| City not found | WeatherException with friendly message |
| No internet | ClientException → WeatherException |
| API timeout | TimeoutException caught → WeatherException |
| Bad HTTP status | Check statusCode != 200 |
| Unexpected errors | Generic catch (e) as last resort |
🔥 Bonus: 7-Day Forecast
To add a 7-day forecast, add daily parameters to your request:
dart// In _fetchWeatherForLocation(), add to query params: 'daily': 'temperature_2m_max,temperature_2m_min,weather_code', // Parse in WeatherData.fromJson(): final daily = json['daily'] as Map<String, dynamic>; final maxTemps = (daily['temperature_2m_max'] as List) .map((t) => (t as num).toDouble()) .toList();
💡 Key Takeaways
| Concept | Where Used |
|---|---|
http.get() | Fetching data from Open-Meteo |
jsonDecode() | Parsing the API response |
Uri.https() | Building safe, encoded URLs |
async/await | All network calls |
Future<T> | Return type of async methods |
Named records ({...}) | Returning multiple values |
switch expressions | WMO code → condition/emoji |
| Custom exceptions | WeatherException |
.timeout() | Preventing infinite hangs |
Summary
You just built a real API-powered application in Dart! This project demonstrated:
- HTTP requests using the
httppackage - Chaining async calls — geocoding first, then weather
- JSON parsing into model classes
- Layered error handling from network to display
- Pattern matching with switch expressions and
||patterns
This is a major milestone — you can now connect any Dart app to the internet. Next up: we'll create and publish a real Dart package on pub.dev!