Flutter Web Weather Dashboard
Build a multi-page weather dashboard with Riverpod and live API data.
Introduction
30 Second Summary
Weather dashboards are a part of daily life. Everyone checks the forecast, but few people think about how a weather app actually pulls live data, keeps multiple views in sync, and adapts to different screen sizes.
In this project, you will build SkyScope, a Flutter web dashboard that fetches live weather data from the Open-Meteo API. You will manage shared state across four pages using Riverpod and create a responsive layout that works on both desktop and mobile.
What You'll Build
Type a city name like Tokyo into the search bar, watch live temperature and conditions appear, then resize your browser and see the navigation switch from a bottom bar to a sidebar while every page stays in sync.
By the end of this project, you'll have:
- A live weather search across any city worldwide, pulling current temperature, wind, humidity, and conditions from the Open-Meteo REST API with zero API keys needed.
- A 7-day forecast with an interactive hourly temperature line chart powered by fl_chart, plus a city comparison view where you add multiple cities and see their weather side by side.
- A responsive four-page app using go_router that switches between sidebar and bottom navigation as you resize the browser, with a Settings page to toggle dark mode and temperature units that update across every page instantly.
- Secret Mission: Add shimmer skeleton loading placeholders and animated page transitions for production polish.
Are there any prerequisites?
You will need Flutter SDK 3.47 installed on your machine and a code editor like VS Code or Cursor. Some basic Flutter knowledge helps, such as having completed at least one tutorial or simple app.
Before We Start
Before writing any code, take a moment to lock in what you are about to build and why it matters for your Flutter skills. This step is your chance to commit to the goal before any hands-on work begins.
Set Up Your Flutter Web Project
SkyScope pulls live weather data and renders it across four screens with shared state. Before any of that can happen, you need a properly configured Flutter web project with the right packages installed.
In this step, you'll scaffold the project, add the dependencies that power each feature, and set up a folder structure that keeps your code organized as the app grows.
In this step, get ready to:
- Create a Flutter web project and install four dependencies.
- Set up a feature-first folder structure with placeholder files.
- Verify the default app runs in Chrome.
Create the project and add dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Flutter web project by running this command:
flutter create --platforms=web sky_scope
What does this command do?
The --platforms=web flag tells Flutter to scaffold only the web platform files. This keeps the project lean by skipping iOS, Android, and desktop targets you won't need.
The command generates a demo counter app that you'll replace with SkyScope in later steps.
- Move into the new project folder by running:
cd sky_scope
- Confirm the project files exist by running:
ls
You should see pubspec.yaml, lib/, and web/ in the output.
Command not found?
Make sure Flutter is installed and on your system PATH. Run flutter --version to check. If that command also fails, revisit the Flutter installation guide before continuing.
Why is my flutter create command not working?
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type the name of your code editor (e.g. VS Code or Cursor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the sky_scope folder, then click Open.
- In the editor sidebar, click pubspec.yaml to open it.
- Select all the existing content (Cmd+A on macOS, Ctrl+A on Windows) and replace it with this code:
name: sky_scope
description: SkyScope - Weather Intelligence Dashboard
publish_to: 'none'
version: 1.0.0
environment:
sdk: ^3.12.0
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.4.2
go_router: ^17.5.0
http: ^1.6.0
fl_chart: ^1.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
What do these dependencies do?
- flutter_riverpod provides Riverpod state management for sharing data across widgets and pages.
- go_router handles page navigation with browser URL support and deep linking.
- http makes HTTP requests to fetch weather data from the Open-Meteo API.
- fl_chart renders interactive line charts for the forecast view.
- Save pubspec.yaml.
- Install the dependencies by running this command in your terminal:
flutter pub get
You should see output listing the resolved packages with no error messages.
Seeing a YAML or dependency error?
- YAML is whitespace-sensitive. Make sure you use spaces (not tabs) and that each indentation level is exactly two spaces.
- Check that the package names and versions match the code block above exactly. A single typo in a package name (e.g. flutter_riverpods instead of flutter_riverpod) causes a resolution failure.
Why is flutter pub get failing for me?
✔️ Awesome, I've got everything!
Great. Double check you've saved pubspec.yaml and that flutter pub get completed without errors.
ⓧ I'd like to double check the full code
Here is the complete pubspec.yaml for comparison:
name: sky_scope
description: SkyScope - Weather Intelligence Dashboard
publish_to: 'none'
version: 1.0.0
environment:
sdk: ^3.12.0
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^3.4.2
go_router: ^17.5.0
http: ^1.6.0
fl_chart: ^1.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Create the feature-first folder structure
Instead of dumping every file into lib/, you'll organize your code into folders by responsibility. This keeps the project navigable as you add screens, providers, and widgets.
- Create the project folders by running this command in your terminal:
mkdir -p lib/core lib/data lib/providers lib/router lib/screens lib/widgets
- Create placeholder files for the core utilities by running:
touch lib/core/constants.dart lib/core/theme.dart lib/core/weather_utils.dart
- Confirm the folders were created by running:
ls lib/
You should see core, data, main.dart, providers, router, screens, and widgets listed.
Why this folder structure?
- core/ holds shared constants, theme definitions, and utility functions.
- data/ holds data models and repository classes that talk to APIs.
- providers/ holds Riverpod providers that manage app state.
- router/ holds navigation configuration.
- screens/ holds full-page widgets.
- widgets/ holds reusable UI components shared across screens.
Missing folders in the output?
Make sure you ran the mkdir command from inside the sky_scope folder, not from your Desktop. Run pwd to check your current directory.
Why are my folders not showing up?
Verify the project runs in Chrome
Before you run the app, what do you expect to see in the browser? You haven't written any application code yet.
- Start the app in Chrome by running:
flutter run -d chrome
Chrome opens with the default Flutter demo app. You should see a counter displaying zero and a blue floating action button.
- Press q in your terminal to stop the app.
App not loading in Chrome?
- Make sure Google Chrome is installed on your machine. Flutter uses Chrome as the default web target.
- If you see a port conflict error, close other development servers that might be using the same port. You can also specify a different port by running flutter run -d chrome --web-port=8080.
Why won't my Flutter app launch in Chrome?
Your project is scaffolded and every dependency is installed. Next up, you'll build the data layer and connect to the Open-Meteo API to see live weather on screen.
Build the Data Layer and Dashboard
Your Flutter web project is scaffolded and running the default counter app. Time to replace it with something real.
Before adding navigation or search, you need to prove the Open-Meteo API connection works and see live weather data on screen. In this step, you will build the data models, connect to the API, write your first AsyncNotifierProvider, and display live Berlin weather on a styled Dashboard.
In this step, get ready to:
- Create data models and a weather repository that calls the Open-Meteo API.
- Create Riverpod providers and a Dashboard screen with loading, error, and retry states.
- Wire up the app entry point and see live Berlin weather in Chrome.
Create the data models and weather repository
Every API integration starts with three things: constants for the URLs, models to hold the response data, and a repository class to make the HTTP calls.
- Open lib/core/constants.dart in your editor. Replace the placeholder content with the API constants by pasting this code:
class ApiConstants {
// Open-Meteo forecast endpoint (no API key needed)
static const String forecastBaseUrl = 'https://api.open-meteo.com/v1/forecast';
static const String geocodingBaseUrl = 'https://geocoding-api.open-meteo.com/v1/search';
// Weather variables to request from the API
static const String currentParams =
'temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,apparent_temperature';
static const String hourlyParams = 'temperature_2m,weather_code';
static const String dailyParams =
'temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum';
}
What does this code do?
ApiConstants stores the Open-Meteo API endpoints and the weather variable names you want returned. The currentParams string tells the API exactly which weather measurements to include in the response.
- Create a new file lib/data/models.dart and add the City class by pasting this code:
// Represents a city from the Open-Meteo Geocoding API
class City {
final int id;
final String name;
final double latitude;
final double longitude;
final String? country;
final String? admin1;
final String? timezone;
const City({
required this.id,
required this.name,
required this.latitude,
required this.longitude,
this.country,
this.admin1,
this.timezone,
});
// Parse a city from the geocoding JSON response
factory City.fromJson(Map<String, dynamic> json) {
return City(
id: json['id'] as int,
name: json['name'] as String,
latitude: (json['latitude'] as num).toDouble(),
longitude: (json['longitude'] as num).toDouble(),
country: json['country'] as String?,
admin1: json['admin1'] as String?,
timezone: json['timezone'] as String?,
);
}
}
What does this code do?
The City class maps directly to the JSON shape the Open-Meteo Geocoding API returns. Each field (id, latitude, longitude) becomes a typed Dart property. The fromJson factory constructor handles the parsing.
- Save both files. Confirm the files exist by running:
ls lib/core/ lib/data/
You should see constants.dart listed under lib/core/ and models.dart listed under lib/data/.
The City class still needs a human-readable display name and equality comparison. It also needs a companion class for the weather data itself.
- In lib/data/models.dart, find the closing } of the City class. Add these methods just before that closing brace:
// Builds "Berlin, Germany" style display text
String get displayName {
final parts = <String>[name];
if (admin1 != null && admin1 != name) parts.add(admin1!);
if (country != null) parts.add(country!);
return parts.join(', ');
}
// Two cities are equal if they share the same API id
@override
bool operator ==(Object other) =>
identical(this, other) || other is City && id == other.id;
@override
int get hashCode => id.hashCode;
- Below the City class (after its closing }), add the CurrentWeather class by pasting this code:
// Holds the current weather snapshot from the forecast API
class CurrentWeather {
final double temperature;
final double apparentTemperature;
final int relativeHumidity;
final double windSpeed;
final int weatherCode;
final DateTime time;
const CurrentWeather({
required this.temperature,
required this.apparentTemperature,
required this.relativeHumidity,
required this.windSpeed,
required this.weatherCode,
required this.time,
});
// Map JSON keys like 'temperature_2m' to Dart fields
factory CurrentWeather.fromJson(Map<String, dynamic> json) {
return CurrentWeather(
temperature: (json['temperature_2m'] as num).toDouble(),
apparentTemperature: (json['apparent_temperature'] as num).toDouble(),
relativeHumidity: (json['relative_humidity_2m'] as num).toInt(),
windSpeed: (json['wind_speed_10m'] as num).toDouble(),
weatherCode: (json['weather_code'] as num).toInt(),
time: DateTime.parse(json['time'] as String),
);
}
}
What does this code do?
displayName joins the city name, region, and country into a readable label like "Berlin, Germany". The equality override lets Riverpod detect when the selected city actually changes.
CurrentWeather maps the API's underscore-style keys (temperature_2m, wind_speed_10m) to clean Dart field names. The weatherCode is a WMO standard integer that you will map to icons and descriptions later.
- Save models.dart.
✔️ Awesome, I've got everything!
Your models file should have the City class (with fromJson, displayName, and equality) followed by the CurrentWeather class. Make sure you have saved the file.
ⓧ I'd like to double check the full code
Scroll up and confirm your lib/data/models.dart has these sections in order:
- City class with fields, constructor, fromJson factory, displayName getter, and ==/hashCode overrides.
- CurrentWeather class with six fields, constructor, and fromJson factory.
Now you need a repository class that uses these models to talk to the Open-Meteo API over HTTP.
- Create a new file lib/data/weather_repository.dart and add the repository class with the getCurrentWeather method by pasting this code:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:sky_scope/core/constants.dart';
import 'package:sky_scope/data/models.dart';
class WeatherRepository {
final http.Client _client;
WeatherRepository({http.Client? client})
: _client = client ?? http.Client();
// Fetch current weather for a lat/lng coordinate
Future<CurrentWeather> getCurrentWeather({
required double latitude,
required double longitude,
String temperatureUnit = 'celsius',
}) async {
final uri = Uri.parse(ApiConstants.forecastBaseUrl).replace(
queryParameters: {
'latitude': latitude.toString(),
'longitude': longitude.toString(),
'current': ApiConstants.currentParams,
'timezone': 'auto',
'temperature_unit': temperatureUnit,
},
);
final response = await _client.get(uri);
if (response.statusCode != 200) {
throw Exception('Failed to fetch weather: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return CurrentWeather.fromJson(json['current'] as Map<String, dynamic>);
}
}
What does this code do?
WeatherRepository wraps an http.Client and builds the forecast URL with query parameters. The timezone: 'auto' parameter tells Open-Meteo to resolve the local timezone from the coordinates automatically.
- Save weather_repository.dart. Verify the data layer compiles by running:
flutter analyze
You should see No issues found! in the terminal output.
Seeing analysis errors?
- If you see an "undefined class" error for ApiConstants, check that lib/core/constants.dart was saved with the new code, not the old placeholder text.
- If you see a "URI doesn't exist" error for the import, make sure the file path matches exactly: package:sky_scope/core/constants.dart.
- help me fix flutter analyze errors in my weather repository
The repository also needs a method to search for cities by name. You will use this on the Dashboard later.
- In lib/data/weather_repository.dart, find the closing } of the WeatherRepository class. Add the searchCities method just before it:
// Search cities by name using the Geocoding API
Future<List<City>> searchCities(String query) async {
if (query.length < 2) return [];
final uri = Uri.parse(ApiConstants.geocodingBaseUrl).replace(
queryParameters: {
'name': query,
'count': '5',
'language': 'en',
},
);
final response = await _client.get(uri);
if (response.statusCode != 200) {
throw Exception('Failed to search cities: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final results = json['results'] as List<dynamic>?;
if (results == null) return [];
return results
.map((e) => City.fromJson(e as Map<String, dynamic>))
.toList();
}
What does this code do?
searchCities calls the Open-Meteo Geocoding API with a city name. It skips queries shorter than 2 characters to avoid noise. The API may return null for the results key when no cities match, so the null check prevents a crash.
- Save weather_repository.dart.
✔️ Awesome, I've got everything!
Your repository file should have the getCurrentWeather method followed by searchCities, both inside the WeatherRepository class. Make sure you have saved.
ⓧ I'd like to double check the full code
Scroll up and confirm your lib/data/weather_repository.dart has these sections in order:
- Four imports (dart:convert, http, constants, models).
- WeatherRepository class with _client field and constructor.
- getCurrentWeather method calling forecastBaseUrl.
- searchCities method calling geocodingBaseUrl.
Create the Riverpod providers and Dashboard screen
The data layer is ready. Now you need Riverpod providers to manage the selected city, app settings, and the weather fetch. Then you will build the UI that watches those providers and reacts to changes.
- Create a new file lib/providers/settings_provider.dart and add the SettingsState class by pasting this code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Immutable snapshot of the app's settings
class SettingsState {
final ThemeMode themeMode;
final String temperatureUnit;
const SettingsState({
this.themeMode = ThemeMode.light,
this.temperatureUnit = 'celsius',
});
SettingsState copyWith({
ThemeMode? themeMode,
String? temperatureUnit,
}) {
return SettingsState(
themeMode: themeMode ?? this.themeMode,
temperatureUnit: temperatureUnit ?? this.temperatureUnit,
);
}
// Returns "C" or "F" for display in the UI
String get unitSymbol => temperatureUnit == 'celsius' ? 'C' : 'F';
}
What does this code do?
SettingsState is an immutable data class holding the current theme mode and temperature unit. The copyWith method creates a new state with one field changed. This pattern is how Riverpod detects state changes.
- Below the SettingsState class in the same file, add the notifier and provider declaration by pasting this code:
// Manages mutations to SettingsState
class SettingsNotifier extends Notifier<SettingsState> {
@override
SettingsState build() => const SettingsState();
void toggleTheme() {
state = state.copyWith(
themeMode: state.themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light,
);
}
void toggleTemperatureUnit() {
state = state.copyWith(
temperatureUnit:
state.temperatureUnit == 'celsius' ? 'fahrenheit' : 'celsius',
);
}
}
final settingsProvider =
NotifierProvider<SettingsNotifier, SettingsState>(SettingsNotifier.new);
What is a NotifierProvider?
A NotifierProvider pairs a Notifier class (which holds logic and state) with a provider that widgets can watch. When the notifier calls state = ..., every widget watching the provider rebuilds with the new state.
- Save settings_provider.dart. Confirm the file exists by running:
ls lib/providers/
You should see settings_provider.dart listed.
- Create a new file lib/providers/city_provider.dart and add the selected city provider by pasting this code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/data/models.dart';
// Tracks which city the user is currently viewing
class SelectedCityNotifier extends Notifier<City> {
@override
City build() {
// Default to Berlin so the Dashboard has data on first load
return const City(
id: 2950159,
name: 'Berlin',
latitude: 52.52,
longitude: 13.41,
country: 'Germany',
admin1: 'Berlin',
timezone: 'Europe/Berlin',
);
}
void selectCity(City city) {
state = city;
}
}
final selectedCityProvider =
NotifierProvider<SelectedCityNotifier, City>(SelectedCityNotifier.new);
Why default to Berlin?
The Dashboard needs a city to fetch weather for on first load. Berlin is hardcoded as the starting city so you see data immediately without needing a search feature yet.
- Save city_provider.dart. Create a new file lib/providers/weather_provider.dart and add the weather providers by pasting this code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/data/models.dart';
import 'package:sky_scope/data/weather_repository.dart';
import 'package:sky_scope/providers/city_provider.dart';
import 'package:sky_scope/providers/settings_provider.dart';
// Single shared instance of WeatherRepository
final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
return WeatherRepository();
});
// Fetches weather whenever the selected city or temp unit changes
class WeatherNotifier extends AsyncNotifier<CurrentWeather> {
@override
Future<CurrentWeather> build() async {
final city = ref.watch(selectedCityProvider);
final unit = ref.watch(
settingsProvider.select((s) => s.temperatureUnit),
);
final repo = ref.read(weatherRepositoryProvider);
return repo.getCurrentWeather(
latitude: city.latitude,
longitude: city.longitude,
temperatureUnit: unit,
);
}
}
final weatherProvider =
AsyncNotifierProvider<WeatherNotifier, CurrentWeather>(
WeatherNotifier.new);
What is an AsyncNotifierProvider?
AsyncNotifierProvider works like NotifierProvider but its build() method returns a Future. The provider wraps the result in an AsyncValue that tracks loading, data, and error states automatically.
The ref.watch(selectedCityProvider) call creates a dependency. Whenever the selected city changes, the weather provider re-runs build() and fetches fresh data. The select on settings means only the temperature unit triggers a refetch, not a theme change.
- Save weather_provider.dart. Verify all providers compile by running:
flutter analyze
You should see No issues found! confirming all your data and provider code compiles cleanly.
Seeing import errors?
- Check that every import path starts with package:sky_scope/ and matches the actual file location under lib/.
- Make sure your terminal is inside the sky_scope project folder, not a parent directory.
- help me debug flutter analyze import errors in my providers
Now you need the utility functions that translate WMO weather codes into human-readable descriptions and icons. The weather card widget needs these.
- Open lib/core/weather_utils.dart and replace the placeholder content with the weather description function by pasting this code:
import 'package:flutter/material.dart';
// Convert WMO weather code to human-readable description
String weatherDescription(int code) => switch (code) {
0 => 'Clear sky',
1 => 'Mainly clear',
2 => 'Partly cloudy',
3 => 'Overcast',
45 || 48 => 'Foggy',
51 || 53 || 55 => 'Drizzle',
56 || 57 => 'Freezing drizzle',
61 || 63 || 65 => 'Rain',
66 || 67 => 'Freezing rain',
71 || 73 || 75 => 'Snow fall',
77 => 'Snow grains',
80 || 81 || 82 => 'Rain showers',
85 || 86 => 'Snow showers',
95 => 'Thunderstorm',
96 || 99 => 'Thunderstorm with hail',
_ => 'Unknown',
};
What are WMO weather codes?
The World Meteorological Organization defines standard integer codes for weather conditions. Open-Meteo returns these codes in the weather_code field. The switch expression maps each code (or range of codes) to a plain-English description like "Rain" or "Partly cloudy".
- Below the weatherDescription function in the same file, add the icon and colour helpers by pasting this code:
// Map weather code ranges to Material Icons
IconData weatherIcon(int code) {
if (code == 0) return Icons.wb_sunny;
if (code <= 3) return Icons.cloud;
if (code <= 48) return Icons.foggy;
if (code <= 57) return Icons.grain;
if (code <= 67) return Icons.water_drop;
if (code <= 77) return Icons.ac_unit;
if (code <= 86) return Icons.snowing;
return Icons.thunderstorm;
}
// Map weather code ranges to accent colours
Color weatherColor(int code) {
if (code == 0) return Colors.amber;
if (code <= 3) return Colors.blueGrey;
if (code <= 48) return Colors.grey;
if (code <= 67) return Colors.blue;
if (code <= 77) return Colors.lightBlue;
return Colors.deepPurple;
}
- Save weather_utils.dart.
Time to build the widget that will display the weather data. The WeatherCard shows the city name, a weather icon, the current temperature, and key stats.
- Create a new file lib/widgets/weather_card.dart and add the WeatherCard widget by pasting this code:
import 'package:flutter/material.dart';
import 'package:sky_scope/core/weather_utils.dart';
import 'package:sky_scope/data/models.dart';
class WeatherCard extends StatelessWidget {
final CurrentWeather weather;
final City city;
final String unitSymbol;
const WeatherCard({
super.key,
required this.weather,
required this.city,
required this.unitSymbol,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// City name row
Row(children: [
Icon(Icons.location_on, color: theme.colorScheme.primary),
const SizedBox(width: 8),
This file is not complete yet. The next chunk finishes the widget tree.
- Directly below the code you just pasted (do not save yet), continue building the WeatherCard by pasting this code:
Expanded(
child: Text(
city.displayName,
style: theme.textTheme.titleLarge,
overflow: TextOverflow.ellipsis,
),
),
]),
const SizedBox(height: 16),
// Weather icon and temperature
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
weatherIcon(weather.weatherCode),
size: 64,
color: weatherColor(weather.weatherCode),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${weather.temperature.round()}\u00B0$unitSymbol',
style: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
- Continue pasting. Add the weather description, the info chips row, and the closing brackets:
Text(
weatherDescription(weather.weatherCode),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
const SizedBox(height: 16),
// Feels-like, humidity, wind stats
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_InfoChip(
icon: Icons.thermostat,
label: 'Feels like',
value: '${weather.apparentTemperature.round()}\u00B0$unitSymbol',
),
_InfoChip(
icon: Icons.water_drop,
label: 'Humidity',
value: '${weather.relativeHumidity}%',
),
_InfoChip(
icon: Icons.air,
label: 'Wind',
value: '${weather.windSpeed.round()} km/h',
),
],
),
],
),
),
);
}
}
- Below the WeatherCard class in the same file, add the _InfoChip helper widget by pasting this code:
// Small chip showing an icon, label, and value
class _InfoChip extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _InfoChip({
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: [
Icon(icon, size: 20, color: theme.colorScheme.primary),
const SizedBox(height: 4),
Text(label, style: theme.textTheme.bodySmall),
Text(value, style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
)),
],
);
}
}
What does the WeatherCard display?
- A top row with a location pin icon and the city's display name.
- A large weather icon (sized 64px) colour-coded by condition, next to the temperature and description.
- Three _InfoChip widgets showing feels-like temperature, humidity percentage, and wind speed.
- Save weather_card.dart. Verify the widget compiles by running:
flutter analyze
You should see No issues found! again.
Seeing bracket or syntax errors?
- The WeatherCard build method has deep nesting. Count your closing brackets carefully. The function ends with ); then } then } (one for build, one for the class).
- If the analyze highlights _InfoChip as undefined, make sure the _InfoChip class is in the same file, below the WeatherCard class.
- help me fix bracket or syntax errors in my weather_card.dart
The last piece of the UI is the Dashboard screen itself. It watches the weather provider and shows loading, data, or error states.
- Create a new file lib/screens/dashboard_screen.dart and add the first half of the Dashboard (the provider setup) by pasting this code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/providers/city_provider.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/providers/weather_provider.dart';
import 'package:sky_scope/widgets/weather_card.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final weatherAsync = ref.watch(weatherProvider);
final city = ref.watch(selectedCityProvider);
final unitSymbol = ref.watch(
settingsProvider.select((s) => s.unitSymbol),
);
// Show a SnackBar when weather fetch fails
ref.listen(weatherProvider, (previous, next) {
if (next.hasError && previous?.hasError != true) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Failed to load weather data'),
action: SnackBarAction(
label: 'Retry',
onPressed: () => ref.invalidate(weatherProvider),
),
),
);
}
});
This file is not complete yet. The next chunk adds the return statement with the UI layout.
- Directly below the code you just pasted (do not save yet), add the return statement that builds the Dashboard layout:
// Build the Dashboard layout
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Dashboard',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
// Smooth transition when the city changes
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: weatherAsync.when(
data: (weather) => WeatherCard(
key: ValueKey(city.id),
weather: weather,
city: city,
unitSymbol: unitSymbol,
),
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
),
),
- Continue pasting. Add the error state and close the widget tree:
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 8),
Text('Could not load weather: $error'),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () =>
ref.invalidate(weatherProvider),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
),
),
),
],
),
),
),
);
}
}
What does the Dashboard do?
- ref.watch(weatherProvider) returns an AsyncValue. The .when() method handles all three states: loading shows a spinner, data shows the WeatherCard, error shows a retry button.
- ref.listen fires a side effect (the SnackBar) when the weather fetch fails. Unlike ref.watch, it does not trigger a rebuild.
- AnimatedSwitcher fades between weather cards when the user switches cities. The key: ValueKey(city.id) tells Flutter that a new city means a new widget, triggering the animation.
- Save dashboard_screen.dart.
Wire up the app entry point
All the pieces are built. You have models, a repository, providers, utilities, a weather card, and a Dashboard screen. The last thing to do is connect them to the app's entry point so Flutter knows what to render.
- Open lib/core/theme.dart and replace the placeholder content with the app theme by pasting this code:
import 'package:flutter/material.dart';
class AppTheme {
// Light theme using Material 3 with blue seed colour
static final light = ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blue,
brightness: Brightness.light,
cardTheme: const CardTheme(
elevation: 2,
margin: EdgeInsets.all(8),
),
);
// Dark theme variant
static final dark = ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blue,
brightness: Brightness.dark,
cardTheme: const CardTheme(
elevation: 2,
margin: EdgeInsets.all(8),
),
);
}
What does this code do?
AppTheme defines two Material 3 themes seeded from Colors.blue. Flutter generates a full colour scheme from that single seed. The CardTheme gives every Card a consistent elevation and margin.
- Save theme.dart. Now open lib/main.dart and replace its entire contents with the new app entry point by pasting this code:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/core/theme.dart';
import 'package:sky_scope/screens/dashboard_screen.dart';
void main() {
runApp(const ProviderScope(child: SkyScope()));
}
// Root widget that applies the theme and shows the Dashboard
class SkyScope extends ConsumerWidget {
const SkyScope({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
return MaterialApp(
title: 'SkyScope',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: settings.themeMode,
home: const DashboardScreen(),
);
}
}
What does this code do?
- ProviderScope wraps the entire app so every widget below it can access Riverpod providers.
- SkyScope is a ConsumerWidget so it can watch the settings provider for theme changes.
- This uses a plain MaterialApp with home: pointing to the DashboardScreen. You will switch to MaterialApp.router when you add navigation in a later step.
- Save main.dart.
Before you run this, what do you expect to see in the browser? Think about what the Dashboard does when it first loads.
- Launch the app in Chrome by running:
flutter run -d chrome
You should briefly see a loading spinner, then a styled card showing live weather for Berlin. The card displays the current temperature, a weather icon, the description (like "Partly cloudy"), feels-like temperature, humidity percentage, and wind speed.
Seeing a blank screen or error?
- If the page is blank, open the browser's developer console (F12) and check for Dart errors. A common cause is a mismatched import path.
- If you see a red error screen mentioning a provider, make sure ProviderScope wraps the SkyScope widget in main.dart.
- If the weather card shows an error state with a Retry button, your internet connection may be blocked or the Open-Meteo API may be temporarily down. Click Retry to try again.
- help me debug my SkyScope Dashboard not loading in Chrome
You have live weather data flowing from the Open-Meteo API, through your repository and Riverpod providers, all the way to a styled Dashboard card. Next up, you will add a search bar so you can look up any city in the world.
Add City Search with Debouncing
Berlin's current temperature, wind speed, humidity, and weather conditions are live on your Dashboard. That proves the Open-Meteo connection works end to end.
A dashboard locked to one city is not useful in the real world. In this step, you'll add a search bar that queries the Open-Meteo Geocoding API as the user types, with debouncing to avoid hammering the server on every keystroke.
In this step, get ready to:
- Create debounced search providers with request cancellation.
- Build a search bar widget with a city results dropdown.
- Connect the search bar to the Dashboard.
Create the search providers
Right now, the Dashboard always shows Berlin because that's the default in selectedCityProvider. To let users search for any city, you need two new Riverpod providers: one to hold the search query text, and one to perform the API call with debouncing.
Debouncing means waiting for the user to pause typing before sending a request. Without it, every keystroke fires a network call.
- Create a new file lib/providers/search_provider.dart and add the following code:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/data/models.dart';
import 'package:sky_scope/providers/weather_provider.dart';
// Holds the raw search text from the input field
final searchQueryProvider = StateProvider<String>((ref) => '');
final searchResultsProvider =
FutureProvider.autoDispose<List<City>>((ref) async {
final query = ref.watch(searchQueryProvider);
if (query.length < 2) return [];
// Set a cancellation flag when this provider is disposed
bool isCancelled = false;
ref.onDispose(() => isCancelled = true);
// Wait 400ms before firing the API call (debounce)
await Future.delayed(const Duration(milliseconds: 400));
if (isCancelled) return [];
final repo = ref.read(weatherRepositoryProvider);
return repo.searchCities(query);
});
What does this code do?
- searchQueryProvider holds the raw text from the search input as a StateProvider. Every keystroke updates it.
- searchResultsProvider uses FutureProvider.autoDispose. When the query changes, the old provider is disposed and recreated automatically.
- ref.onDispose sets a cancellation flag. If the user types another character during the 400ms wait, the old provider disposes, isCancelled becomes true, and the stale API call never fires.
- Queries shorter than 2 characters return an empty list immediately to avoid pointless API calls.
- Save search_provider.dart.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/data/models.dart';
import 'package:sky_scope/providers/weather_provider.dart';
// Holds the raw search text from the input field
final searchQueryProvider = StateProvider<String>((ref) => '');
final searchResultsProvider =
FutureProvider.autoDispose<List<City>>((ref) async {
final query = ref.watch(searchQueryProvider);
if (query.length < 2) return [];
// Set a cancellation flag when this provider is disposed
bool isCancelled = false;
ref.onDispose(() => isCancelled = true);
// Wait 400ms before firing the API call (debounce)
await Future.delayed(const Duration(milliseconds: 400));
if (isCancelled) return [];
final repo = ref.read(weatherRepositoryProvider);
return repo.searchCities(query);
});
Build the search bar widget
The search bar combines a text field with a dropdown overlay showing matching cities. You'll use a ConsumerStatefulWidget because the widget needs local UI state (the text controller and focus node) alongside Riverpod providers.
- Create a new file lib/widgets/city_search_bar.dart and add the class structure:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/data/models.dart';
import 'package:sky_scope/providers/city_provider.dart';
import 'package:sky_scope/providers/search_provider.dart';
class CitySearchBar extends ConsumerStatefulWidget {
// Optional callback for custom selection behavior
final void Function(City city)? onCitySelected;
const CitySearchBar({super.key, this.onCitySelected});
@override
ConsumerState<CitySearchBar> createState() => _CitySearchBarState();
}
class _CitySearchBarState extends ConsumerState<CitySearchBar> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
bool _showResults = false;
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
// Sync each keystroke with the search query provider
void _onChanged(String value) {
ref.read(searchQueryProvider.notifier).state = value;
setState(() => _showResults = value.length >= 2);
}
What is ConsumerStatefulWidget?
A ConsumerStatefulWidget gives you access to both Flutter's local state (like TextEditingController and FocusNode) and Riverpod's global state (via ref). The Dashboard uses a simpler ConsumerWidget because it doesn't need local mutable state.
- Confirm both new files exist by running:
ls lib/providers/search_provider.dart lib/widgets/city_search_bar.dart
- You should see both file paths printed with no errors.
- Below the _onChanged method in city_search_bar.dart, add the city tap handler and the build method:
// Handle city selection and clear the search
void _onCityTap(City city) {
if (widget.onCitySelected != null) {
widget.onCitySelected!(city);
} else {
ref.read(selectedCityProvider.notifier).selectCity(city);
}
_controller.clear();
ref.read(searchQueryProvider.notifier).state = '';
setState(() => _showResults = false);
_focusNode.unfocus();
}
@override
Widget build(BuildContext context) {
final searchResults = ref.watch(searchResultsProvider);
return Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
controller: _controller,
focusNode: _focusNode,
decoration: InputDecoration(
hintText: 'Search for a city...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty ? IconButton(
icon: const Icon(Icons.clear),
onPressed: () { _controller.clear(); _onChanged(''); },
) : null,
border: const OutlineInputBorder(),
),
onChanged: _onChanged,
),
]);
}
}
What do these methods do?
- _onCityTap routes the selected city to either the onCitySelected callback (used later by the Comparison page) or selectedCityProvider. It then clears the text field, resets the query, hides the dropdown, and removes focus.
- The build method watches searchResultsProvider so the widget rebuilds whenever results change. Right now the widget only renders the TextField. You'll add the dropdown next.
- Save city_search_bar.dart.
- Verify the project compiles by running:
flutter analyze
- You should see No issues found! in the output.
Seeing analysis errors?
- Make sure the import paths in city_search_bar.dart match the exact file paths in your project. Check for typos in sky_scope.
- Confirm that search_provider.dart is saved in lib/providers/ and city_search_bar.dart is saved in lib/widgets/.
Why is my Flutter project showing analysis errors?
Connect the search bar to the Dashboard
The search bar widget is ready to use. The Dashboard already has an AnimatedSwitcher that will smoothly transition the weather card when the selected city changes.
- In dashboard_screen.dart, add this import at the top with the other imports:
import 'package:sky_scope/widgets/city_search_bar.dart';
- In the Column children list, find the AnimatedSwitcher( line.
- Add these two lines directly above it:
const CitySearchBar(),
const SizedBox(height: 16),
- Save dashboard_screen.dart.
- Hot reload your app in Chrome.
You should see a search text field with a magnifying glass icon below the Dashboard heading. Type a few characters and the clear button appears on the right.
The search bar accepts input, but results don't appear yet. You still need the dropdown overlay that shows matching cities.
- Switch back to city_search_bar.dart and scroll to the build method.
- Find the TextField's closing ), inside the children list.
- Add the search results dropdown below it, before the closing ]);:
// Show search results dropdown when typing
if (_showResults)
Card(
margin: const EdgeInsets.only(top: 4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 250),
child: searchResults.when(
data: (cities) {
if (cities.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16),
child: Text('No cities found'));
}
return ListView.builder(
shrinkWrap: true,
itemCount: cities.length,
itemBuilder: (context, index) {
final city = cities[index];
return ListTile(
leading: const Icon(Icons.location_city),
title: Text(city.name),
subtitle: Text([city.admin1, city.country]
.where((s) => s != null).join(', ')),
onTap: () => _onCityTap(city),
);
},
);
},
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator())),
error: (err, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Search failed: $err')),
),
),
),
What does the dropdown do?
- if (_showResults) only renders the dropdown when the user has typed 2 or more characters.
- searchResults.when() handles three states: a ListView of city results on success, a CircularProgressIndicator during loading, and error text if the API call fails.
- Each ListTile shows the city name as the title, with the admin region and country as the subtitle.
- Tapping a result calls _onCityTap, which updates the selected city, clears the search, and hides the dropdown.
- Save city_search_bar.dart.
Before you test the search, think about what you expect to happen. When you type "Lon" in the search bar, will results appear instantly, or will there be a slight delay?
- Hot reload your app in Chrome.
- Type Lon in the search bar and wait about half a second.
After the 400ms debounce delay, a dropdown appears with city suggestions including London. The debounce prevented the API call from firing on each individual keystroke.
- Select London, United Kingdom from the dropdown.
The weather card transitions smoothly to show London's current conditions. The AnimatedSwitcher you built in the previous step handles the fade transition automatically because each city has a unique ValueKey.
Search results not appearing?
- Make sure your query is at least 2 characters long. Single-character queries return an empty list by design.
- Check that searchResultsProvider in search_provider.dart watches searchQueryProvider with ref.watch, not ref.read.
- Verify your internet connection. The search calls the Open-Meteo Geocoding API at geocoding-api.open-meteo.com.
- Make sure the dropdown code is inside the children list of the Column, after the TextField.
Why aren't my city search results appearing in SkyScope?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
The full city_search_bar.dart is built across multiple chunks above. Scroll up and confirm you have all three sections in order:
- The class structure with imports, CitySearchBar, state fields, dispose, and _onChanged.
- The _onCityTap handler and the build method with the TextField.
- The dropdown Card with searchResults.when() inside the Column children, after the TextField.
For dashboard_screen.dart, confirm you have the city_search_bar.dart import and that const CitySearchBar() sits in the Column children between the first SizedBox and the AnimatedSwitcher, with a SizedBox(height: 16) after it.
Why autoDispose for search results?
The weatherProvider on the Dashboard uses a plain AsyncNotifierProvider because you always want the current city's weather cached and ready.
Search results are different. They're short-lived data you only need while the dropdown is open. .autoDispose cleans them up the moment no widget is watching, which also triggers the cancellation flag via ref.onDispose.
Your Dashboard now lets users search for any city worldwide and see its weather update live. Try a few more cities to watch the debouncing and smooth transitions in action.
The app works well, but you're stuck on a single page. There's no way to explore detailed forecasts, compare cities, or change settings. In the next step, you'll add multi-page navigation with go_router and a responsive layout that adapts to different screen sizes.
Add Navigation, Settings, and Responsive Layout
Your Dashboard now shows live weather for any city you search for. But the entire app is stuck on a single page. There is no way to explore forecasts, compare cities, or adjust settings like dark mode.
In this step, you will add multi-page navigation using go_router. A ShellRoute wraps all four pages in a persistent navigation shell that switches between a sidebar and a bottom bar based on screen width.
In this step, get ready to:
- Set up a go_router configuration with ShellRoute and four page routes.
- Build a responsive navigation shell that switches between a sidebar and bottom bar at 600 pixels.
- Build a Settings screen with dark mode and temperature unit toggles.
Set up go_router with ShellRoute
The router defines which screen appears at each URL path. Before creating the router itself, you need to create the screen widgets it will reference.
- Create three placeholder screen files. In your editor, create lib/screens/forecast_screen.dart and paste this code:
import 'package:flutter/material.dart';
// Placeholder - replaced in Step 5
class ForecastScreen extends StatelessWidget {
const ForecastScreen({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('Coming soon'),
);
}
}
- Create lib/screens/comparison_screen.dart with the same pattern:
import 'package:flutter/material.dart';
// Placeholder - replaced in Step 5
class ComparisonScreen extends StatelessWidget {
const ComparisonScreen({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('Coming soon'),
);
}
}
- Create lib/screens/settings_screen.dart as a placeholder too. You will replace this with the full implementation later in this step:
import 'package:flutter/material.dart';
// Placeholder - replaced later this step
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('Settings coming soon'),
);
}
}
Why create placeholders first?
The router file you are about to create imports every screen it routes to. If those files do not exist, the Dart analyzer flags unresolved imports and the app will not compile. Placeholders let the router compile while you build the real screens.
- Save all three files.
- Confirm the files appear in your editor's sidebar under lib/screens/. You should see comparison_screen.dart, forecast_screen.dart, and settings_screen.dart alongside the existing dashboard_screen.dart.
Next, create the 404 error page that the router shows when someone navigates to an unknown URL.
- Create lib/screens/not_found_screen.dart and paste this first section:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class NotFoundScreen extends StatelessWidget {
const NotFoundScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
size: 80,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'404',
style: theme.textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
- Paste this directly below to complete the file:
const SizedBox(height: 8),
Text(
'This page could not be found',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => context.go('/'),
icon: const Icon(Icons.home),
label: const Text('Go Home'),
),
],
),
),
);
}
}
What does this screen do?
This is a friendly 404 page. When someone types a URL path that does not match any route, the router shows this screen with a cloud-off icon, a 404 heading, and a Go Home button.
The context.go('/') call navigates back to the Dashboard route.
- Save the file.
Now create the router that ties all these screens together.
- Create the folder lib/router/ if it does not exist.
- Create lib/router/app_router.dart and paste this first section:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sky_scope/screens/comparison_screen.dart';
import 'package:sky_scope/screens/dashboard_screen.dart';
import 'package:sky_scope/screens/forecast_screen.dart';
import 'package:sky_scope/screens/not_found_screen.dart';
import 'package:sky_scope/screens/settings_screen.dart';
import 'package:sky_scope/widgets/responsive_shell.dart';
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => ResponsiveShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: '/forecast',
builder: (context, state) => const ForecastScreen(),
),
- Paste this directly below to complete the file:
GoRoute(
path: '/compare',
builder: (context, state) => const ComparisonScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
],
errorBuilder: (context, state) => const NotFoundScreen(),
);
});
What does this router do?
The routerProvider is a Riverpod Provider that creates a GoRouter instance. The ShellRoute wraps all four child routes in a ResponsiveShell widget, so the navigation UI persists across page changes.
Each GoRoute maps a URL path to a screen widget. The errorBuilder catches any path that does not match a defined route and shows the 404 page.
- Save app_router.dart.
Seeing red underlines on the ResponsiveShell import?
That is expected. The router imports responsive_shell.dart which you have not created yet. The error will resolve in the next substep.
If you see errors on any other import, check that the filenames match exactly.
Help me fix import errors in my go_router setup.
Build the responsive navigation shell
The shell is the persistent UI wrapper that appears on every page. It uses a LayoutBuilder to measure the browser width and switch between a NavigationRail sidebar (desktop) and a NavigationBar at the bottom (mobile).
- Create lib/widgets/responsive_shell.dart and paste the class skeleton with its helper methods:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class ResponsiveShell extends StatelessWidget {
final Widget child;
const ResponsiveShell({super.key, required this.child});
// Determine which nav item is active based on the URL
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.path;
if (location.startsWith('/forecast')) return 1;
if (location.startsWith('/compare')) return 2;
if (location.startsWith('/settings')) return 3;
return 0;
}
// Navigate to the route matching the tapped index
void _onTap(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/');
case 1:
context.go('/forecast');
case 2:
context.go('/compare');
case 3:
context.go('/settings');
}
}
What do these helpers do?
The _currentIndex method reads the current URL path from GoRouterState and returns which navigation tab should be highlighted. The _onTap method navigates to the matching route when the learner taps a tab.
- Save the file.
- Add the bottom-bar navigation destinations directly below the _onTap method:
// Destinations for the mobile bottom NavigationBar
static const _destinations = [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Dashboard',
),
NavigationDestination(
icon: Icon(Icons.calendar_view_week_outlined),
selectedIcon: Icon(Icons.calendar_view_week),
label: 'Forecast',
),
NavigationDestination(
icon: Icon(Icons.compare_arrows_outlined),
selectedIcon: Icon(Icons.compare_arrows),
label: 'Compare',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
];
- Add the sidebar navigation destinations directly below _destinations:
// Destinations for the desktop NavigationRail sidebar
static const _railDestinations = [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text('Dashboard'),
),
NavigationRailDestination(
icon: Icon(Icons.calendar_view_week_outlined),
selectedIcon: Icon(Icons.calendar_view_week),
label: Text('Forecast'),
),
NavigationRailDestination(
icon: Icon(Icons.compare_arrows_outlined),
selectedIcon: Icon(Icons.compare_arrows),
label: Text('Compare'),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Settings'),
),
];
Why two destination lists?
Flutter's NavigationBar widget (bottom bar) uses NavigationDestination objects. The NavigationRail widget (sidebar) uses NavigationRailDestination objects instead. Both lists define the same four pages with the same icons, just in the format each widget requires.
- Save the file.
- Add the build method below _railDestinations. Start with the wide-screen layout that renders a sidebar:
@override
Widget build(BuildContext context) {
final selectedIndex = _currentIndex(context);
return LayoutBuilder(
builder: (context, constraints) {
// Wide layout: sidebar on the left
if (constraints.maxWidth >= 600) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: (i) => _onTap(context, i),
labelType: NavigationRailLabelType.all,
leading: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'SkyScope',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
destinations: _railDestinations,
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: child),
],
),
);
}
- Paste this directly below to add the narrow-screen layout and close the class:
// Narrow layout: bottom navigation bar
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (i) => _onTap(context, i),
destinations: _destinations,
),
);
},
);
}
}
What does the build method do?
The LayoutBuilder checks the available width every time the browser resizes. At 600 pixels or wider, it renders a Row with a NavigationRail sidebar on the left and the page content on the right.
Below 600 pixels, it switches to a Scaffold with a NavigationBar at the bottom. Both layouts highlight the active tab using selectedIndex from the current URL.
- Save responsive_shell.dart.
- Check that the red underlines in app_router.dart have resolved now that responsive_shell.dart exists.
Still seeing import errors in app_router.dart?
Make sure the file is saved as lib/widgets/responsive_shell.dart and the class name is ResponsiveShell with a capital R and S. Dart imports are case-sensitive.
Help me fix unresolved imports in my Flutter project.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your responsive_shell.dart contains all of these sections in order:
- Imports for material and go_router.
- Class declaration with _currentIndex and _onTap methods.
- The _destinations list with four NavigationDestination items.
- The _railDestinations list with four NavigationRailDestination items.
- The build method with LayoutBuilder, a wide-screen NavigationRail branch, and a narrow-screen NavigationBar branch.
The shell and router are ready. Now wire them into the app entry point so you can see navigation in action.
- Replace the entire contents of lib/main.dart with the following:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/router/app_router.dart';
import 'package:sky_scope/core/theme.dart';
void main() {
runApp(const ProviderScope(child: SkyScope()));
}
class SkyScope extends ConsumerWidget {
const SkyScope({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the router and settings so the app reacts to changes
final router = ref.watch(routerProvider);
final settings = ref.watch(settingsProvider);
return MaterialApp.router(
title: 'SkyScope',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: settings.themeMode,
routerConfig: router,
);
}
}
What changed in main.dart?
The old version used a plain MaterialApp with a hard-coded DashboardScreen as its home. The new version uses MaterialApp.router and passes the routerConfig from the router provider. Now go_router controls which screen appears based on the URL.
The ref.watch(settingsProvider) reads the current theme mode so the app toggles between AppTheme.light and AppTheme.dark when the learner flips the switch in Settings.
- Save main.dart.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before running the app.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/router/app_router.dart';
import 'package:sky_scope/core/theme.dart';
void main() {
runApp(const ProviderScope(child: SkyScope()));
}
class SkyScope extends ConsumerWidget {
const SkyScope({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the router and settings so the app reacts to changes
final router = ref.watch(routerProvider);
final settings = ref.watch(settingsProvider);
return MaterialApp.router(
title: 'SkyScope',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: settings.themeMode,
routerConfig: router,
);
}
}
Before you run the app, what do you expect to see? Will the Dashboard still show weather data, or will something break?
- Run the app by running this command:
flutter run -d chrome
The Dashboard loads with your weather data, and a navigation sidebar now appears on the left with four tabs. The browser URL bar shows /.
- Click Forecast in the sidebar. You should see "Coming soon" text and the URL bar change to /forecast.
- Click Compare and then Settings. Each page shows placeholder text and the URL updates to /compare and /settings.
- Resize the browser window to be narrow (under 600 pixels wide). The sidebar should disappear and a bottom navigation bar should appear.
- Widen the window again. The sidebar should reappear.
App crashes or shows a blank page?
Check the terminal for error messages. The most common cause is a missing import in main.dart. Make sure you import app_router.dart, settings_provider.dart, and theme.dart.
If the navigation tabs show but pages are blank, verify that each placeholder screen file exports a widget class matching the name the router imports (ForecastScreen, ComparisonScreen, SettingsScreen).
Help me debug my SkyScope navigation setup.
Build the Settings screen and wire up the app
Navigation works, but the Settings page is still a placeholder. Time to replace it with a real screen that lets you toggle dark mode and switch between Celsius and Fahrenheit.
- Open lib/screens/settings_screen.dart and replace all of its contents with this first section. This sets up the widget scaffold and the Dark Mode toggle:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/providers/settings_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Settings',
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 24),
- Paste the toggles Card directly below:
// Card with theme and unit toggle switches
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: Text(
settings.themeMode == ThemeMode.dark
? 'Dark theme active'
: 'Light theme active',
),
secondary: Icon(
settings.themeMode == ThemeMode.dark
? Icons.dark_mode
: Icons.light_mode,
),
value: settings.themeMode == ThemeMode.dark,
onChanged: (_) {
ref
.read(settingsProvider.notifier)
.toggleTheme();
},
),
const Divider(height: 1),
What does this code do?
The widget watches settingsProvider so it rebuilds whenever settings change. The SwitchListTile for Dark Mode reads the current theme mode and calls toggleTheme() on the notifier when the switch is tapped.
- Save the file.
- Paste the Fahrenheit toggle directly below the Divider:
SwitchListTile(
title: const Text('Fahrenheit'),
subtitle: Text(
settings.temperatureUnit == 'fahrenheit'
? 'Showing temperatures in \u00B0F'
: 'Showing temperatures in \u00B0C',
),
secondary: const Icon(Icons.thermostat),
value: settings.temperatureUnit == 'fahrenheit',
onChanged: (_) {
ref
.read(settingsProvider.notifier)
.toggleTemperatureUnit();
},
),
],
),
),
- Paste the About card and closing brackets directly below to complete the file:
const SizedBox(height: 24),
// About section with attribution
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About SkyScope',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'Weather data provided by Open-Meteo.com under CC BY 4.0 licence. '
'No API key required.',
),
],
),
),
),
],
),
),
),
);
}
}
What does the rest of the Settings screen do?
The Fahrenheit toggle calls toggleTemperatureUnit() on the settings notifier. Because the weather provider uses select to watch only the temperature unit, flipping this switch triggers a fresh API call with the new unit.
The About card credits Open-Meteo as the data source. The entire screen is constrained to 600 pixels wide so it does not stretch across ultra-wide displays.
- Save settings_screen.dart.
Seeing a syntax error in settings_screen.dart?
The most common cause is a missing or extra bracket at the end of the file. Count the closing brackets. You should have one closing each for: the Column children list, the Column, the Card, the outer Column children list, the outer Column, the ConstrainedBox, the Center, the SingleChildScrollView, the build method, and the class.
Help me fix bracket errors in my Settings screen.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before reloading.
ⓧ I'd like to double check the full code
Scroll up and confirm your settings_screen.dart contains all of these sections in order:
- Imports for material, flutter_riverpod, and settings_provider.
- A ConsumerWidget class with ref.watch(settingsProvider) in the build method.
- A Card with two SwitchListTile widgets (Dark Mode and Fahrenheit) separated by a Divider.
- An About SkyScope card with attribution text.
What do you think will happen when you reload the app and navigate to Settings? Will the toggles already work, or will they need something else?
- In the terminal where the app is running, press R (capital R) to perform a hot restart.
- Navigate to Settings using the sidebar.
- Toggle the Dark Mode switch. The entire app should switch to a dark theme immediately.
- Toggle the Fahrenheit switch. The subtitle should change to "Showing temperatures in °F".
- Click Dashboard in the sidebar. The weather card should now show the temperature in Fahrenheit.
- Resize the browser window below 600 pixels wide. The sidebar should disappear and a bottom navigation bar should appear.
- Widen the window again past 600 pixels. The sidebar should reappear.
Theme toggle does not change anything?
Make sure main.dart uses ref.watch(settingsProvider) and passes settings.themeMode to the themeMode parameter of MaterialApp.router. If main.dart does not watch the settings provider, it will not rebuild when the theme changes.
Also verify that settings_screen.dart calls ref.read(settingsProvider.notifier).toggleTheme() in the onChanged callback, not ref.watch.
Help me debug my Settings screen toggles.
Your app now has four-page navigation, a responsive layout, working theme and unit toggles, and browser URL support. Next up, you will fill in the Forecast and Comparison pages with live data, interactive charts, and the most advanced Riverpod patterns in the project.
Build Forecast Charts and City Comparison
Your navigation shell is in place and all four tabs are wired up. But two of those pages are still empty placeholders.
This step fills them with the most advanced Riverpod patterns in the project. You will use .family providers for per-city weather data, derived providers for computed comparison summaries, and select for efficient rebuilds. The fl_chart line chart brings the forecast data to life.
In this step, get ready to:
- Add forecast data models and a new API method to fetch hourly and daily weather.
- Build the Forecast screen with an interactive temperature line chart using fl_chart.
- Build the City Comparison screen with .family providers and a derived summary.
Build the Forecast screen with fl_chart
The Dashboard fetches current weather, but a real weather app needs forecasts. You need new data models for hourly and daily predictions, a repository method to fetch them, and a chart widget to display them.
- In lib/data/models.dart, add the HourlyForecast class below the existing CurrentWeather class:
class HourlyForecast {
final List<DateTime> times;
final List<double> temperatures;
final List<int> weatherCodes;
const HourlyForecast({
required this.times,
required this.temperatures,
required this.weatherCodes,
});
factory HourlyForecast.fromJson(Map<String, dynamic> json) {
final timeStrings = (json['time'] as List).cast<String>();
return HourlyForecast(
times: timeStrings.map(DateTime.parse).toList(),
temperatures: (json['temperature_2m'] as List)
.map((e) => (e as num).toDouble())
.toList(),
weatherCodes: (json['weather_code'] as List)
.map((e) => (e as num).toInt())
.toList(),
);
}
}
What does HourlyForecast hold?
Open-Meteo returns hourly arrays for time, temperature, and weather codes. HourlyForecast maps the JSON keys temperature_2m and weather_code into typed Dart lists so the chart widget can loop through them.
- Still in lib/data/models.dart, add the DailyForecast class below HourlyForecast:
class DailyForecast {
final List<DateTime> dates;
final List<double> maxTemperatures;
final List<double> minTemperatures;
final List<int> weatherCodes;
final List<double> precipitationSums;
const DailyForecast({
required this.dates,
required this.maxTemperatures,
required this.minTemperatures,
required this.weatherCodes,
required this.precipitationSums,
});
factory DailyForecast.fromJson(Map<String, dynamic> json) {
final dateStrings = (json['time'] as List).cast<String>();
return DailyForecast(
dates: dateStrings.map(DateTime.parse).toList(),
maxTemperatures: (json['temperature_2m_max'] as List)
.map((e) => (e as num).toDouble())
.toList(),
minTemperatures: (json['temperature_2m_min'] as List)
.map((e) => (e as num).toDouble())
.toList(),
weatherCodes: (json['weather_code'] as List)
.map((e) => (e as num).toInt())
.toList(),
precipitationSums: (json['precipitation_sum'] as List)
.map((e) => (e as num).toDouble())
.toList(),
);
}
}
- Add the ForecastData wrapper class below DailyForecast:
class ForecastData {
final HourlyForecast hourly;
final DailyForecast daily;
const ForecastData({required this.hourly, required this.daily});
}
- Save models.dart.
- Now open lib/data/weather_repository.dart and add the getForecast method inside the WeatherRepository class, below the existing getCurrentWeather method:
Future<ForecastData> getForecast({
required double latitude,
required double longitude,
String temperatureUnit = 'celsius',
}) async {
final uri = Uri.parse(ApiConstants.forecastBaseUrl).replace(
queryParameters: {
'latitude': latitude.toString(),
'longitude': longitude.toString(),
'hourly': ApiConstants.hourlyParams,
'daily': ApiConstants.dailyParams,
'timezone': 'auto',
'forecast_days': '7',
'temperature_unit': temperatureUnit,
},
);
final response = await _client.get(uri);
if (response.statusCode != 200) {
throw Exception('Failed to fetch forecast: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
return ForecastData(
hourly: HourlyForecast.fromJson(json['hourly'] as Map<String, dynamic>),
daily: DailyForecast.fromJson(json['daily'] as Map<String, dynamic>),
);
}
What does this method request?
This calls the same Open-Meteo forecast endpoint but requests different parameters. The hourly field asks for temperature_2m and weather_code.
The daily field asks for max/min temperatures, weather codes, and precipitation totals. Setting forecast_days to 7 gives you a full week of data.
- Save weather_repository.dart.
- Hot restart your app in Chrome. The app should load without any errors.
Seeing a compile error?
Make sure ForecastData is defined below DailyForecast in models.dart. It references both HourlyForecast and DailyForecast, so it must come after them.
Check that your import for models.dart in weather_repository.dart already exists from the previous step.
Why is my forecast data not compiling?
- Now open lib/providers/weather_provider.dart and add the forecastProvider below the existing weatherProvider:
final forecastProvider =
FutureProvider.autoDispose<ForecastData>((ref) async {
final city = ref.watch(selectedCityProvider);
final unit = ref.watch(
settingsProvider.select((s) => s.temperatureUnit),
);
final repo = ref.read(weatherRepositoryProvider);
return repo.getForecast(
latitude: city.latitude,
longitude: city.longitude,
temperatureUnit: unit,
);
});
Why FutureProvider.autoDispose?
FutureProvider.autoDispose fetches data once and cleans up when no widget is watching. When the learner navigates away from the Forecast screen, the provider is disposed. Navigating back creates a fresh fetch.
The select call on settingsProvider means this provider only refetches when the temperature unit changes. Theme changes do not trigger a refetch.
- Make sure you also add the import for ForecastData at the top of the file if your editor has not auto-imported it. The existing import of package:sky_scope/data/models.dart already covers it.
- Save weather_provider.dart.
- Create the file lib/widgets/forecast_chart.dart. Start with the imports, class declaration, and the data preparation section of the build method:
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:sky_scope/data/models.dart';
class ForecastChart extends StatelessWidget {
final HourlyForecast hourly;
final String unitSymbol;
const ForecastChart({
super.key,
required this.hourly,
required this.unitSymbol,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final startIndex = hourly.times.indexWhere((t) => t.isAfter(now));
final start = startIndex >= 0 ? startIndex : 0;
final end = (start + 24).clamp(0, hourly.times.length);
final spots = <FlSpot>[];
for (int i = start; i < end; i++) {
spots.add(FlSpot(
(i - start).toDouble(),
hourly.temperatures[i],
));
}
What is this data preparation doing?
The API returns hourly data for the entire forecast window. This code finds the first hour after "now" and slices the next 24 hours of data.
Each temperature becomes a FlSpot point. The x value is the offset from the start (0 through 23) and the y value is the temperature. fl_chart uses these spots to plot the line.
- Continue adding the empty-state check, min/max calculations, and the beginning of the LineChart widget:
if (spots.isEmpty) {
return const Center(child: Text('No hourly data available'));
}
final temps = spots.map((s) => s.y);
final minTemp = temps.reduce((a, b) => a < b ? a : b);
final maxTemp = temps.reduce((a, b) => a > b ? a : b);
final padding = ((maxTemp - minTemp) * 0.2).clamp(2.0, 10.0);
return SizedBox(
height: 220,
child: LineChart(
LineChartData(
minY: minTemp - padding,
maxY: maxTemp + padding,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 5,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.dividerColor.withValues(alpha: 0.3),
strokeWidth: 1,
),
),
- Continue adding the axis titles configuration:
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (value, meta) => Text(
'${value.round()}\u00B0',
style: theme.textTheme.bodySmall,
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 4,
getTitlesWidget: (value, meta) {
final index = start + value.toInt();
if (index >= hourly.times.length) return const SizedBox();
final hour = hourly.times[index].hour;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${hour.toString().padLeft(2, '0')}:00',
style: theme.textTheme.bodySmall,
),
);
},
),
),
- Finish the chart with the line styling, touch tooltips, and closing braces:
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: theme.colorScheme.primary,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: theme.colorScheme.primary.withValues(alpha: 0.1),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
return LineTooltipItem(
'${spot.y.round()}\u00B0$unitSymbol',
- Add the final closing braces to complete the file:
TextStyle(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
),
);
}
}
How does the LineChart work?
LineChartData configures the entire chart. lineBarsData holds one LineChartBarData that draws a curved line through the FlSpot points.
belowBarData fills the area under the line with a 10% opacity tint of the primary colour. LineTouchData adds tooltip popups when you hover or tap a point on the chart.
- Save forecast_chart.dart.
- Hot restart your app to confirm it still compiles. The Forecast page still shows the placeholder since we have not updated the screen yet.
Now replace the placeholder Forecast screen with the real implementation. The screen watches forecastProvider and renders the chart plus a 7-day card list.
- Open lib/screens/forecast_screen.dart and replace its entire contents. Start with the imports, class declaration, and provider watches:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/core/weather_utils.dart';
import 'package:sky_scope/providers/city_provider.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/providers/weather_provider.dart';
import 'package:sky_scope/widgets/forecast_chart.dart';
class ForecastScreen extends ConsumerWidget {
const ForecastScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final forecastAsync = ref.watch(forecastProvider);
final city = ref.watch(selectedCityProvider);
final unitSymbol = ref.watch(
settingsProvider.select((s) => s.unitSymbol),
);
final theme = Theme.of(context);
- Continue with the layout scaffold and the data builder that renders the chart card and the 7-day heading:
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Forecast for ${city.name}',
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 16),
forecastAsync.when(
data: (forecast) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next 24 Hours',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
- Continue with the chart widget, the 7-day list, and the loading/error states:
ForecastChart(
hourly: forecast.hourly,
unitSymbol: unitSymbol,
),
],
),
),
),
const SizedBox(height: 16),
Text(
'7-Day Forecast',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
...List.generate(
forecast.daily.dates.length,
(i) => _DayCard(
date: forecast.daily.dates[i],
maxTemp: forecast.daily.maxTemperatures[i],
minTemp: forecast.daily.minTemperatures[i],
weatherCode: forecast.daily.weatherCodes[i],
precipitation: forecast.daily.precipitationSums[i],
unitSymbol: unitSymbol,
),
),
],
),
- Add the loading state, error state with retry, and close the ForecastScreen class:
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Icon(Icons.cloud_off, size: 48),
const SizedBox(height: 8),
Text('Could not load forecast: $error'),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () =>
ref.invalidate(forecastProvider),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
),
),
],
),
),
),
);
}
}
Now you need the _DayCard widget that each row in the 7-day list uses.
- Still in forecast_screen.dart, add the _DayCard class below the ForecastScreen class. Start with the fields, constructor, and the day label getter:
class _DayCard extends StatelessWidget {
final DateTime date;
final double maxTemp;
final double minTemp;
final int weatherCode;
final double precipitation;
final String unitSymbol;
const _DayCard({
required this.date,
required this.maxTemp,
required this.minTemp,
required this.weatherCode,
required this.precipitation,
required this.unitSymbol,
});
String get _dayLabel {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final diff = date.difference(today).inDays;
if (diff == 0) return 'Today';
if (diff == 1) return 'Tomorrow';
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return days[date.weekday - 1];
}
- Add the _DayCard build method:
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SizedBox(
width: 80,
child: Text(
_dayLabel,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Icon(
weatherIcon(weatherCode),
color: weatherColor(weatherCode),
),
const SizedBox(width: 8),
Expanded(
child: Text(weatherDescription(weatherCode)),
),
- Finish _DayCard with the precipitation display and temperature values:
if (precipitation > 0) ...[
Icon(Icons.water_drop, size: 16, color: Colors.blue[300]),
const SizedBox(width: 2),
Text('${precipitation.toStringAsFixed(1)} mm'),
const SizedBox(width: 8),
],
Text(
'${maxTemp.round()}\u00B0',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
Text(
'${minTemp.round()}\u00B0',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
- Save forecast_screen.dart.
- Hot restart the app and click the Forecast tab in the navigation.
You should see a curved temperature line chart for the next 24 hours inside a card, with hourly labels along the bottom. Below it, a list of 7 day-cards shows each day's weather icon, description, and high/low temperatures.
Chart is blank or showing no data?
If the chart shows 'No hourly data available', the API may have returned an empty hourly array. Check your internet connection and try a hot restart.
If you see a render overflow, make sure the SizedBox height is set to 220 in forecast_chart.dart.
My forecast chart isn't rendering correctly.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both files before moving on.
ⓧ I'd like to double check the full code
The files are long, so scroll up and confirm each section is present in order.
- forecast_chart.dart: imports, ForecastChart class with hourly and unitSymbol fields, build method with spots calculation, empty check, SizedBox containing LineChart with grid data, titles, border, line bars, and touch tooltips.
- forecast_screen.dart: imports, ForecastScreen ConsumerWidget watching forecastProvider, selectedCityProvider, and settingsProvider.select(unitSymbol). Returns SingleChildScrollView with chart card, 7-day heading, List.generate of _DayCard widgets, loading spinner, error with retry.
- _DayCard class: fields for date, maxTemp, minTemp, weatherCode, precipitation, unitSymbol. A _dayLabel getter returning Today/Tomorrow/weekday name. Build method with Row containing day label, weather icon, description, optional precipitation, and high/low temps.
Build the City Comparison screen with .family providers
The Comparison page needs to fetch weather for multiple cities at once. Each city needs its own independent API call. Riverpod's .family modifier creates a separate provider instance for each city you pass in.
- Create the file lib/providers/comparison_provider.dart. Start with the imports and the ComparisonNotifier that manages the list of compared cities:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/data/models.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/providers/weather_provider.dart';
class ComparisonNotifier extends Notifier<List<City>> {
@override
List<City> build() => [];
void addCity(City city) {
if (!state.contains(city)) {
state = [...state, city];
}
}
void removeCity(City city) {
state = state.where((c) => c.id != city.id).toList();
}
void clear() {
state = [];
}
}
final comparisonCitiesProvider =
NotifierProvider<ComparisonNotifier, List<City>>(ComparisonNotifier.new);
Why use a Notifier for the comparison list?
ComparisonNotifier holds a simple List<City>. Each mutation (add, remove, clear) creates a new list. Riverpod detects the new reference and rebuilds any widget watching it.
The addCity method checks for duplicates using the == operator you defined on City (which compares by id).
- Below comparisonCitiesProvider, add the .family provider and the derived summary provider:
final cityWeatherProvider =
FutureProvider.autoDispose.family<CurrentWeather, City>((ref, city) async {
final unit = ref.watch(
settingsProvider.select((s) => s.temperatureUnit),
);
final repo = ref.read(weatherRepositoryProvider);
return repo.getCurrentWeather(
latitude: city.latitude,
longitude: city.longitude,
temperatureUnit: unit,
);
});
What does .family do?
FutureProvider.autoDispose.family creates a separate provider instance for each unique City you pass as a parameter. When you call ref.watch(cityWeatherProvider(tokyo)) and ref.watch(cityWeatherProvider(paris)), each triggers its own independent API call.
The .autoDispose modifier cleans up each instance when its widget is removed. Removing a city from the comparison list disposes that city's weather provider.
- Add the derived comparisonSummaryProvider below cityWeatherProvider:
final comparisonSummaryProvider = Provider<String>((ref) {
final cities = ref.watch(comparisonCitiesProvider);
if (cities.length < 2) return '';
double? warmest;
double? coldest;
String warmestName = '';
String coldestName = '';
for (final city in cities) {
final weather = ref.watch(cityWeatherProvider(city));
weather.whenData((data) {
if (warmest == null || data.temperature > warmest!) {
warmest = data.temperature;
warmestName = city.name;
}
if (coldest == null || data.temperature < coldest!) {
coldest = data.temperature;
coldestName = city.name;
}
});
}
if (warmest == null || coldest == null) return 'Loading comparison...';
return '$warmestName is the warmest, $coldestName is the coldest';
});
What is a derived provider?
comparisonSummaryProvider does not fetch any data itself. It watches comparisonCitiesProvider and each city's cityWeatherProvider, then computes a summary string from the results. Riverpod rebuilds it automatically whenever any upstream provider changes.
- Save comparison_provider.dart.
Now replace the placeholder Comparison screen with the real implementation.
- Open lib/screens/comparison_screen.dart and replace its entire contents. Start with the imports, class declaration, and the main layout:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sky_scope/core/weather_utils.dart';
import 'package:sky_scope/providers/comparison_provider.dart';
import 'package:sky_scope/providers/settings_provider.dart';
import 'package:sky_scope/widgets/city_search_bar.dart';
class ComparisonScreen extends ConsumerWidget {
const ComparisonScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cities = ref.watch(comparisonCitiesProvider);
final summary = ref.watch(comparisonSummaryProvider);
final unitSymbol = ref.watch(
settingsProvider.select((s) => s.unitSymbol),
);
final theme = Theme.of(context);
- Continue with the scroll view, search bar, and summary card:
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'City Comparison',
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 16),
CitySearchBar(
onCitySelected: (city) {
ref.read(comparisonCitiesProvider.notifier).addCity(city);
},
),
const SizedBox(height: 16),
if (summary.isNotEmpty)
Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
Icons.insights,
- Continue with the rest of the summary card, the empty state, and the city list:
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
summary,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
),
],
),
),
),
if (cities.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.compare_arrows,
size: 64,
color: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
- Finish the empty state text, city cards list, and the clear all button:
Text(
'Add cities to compare their weather',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Search above and tap a city to add it',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
...cities.map((city) => _ComparisonCard(
city: city,
unitSymbol: unitSymbol,
)),
if (cities.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: TextButton.icon(
onPressed: () =>
ref.read(comparisonCitiesProvider.notifier).clear(),
icon: const Icon(Icons.clear_all),
label: const Text('Clear all'),
),
),
- Close the ComparisonScreen class and add the _ComparisonCard widget:
],
),
),
),
);
}
}
class _ComparisonCard extends ConsumerWidget {
final dynamic city;
final String unitSymbol;
const _ComparisonCard({
required this.city,
required this.unitSymbol,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final weatherAsync = ref.watch(cityWeatherProvider(city));
final theme = Theme.of(context);
- Add the card body with the weather data display:
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: weatherAsync.when(
data: (weather) => Row(
children: [
Icon(
weatherIcon(weather.weatherCode),
size: 40,
color: weatherColor(weather.weatherCode),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
city.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'${city.country ?? ""}',
style: theme.textTheme.bodySmall,
),
],
),
),
- Finish with the temperature display, remove button, loading/error states, and close the class:
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${weather.temperature.round()}\u00B0$unitSymbol',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
weatherDescription(weather.weatherCode),
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => ref
.read(comparisonCitiesProvider.notifier)
.removeCity(city),
),
],
),
- Add the loading and error states to close the file:
loading: () => const Padding(
padding: EdgeInsets.all(8),
child: Center(child: CircularProgressIndicator()),
),
error: (err, _) => Row(
children: [
Expanded(
child: Text('Failed to load ${city.name}: $err'),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () =>
ref.invalidate(cityWeatherProvider(city)),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => ref
.read(comparisonCitiesProvider.notifier)
.removeCity(city),
),
],
),
),
),
);
}
}
- Save comparison_screen.dart.
- Hot restart the app and click the Compare tab.
You should see the empty state with "Add cities to compare their weather." Type "Tokyo" in the search bar, tap it, then search and add "Paris." Both cities appear as cards with their current temperature, weather icon, and description.
A summary card at the top tells you which city is warmest and which is coldest.
Cities not appearing after search?
Make sure the CitySearchBar has the onCitySelected callback wired up. In the Comparison screen it should call ref.read(comparisonCitiesProvider.notifier).addCity(city).
Why aren't comparison cities showing up?
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files before the final verification.
ⓧ I'd like to double check the full code
Scroll up and confirm each section is present in order.
- comparison_provider.dart: ComparisonNotifier with addCity/removeCity/clear, comparisonCitiesProvider, cityWeatherProvider as FutureProvider.autoDispose.family, comparisonSummaryProvider as derived Provider.
- comparison_screen.dart: ComparisonScreen ConsumerWidget with CitySearchBar (onCitySelected), summary card, empty state, city cards list, clear all button. _ComparisonCard ConsumerWidget watching cityWeatherProvider(city) with data/loading/error states and a remove button.
Verify shared state across all pages
All four screens now share state through Riverpod providers. The selected city, theme mode, temperature unit, and comparison list flow across every page. Time to prove it.
- On the Dashboard, search for a city like "London" and select it.
- Click the Forecast tab. The heading should read "Forecast for London" and the chart should show London's hourly temperatures.
Before you test the next check, what do you think will happen to the temperatures on every page when you switch the unit to Fahrenheit?
- Navigate to Settings and toggle Fahrenheit on.
- Go back to Dashboard. The temperature should now display in Fahrenheit.
- Click Forecast. The chart and daily cards should show Fahrenheit values.
- Click Compare, add two cities, and confirm they also display in Fahrenheit.
Every page refetches with the new unit because each provider uses settingsProvider.select((s) => s.temperatureUnit). The select call ensures that toggling the theme does not trigger unnecessary weather refetches.
- On the Comparison page, tap the close icon on one of your compared cities. The card should disappear immediately.
- Check the browser URL bar. It should read /compare while you are on the Comparison page.
Why does select matter here?
Without select, watching the entire settingsProvider would mean every theme toggle triggers a weather API call. With select((s) => s.temperatureUnit), Riverpod only rebuilds the provider when temperatureUnit actually changes.
All four screens are fully functional with live data, shared state, and responsive navigation. Next up, you can polish the app with skeleton loading and page transitions in the Secret Mission.
Secret mission
Polish with Skeleton Loading and Page Transitions
Your dashboard works, but it still shows a plain spinning indicator while data loads and jumps between pages with no animation. In this mission, you will create a shimmer skeleton loader that mirrors the weather card layout and add smooth fade transitions between every page.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses the Open-Meteo API, which requires no API key and no paid account. There are no ongoing costs to worry about.
Decide whether to keep your project files to continue building on, or delete them entirely.
Resources you used:
- The sky_scope project folder on your Desktop, containing all Dart source files, assets, and Flutter build output.
- Flutter package dependencies (flutter_riverpod, go_router, http, fl_chart) cached locally by Flutter's package manager.
Keep everything running
No action needed. Choose this if you are still actively building on SkyScope or want to revisit it later.
- Your sky_scope folder stays on your Desktop, ready to open and run anytime with flutter run -d chrome.
- Flutter's cached packages remain in your system's pub cache. They take minimal disk space and are shared across all your Flutter projects.
Pause - I'll come back to this later
Since this project runs locally with no servers or cloud resources, there is nothing to pause. Your files sit quietly on disk until you are ready to return.
- Make sure you have saved all open files in your editor.
- If the Flutter dev server is still running in your terminal, press Ctrl+C to stop it.
- When you come back, open the sky_scope folder in your editor and run flutter run -d chrome to pick up where you left off.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- If the Flutter dev server is still running in your terminal, press Ctrl+C to stop it.
- Delete the project folder by running one of these commands from your Desktop:
macOS
cd ~/Desktop
rm -rf sky_scope
Windows
cd ~\Desktop
rmdir /s /q sky_scope
- Confirm the folder is gone by running:
ls
You should no longer see sky_scope in the output.
Nice Work!
Nice Work!
You just built SkyScope, a fully functional multi-page weather intelligence dashboard running in your browser. It pulls live data from the Open-Meteo API with zero API keys, searches cities worldwide, charts hourly forecasts, compares weather across cities side by side, and adapts its layout to any screen size.
You've learned how to:
- Built a complete Flutter Web dashboard with four screens and integrated the Open-Meteo REST API for live current conditions, hourly forecasts, and 7-day predictions.
- Mastered advanced Riverpod patterns including AsyncNotifierProvider for API calls, .family providers for per-city weather, debounced search with cancellation, derived providers, ref.listen for side effects, and select for granular rebuilds.
- Implemented go_router with ShellRoute for browser URL support and built a responsive navigation shell that switches between a sidebar and bottom bar based on screen width, plus an interactive temperature line chart with fl_chart.
- Secret Mission: Added shimmer skeleton loading and animated page transitions for production polish.
Ready to quiz yourself?