This guide explains how to add a new weather station data provider to The Paragliding App.
The weather station system uses a provider-based architecture that allows multiple weather data sources to work together:
WeatherStationService
↓ (coordinates all providers)
WeatherStationProviderRegistry
↓ (manages available providers)
WeatherStationProvider (interface)
↓ (implemented by each source)
├─ AviationWeatherCenterProvider
├─ NwsWeatherProvider
└─ YourNewProvider
File: lib/data/models/weather_station_source.dart
Add your provider identifier:
enum WeatherStationSource {
awcMetar,
nws,
yourProvider, // Add here
}
File: lib/services/weather_providers/weather_station_provider.dart
All providers must implement this interface:
abstract class WeatherStationProvider {
// Identification
WeatherStationSource get source;
String get displayName; // "Your Provider Name"
String get description; // Short description for UI
String get attributionName; // For About screen
String get attributionUrl; // Provider website
// Configuration
bool get requiresApiKey;
Duration get cacheTTL;
Future<bool> isConfigured();
// Data fetching
Future<List<WeatherStation>> fetchStations(LatLngBounds bounds);
Future<Map<String, WindData>> fetchWeatherData(List<WeatherStation> stations);
// Cache management
void clearCache();
Map<String, dynamic> getCacheStats();
}
File: lib/services/weather_providers/your_provider_weather_provider.dart
class YourProviderWeatherProvider implements WeatherStationProvider {
static final YourProviderWeatherProvider instance = YourProviderWeatherProvider._();
YourProviderWeatherProvider._();
static const String _baseUrl = 'https://api.yourprovider.com';
@override
WeatherStationSource get source => WeatherStationSource.yourProvider;
@override
String get displayName => 'Your Provider';
@override
String get description => 'Description shown in UI';
@override
String get attributionName => 'Your Provider Inc.';
@override
String get attributionUrl => 'https://yourprovider.com/';
@override
Duration get cacheTTL => MapConstants.weatherStationCacheTTL;
@override
bool get requiresApiKey => true; // or false
@override
Future<bool> isConfigured() async {
// Check if API key is configured (if required)
return true;
}
@override
Future<List<WeatherStation>> fetchStations(LatLngBounds bounds) async {
// Fetch station list from API
// Parse response
// Return List<WeatherStation>
}
@override
Future<Map<String, WindData>> fetchWeatherData(
List<WeatherStation> stations,
) async {
// Fetch weather observations for stations
// Return Map<stationKey, WindData>
}
@override
void clearCache() {
// Clear any cached data
}
@override
Map<String, dynamic> getCacheStats() {
return {
'total_cache_entries': 0,
'valid_cache_entries': 0,
};
}
}
File: lib/services/weather_providers/weather_station_provider.dart
Add to the registry:
class WeatherStationProviderRegistry {
static final Map<WeatherStationSource, WeatherStationProvider> _providers = {
WeatherStationSource.awcMetar: AviationWeatherCenterProvider.instance,
WeatherStationSource.nws: NwsWeatherProvider.instance,
WeatherStationSource.yourProvider: YourProviderWeatherProvider.instance, // Add here
};
// ... rest of registry code
}
1. User views map at zoom ≥ 10
2. WeatherStationService.getStationsInBounds() called
3. Service checks which providers are enabled (via SharedPreferences)
4. Service calls fetchStations() on each enabled provider in parallel
5. Results are combined and deduplicated
6. Stations displayed as markers on map
1. WeatherStationService.getWeatherForStations() called
2. Stations grouped by source
3. Each provider's fetchWeatherData() called with its stations
4. Results combined into Map<stationKey, WindData>
5. Wind data displayed on markers
When adding a new provider, you must update both the dialog and parent screen to properly enable/disable it:
Step 1: Add to map_filter_dialog.dart
const MapFilterDialog({
required this.metarEnabled,
required this.nwsEnabled,
required this.yourProviderEnabled, // Add here
// ... other parameters
});
class _MapFilterDialogState extends State<MapFilterDialog> {
late bool _metarEnabled;
late bool _nwsEnabled;
late bool _yourProviderEnabled; // Add here
initState():
_yourProviderEnabled = widget.yourProviderEnabled;
_buildProviderCheckbox(
value: _yourProviderEnabled,
label: 'Your Provider Name',
subtitle: WeatherStationProviderRegistry.getProvider(
WeatherStationSource.yourProvider
).description,
onChanged: _weatherStationsEnabled ? (value) => setState(() {
_yourProviderEnabled = value ?? true;
_applyFiltersImmediately();
}) : null,
),
onApply callback signature:
widget.onApply(
_sitesEnabled,
_airspaceEnabled,
_forecastEnabled,
_weatherStationsEnabled,
_metarEnabled,
_nwsEnabled,
_yourProviderEnabled, // Add here
_airspaceTypes,
_icaoClasses,
_maxAltitudeFt,
_clippingEnabled,
);
Step 2: Update nearby_sites_screen.dart
bool _yourProviderEnabled = true; // Default: true
initState():
final yourProviderEnabled = prefs.getBool(
'weather_provider_${WeatherStationSource.yourProvider.name}_enabled'
) ?? true;
initState():
_yourProviderEnabled = yourProviderEnabled;
_DraggableFilterDialog(
metarEnabled: _metarEnabled,
nwsEnabled: _nwsEnabled,
yourProviderEnabled: _yourProviderEnabled, // Add here
// ... other parameters
)
_handleFilterApply():
```dart
void _handleFilterApply(
bool sitesEnabled,
bool airspaceEnabled,
bool forecastEnabled,
bool weatherStationsEnabled,
bool metarEnabled,
bool nwsEnabled,
bool yourProviderEnabled, // Add here
// … other parameters
) async {
// Track previous state
final previousYourProviderEnabled = _yourProviderEnabled;// Update state setState(() { _yourProviderEnabled = yourProviderEnabled; });
// Save to preferences await prefs.setBool( ‘weather_provider_${WeatherStationSource.yourProvider.name}_enabled’, yourProviderEnabled );
// Handle provider changes if (weatherStationsEnabled && yourProviderEnabled != previousYourProviderEnabled) { WeatherStationService.instance.clearCache(); _fetchWeatherStations(); } }
6. Update loading overlay to filter disabled providers:
```dart
...WeatherStationProviderRegistry.getAllSources()
.where((source) {
// Only show enabled providers in loading overlay
if (source == WeatherStationSource.awcMetar) return _metarEnabled;
if (source == WeatherStationSource.nws) return _nwsEnabled;
if (source == WeatherStationSource.yourProvider) return _yourProviderEnabled;
return false;
})
.map((source) {
// ... create MapLoadingItem
})
Step 3: Update _DraggableFilterDialog wrapper
Add parameter to the wrapper widget in nearby_sites_screen.dart:
class _DraggableFilterDialog extends StatefulWidget {
final bool yourProviderEnabled; // Add here
const _DraggableFilterDialog({
required this.yourProviderEnabled, // Add here
// ... other parameters
});
}
_applyFiltersImmediately() called_yourProviderEnabled changesweather_provider_yourProvider_enabled keyWeatherStationService.getStationsInBounds() called_getEnabledProviders() checks yourProvider_enabledenabledProviders listWeatherStationSourceWeatherStationProvider interface methodsWeatherStationProviderRegistryUser-Agent: TheParaglidingApp/1.0attributionName/attributionUrl)Choose the appropriate strategy based on your provider’s network size and API characteristics:
Best for: Large networks (1000s+ stations), bbox-based APIs
// Cache key includes bounding box
final cacheKey = '${bounds.west.toStringAsFixed(1)},${bounds.south.toStringAsFixed(1)},'
'${bounds.east.toStringAsFixed(1)},${bounds.north.toStringAsFixed(1)}';
// Cache entry with timestamp
class _CacheEntry {
final List<WeatherStation> stations;
final DateTime timestamp;
final LatLngBounds bounds;
bool isExpired() =>
DateTime.now().difference(timestamp) > MapConstants.weatherStationCacheTTL;
}
// LRU cache (Least Recently Used)
final LinkedHashMap<String, _CacheEntry> _cache = LinkedHashMap();
static const int _maxCacheSize = 20; // Limit memory usage
Advantages:
Disadvantages:
Best for: Small networks (<5000 stations), global APIs
// Single global cache for ALL stations
class _GlobalCacheEntry {
final List<WeatherStation> stations;
final DateTime stationListTimestamp; // 24hr TTL
final DateTime measurementsTimestamp; // 20min TTL
bool get stationListExpired =>
DateTime.now().difference(stationListTimestamp) >
MapConstants.pioupiouStationListCacheTTL; // 24 hours
bool get measurementsExpired =>
DateTime.now().difference(measurementsTimestamp) >
MapConstants.pioupiouMeasurementsCacheTTL; // 20 minutes
}
_GlobalCacheEntry? _globalCache;
Future<List<WeatherStation>> fetchStations(LatLngBounds bounds) async {
// Check if station list cache is valid
if (_globalCache != null && !_globalCache!.stationListExpired) {
// Refresh measurements if stale
if (_globalCache!.measurementsExpired) {
await _refreshMeasurements();
}
// Filter in-memory to bbox
return _filterStationsToBounds(_globalCache!.stations, bounds);
}
// Fetch all stations from API
final stations = await _fetchAllStations();
_globalCache = _GlobalCacheEntry(
stations: stations,
stationListTimestamp: DateTime.now(),
measurementsTimestamp: DateTime.now(),
);
return _filterStationsToBounds(stations, bounds);
}
Advantages:
Disadvantages:
Separate TTLs for station locations vs. measurements:
// Station locations: Rarely change (24hr TTL)
static const Duration pioupiouStationListCacheTTL = Duration(hours: 24);
// Wind measurements: Update frequently (20min TTL)
static const Duration pioupiouMeasurementsCacheTTL = Duration(minutes: 20);
Future<void> _refreshMeasurements() async {
final stations = await _fetchAllStations();
if (stations.isNotEmpty && _globalCache != null) {
// Keep original station list timestamp
_globalCache = _GlobalCacheEntry(
stations: stations,
stationListTimestamp: _globalCache!.stationListTimestamp, // Keep old
measurementsTimestamp: DateTime.now(), // Update new
);
}
}
| Provider | Strategy | Network Size | Cache TTL | Memory Usage | API Calls/Hour |
|---|---|---|---|---|---|
| Aviation Weather Center | Bbox | ~10,000 stations | 30 min | Low (~100KB) | High (varies) |
| NWS | Bbox + Grid | ~2,000 stations | Station: 24hr Obs: 10min |
Medium (~200KB) | Medium (1-2) |
| Pioupiou | Global | ~1,000 stations | List: 24hr Data: 20min |
Medium (~500KB) | Low (1-3) |
// Station metadata (lat/lon, name, ID) - Changes rarely
Duration.hours(24) // Global networks (Pioupiou)
Duration.hours(1) // Regional networks (NWS)
Duration.minutes(30) // Large networks (Aviation Weather Center)
// Wind measurements - Updates frequently
Duration.minutes(5) // Real-time critical
Duration.minutes(10) // Standard updates (NWS)
Duration.minutes(20) // Less frequent updates (Pioupiou)
Duration.minutes(30) // Slow-updating networks (Aviation Weather Center)
User-Agent: TheParaglidingApp/1.0Enable debug logging in LoggingService to see:
[WEATHER_STATION_FETCH_START] - Provider fetch initiated[WEATHER_STATION_FETCH_COMPLETE] - Station count by provider[STATION_FETCH_SUCCESS] - Total stations with data[AWC_METAR_API_REQUEST], [NWS_OBSERVATION_SUCCESS])Provider settings are stored in SharedPreferences:
// Enable/disable provider
final key = 'weather_provider_${source.name}_enabled';
await prefs.setBool(key, enabled);
// API key (if required)
final apiKeyKey = 'weather_provider_${source.name}_api_key';
await prefs.setString(apiKeyKey, apiKey);
lib/services/weather_providers/weather_station_provider.dartlib/services/weather_station_service.dartlib/data/models/weather_station.dart, lib/data/models/wind_data.dartlib/presentation/widgets/map_filter_dialog.dartlib/presentation/widgets/weather_station_marker.dartlib/services/weather_providers/aviation_weather_center_provider.dart, lib/services/weather_providers/nws_weather_provider.dartFor questions or issues, check:
Get observations for a specific station:
curl -s "https://aviationweather.gov/api/data/metar?ids=KSNS&format=json" \
-H "Accept: application/json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Get observations for multiple stations:
curl -s "https://aviationweather.gov/api/data/metar?ids=KSNS,KSFO,KOAK&format=json" \
-H "Accept: application/json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Get observations in a bounding box:
# Format: bbox=south,west,north,east
curl -s "https://aviationweather.gov/api/data/metar?bbox=36.5,-122,37,-121&format=json" \
-H "Accept: application/json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Response format: JSON array with wind speed in knots (wspd), direction in degrees (wdir)
Get station metadata:
curl -s "https://api.weather.gov/stations/KSNS" \
-H "Accept: application/geo+json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Get latest observation:
curl -s "https://api.weather.gov/stations/KSNS/observations/latest" \
-H "Accept: application/geo+json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Response format: GeoJSON with wind speed already in km/h (unitCode: "wmoUnit:km_h-1"), direction in degrees
Note: NWS API is US-only. International coordinates return 404.
Get all stations globally with latest measurements:
curl -s "http://api.pioupiou.fr/v1/live-with-meta/all" \
-H "Accept: application/json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Get metadata for a specific station:
# Station ID: 1701 (Tuniberg, Germany)
curl -s "http://api.pioupiou.fr/v1/stations/1701" \
-H "Accept: application/json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Get latest measurement for a specific station:
# Station ID: 1701
curl -s "http://api.pioupiou.fr/v1/live/1701" \
-H "Accept: application/json" \
-H "User-Agent: TheParaglidingApp/1.0" | python3 -m json.tool
Example Response (live-with-meta/all):
{
"data": [
{
"id": 1701,
"location": {
"latitude": 47.967,
"longitude": 7.766,
"date": "2024-10-21T09:30:00.000Z"
},
"meta": {
"name": "Tuniberg"
},
"status": {
"state": "on",
"date": "2025-01-15T10:23:45.000Z"
},
"measurements": {
"wind_heading": 270,
"wind_speed_avg": 1.75,
"wind_speed_max": 3.75,
"date": "2025-01-15T10:23:00.000Z"
}
}
]
}
Response format:
wind_speed_avg, wind_speed_max)wind_heading)measurements.datestatus.state == "on" to verify station is onlineThe US national weather service