feat(train): workouts' weather from Open-Meteo, through the instance (ADR 0036)

A workout with a track gets the weather over it as its page opens, once
while it has none: the hours from the one it started in to the one it
ended in, kept in its sealed payload (temperature, feels-like, humidity,
precipitation, the WMO code, the wind, its gusts and direction) and shown
under its totals with the link to Open-Meteo its licence asks for.

Train's server asks Open-Meteo in the person's place (the `weather`
endpoint), so Open-Meteo never sees their address: the place rounded to
two decimals (also on the phone), a span of at most 48 hours, the
forecast API for recent spans and the archive for older ones, for people
signed in, at most 30 requests a person a minute, nothing kept or
logged. On by default; `WEATHER=off` switches it off, and
`OPEN_METEO_API_KEY` moves it to the paid hosts for an instance used
commercially.

Health Connect's fingerprint leaves the weather out, so keeping it
writes nothing again. No table and no migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 13:59:41 +01:00
commit 3f7b409e80
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 2503 additions and 7 deletions

View file

@ -707,5 +707,18 @@
"strengthNextUp": "Als Nächstes: {exercise}",
"strengthBarAndPlates": "{bar}-kg-Stange, jede Seite: {plates}",
"strengthHandlesAndPlates": "{bar}-kg-Griffe, jede Seite: {plates}",
"strengthWeightShort": "{weight} kg unter dem Plan"
"strengthWeightShort": "{weight} kg unter dem Plan",
"weatherHeading": "Wetter",
"weatherConditions": "{conditions, select, clear{Klar} partlyCloudy{Teils bewölkt} cloudy{Bedeckt} fog{Nebel} drizzle{Nieselregen} rain{Regen} freezingRain{Gefrierender Regen} snow{Schnee} rainShowers{Schauer} snowShowers{Schneeschauer} thunderstorm{Gewitter} other{Wetter}}",
"weatherTemperature": "{temperature} °C",
"weatherTemperatureRange": "{low}–{high} °C",
"weatherFeelsLike": "Gefühlt {temperature} °C",
"weatherWind": "Wind {speed} km/h aus {direction}",
"weatherWindNoDirection": "Wind {speed} km/h",
"weatherCalm": "Kaum Wind",
"weatherGusts": "Böen bis {speed} km/h",
"weatherDirection": "{direction, select, n{Nord} ne{Nordost} e{Ost} se{Südost} s{Süd} sw{Südwest} w{West} nw{Nordwest} other{Wind}}",
"weatherPrecipitation": "{amount} mm Niederschlag",
"weatherDry": "Trocken",
"weatherBy": "Wetterdaten von Open-Meteo.com"
}

View file

@ -890,5 +890,27 @@
"strengthHandlesAndPlates": "{bar} kg handles, each side: {plates}",
"@strengthHandlesAndPlates": {"placeholders": {"bar": {"type": "String"}, "plates": {"type": "String"}}},
"strengthWeightShort": "{weight} kg below what was planned",
"@strengthWeightShort": {"placeholders": {"weight": {"type": "String"}}}
"@strengthWeightShort": {"placeholders": {"weight": {"type": "String"}}},
"weatherHeading": "Weather",
"weatherConditions": "{conditions, select, clear{Clear} partlyCloudy{Partly cloudy} cloudy{Overcast} fog{Fog} drizzle{Drizzle} rain{Rain} freezingRain{Freezing rain} snow{Snow} rainShowers{Showers} snowShowers{Snow showers} thunderstorm{Thunderstorm} other{Weather}}",
"@weatherConditions": {"placeholders": {"conditions": {"type": "String"}}, "description": "What the sky did over a workout, from Open-Meteo's weather code."},
"weatherTemperature": "{temperature} °C",
"@weatherTemperature": {"placeholders": {"temperature": {"type": "String"}}},
"weatherTemperatureRange": "{low}–{high} °C",
"@weatherTemperatureRange": {"placeholders": {"low": {"type": "String"}, "high": {"type": "String"}}},
"weatherFeelsLike": "Felt like {temperature} °C",
"@weatherFeelsLike": {"placeholders": {"temperature": {"type": "String"}}},
"weatherWind": "Wind {speed} km/h from the {direction}",
"@weatherWind": {"placeholders": {"speed": {"type": "String"}, "direction": {"type": "String"}}},
"weatherWindNoDirection": "Wind {speed} km/h",
"@weatherWindNoDirection": {"placeholders": {"speed": {"type": "String"}}},
"weatherCalm": "Hardly any wind",
"weatherGusts": "gusts up to {speed} km/h",
"@weatherGusts": {"placeholders": {"speed": {"type": "String"}}},
"weatherDirection": "{direction, select, n{north} ne{north-east} e{east} se{south-east} s{south} sw{south-west} w{west} nw{north-west} other{wind}}",
"@weatherDirection": {"placeholders": {"direction": {"type": "String"}}, "description": "Where the wind came from, as in \"from the south-west\"."},
"weatherPrecipitation": "{amount} mm fell",
"@weatherPrecipitation": {"placeholders": {"amount": {"type": "String"}}},
"weatherDry": "Nothing fell",
"weatherBy": "Weather data by Open-Meteo.com"
}

View file

@ -4257,6 +4257,84 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{weight} kg below what was planned'**
String strengthWeightShort(String weight);
/// No description provided for @weatherHeading.
///
/// In en, this message translates to:
/// **'Weather'**
String get weatherHeading;
/// What the sky did over a workout, from Open-Meteo's weather code.
///
/// In en, this message translates to:
/// **'{conditions, select, clear{Clear} partlyCloudy{Partly cloudy} cloudy{Overcast} fog{Fog} drizzle{Drizzle} rain{Rain} freezingRain{Freezing rain} snow{Snow} rainShowers{Showers} snowShowers{Snow showers} thunderstorm{Thunderstorm} other{Weather}}'**
String weatherConditions(String conditions);
/// No description provided for @weatherTemperature.
///
/// In en, this message translates to:
/// **'{temperature} °C'**
String weatherTemperature(String temperature);
/// No description provided for @weatherTemperatureRange.
///
/// In en, this message translates to:
/// **'{low}–{high} °C'**
String weatherTemperatureRange(String low, String high);
/// No description provided for @weatherFeelsLike.
///
/// In en, this message translates to:
/// **'Felt like {temperature} °C'**
String weatherFeelsLike(String temperature);
/// No description provided for @weatherWind.
///
/// In en, this message translates to:
/// **'Wind {speed} km/h from the {direction}'**
String weatherWind(String speed, String direction);
/// No description provided for @weatherWindNoDirection.
///
/// In en, this message translates to:
/// **'Wind {speed} km/h'**
String weatherWindNoDirection(String speed);
/// No description provided for @weatherCalm.
///
/// In en, this message translates to:
/// **'Hardly any wind'**
String get weatherCalm;
/// No description provided for @weatherGusts.
///
/// In en, this message translates to:
/// **'gusts up to {speed} km/h'**
String weatherGusts(String speed);
/// Where the wind came from, as in "from the south-west".
///
/// In en, this message translates to:
/// **'{direction, select, n{north} ne{north-east} e{east} se{south-east} s{south} sw{south-west} w{west} nw{north-west} other{wind}}'**
String weatherDirection(String direction);
/// No description provided for @weatherPrecipitation.
///
/// In en, this message translates to:
/// **'{amount} mm fell'**
String weatherPrecipitation(String amount);
/// No description provided for @weatherDry.
///
/// In en, this message translates to:
/// **'Nothing fell'**
String get weatherDry;
/// No description provided for @weatherBy.
///
/// In en, this message translates to:
/// **'Weather data by Open-Meteo.com'**
String get weatherBy;
}
class _AppLocalizationsDelegate

View file

@ -2735,4 +2735,92 @@ class AppLocalizationsDe extends AppLocalizations {
String strengthWeightShort(String weight) {
return '$weight kg unter dem Plan';
}
@override
String get weatherHeading => 'Wetter';
@override
String weatherConditions(String conditions) {
String _temp0 = intl.Intl.selectLogic(
conditions,
{
'clear': 'Klar',
'partlyCloudy': 'Teils bewölkt',
'cloudy': 'Bedeckt',
'fog': 'Nebel',
'drizzle': 'Nieselregen',
'rain': 'Regen',
'freezingRain': 'Gefrierender Regen',
'snow': 'Schnee',
'rainShowers': 'Schauer',
'snowShowers': 'Schneeschauer',
'thunderstorm': 'Gewitter',
'other': 'Wetter',
},
);
return '$_temp0';
}
@override
String weatherTemperature(String temperature) {
return '$temperature °C';
}
@override
String weatherTemperatureRange(String low, String high) {
return '$low–$high °C';
}
@override
String weatherFeelsLike(String temperature) {
return 'Gefühlt $temperature °C';
}
@override
String weatherWind(String speed, String direction) {
return 'Wind $speed km/h aus $direction';
}
@override
String weatherWindNoDirection(String speed) {
return 'Wind $speed km/h';
}
@override
String get weatherCalm => 'Kaum Wind';
@override
String weatherGusts(String speed) {
return 'Böen bis $speed km/h';
}
@override
String weatherDirection(String direction) {
String _temp0 = intl.Intl.selectLogic(
direction,
{
'n': 'Nord',
'ne': 'Nordost',
'e': 'Ost',
'se': 'Südost',
's': 'Süd',
'sw': 'Südwest',
'w': 'West',
'nw': 'Nordwest',
'other': 'Wind',
},
);
return '$_temp0';
}
@override
String weatherPrecipitation(String amount) {
return '$amount mm Niederschlag';
}
@override
String get weatherDry => 'Trocken';
@override
String get weatherBy => 'Wetterdaten von Open-Meteo.com';
}

View file

@ -2722,6 +2722,94 @@ class AppLocalizationsEn extends AppLocalizations {
String strengthWeightShort(String weight) {
return '$weight kg below what was planned';
}
@override
String get weatherHeading => 'Weather';
@override
String weatherConditions(String conditions) {
String _temp0 = intl.Intl.selectLogic(
conditions,
{
'clear': 'Clear',
'partlyCloudy': 'Partly cloudy',
'cloudy': 'Overcast',
'fog': 'Fog',
'drizzle': 'Drizzle',
'rain': 'Rain',
'freezingRain': 'Freezing rain',
'snow': 'Snow',
'rainShowers': 'Showers',
'snowShowers': 'Snow showers',
'thunderstorm': 'Thunderstorm',
'other': 'Weather',
},
);
return '$_temp0';
}
@override
String weatherTemperature(String temperature) {
return '$temperature °C';
}
@override
String weatherTemperatureRange(String low, String high) {
return '$low–$high °C';
}
@override
String weatherFeelsLike(String temperature) {
return 'Felt like $temperature °C';
}
@override
String weatherWind(String speed, String direction) {
return 'Wind $speed km/h from the $direction';
}
@override
String weatherWindNoDirection(String speed) {
return 'Wind $speed km/h';
}
@override
String get weatherCalm => 'Hardly any wind';
@override
String weatherGusts(String speed) {
return 'gusts up to $speed km/h';
}
@override
String weatherDirection(String direction) {
String _temp0 = intl.Intl.selectLogic(
direction,
{
'n': 'north',
'ne': 'north-east',
'e': 'east',
'se': 'south-east',
's': 'south',
'sw': 'south-west',
'w': 'west',
'nw': 'north-west',
'other': 'wind',
},
);
return '$_temp0';
}
@override
String weatherPrecipitation(String amount) {
return '$amount mm fell';
}
@override
String get weatherDry => 'Nothing fell';
@override
String get weatherBy => 'Weather data by Open-Meteo.com';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -43,6 +43,7 @@ import 'package:halkyon_train_flutter/src/feature/route/data/sync_route_gateway.
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/sync_strength_plan_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -107,6 +108,9 @@ class TrainPerson(
/// The instance's search for places, for the planner (D50).
required final PlaceSearchGateway placeSearch,
/// Workouts' weather through the instance (ADR 0036).
required final WeatherGateway weather,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -498,6 +502,7 @@ Future<TrainPerson> _openPersonData(
),
routing: ServerpodRoutingGateway(client),
placeSearch: ServerpodPlaceSearchGateway(client),
weather: ServerpodWeatherGateway(client),
maps: maps,
mapTiles: keptVectorTiles(offlineMaps),
offlineMaps: offlineMaps,

View file

@ -203,7 +203,11 @@ class HealthConnect(
!_settings.writes) {
continue;
}
final fingerprint = _fingerprintOf(encodeWorkout(content));
// Without the weather, which Health Connect doesn't hold (ADR 0036,
// D4): a workout's weather kept writes nothing again.
final fingerprint = _fingerprintOf(
encodeWorkout(content.withWeather(null)),
);
final before = _settings.written[workout.id];
if (before?.fingerprint == fingerprint) {
continue;

View file

@ -0,0 +1,64 @@
import 'dart:developer';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
/// No weather came: the instance refused it (switched off, too often) or
/// couldn't be asked.
final class const WeatherUnavailable() implements Exception;
/// A workout's weather through the instance (ADR 0036, D1).
abstract interface class WeatherGateway() {
/// The hours from [from] to [to] at [lat], [lon], which leave the phone
/// rounded to about a kilometre (D2); [WeatherUnavailable] where none
/// came.
Future<List<WeatherHour>> hours({
required double lat,
required double lon,
required DateTime from,
required DateTime to,
});
}
/// Degrees of latitude or longitude to two decimals, about a kilometre (D2).
double roundedDegrees(double degrees) => (degrees * 100).round() / 100;
/// [WeatherGateway] on Train's server (`weather`).
class ServerpodWeatherGateway(final protocol.Client _client)
implements WeatherGateway {
@override
Future<List<WeatherHour>> hours({
required double lat,
required double lon,
required DateTime from,
required DateTime to,
}) async {
try {
return [
for (final hour in await _client.weather.hours(
lat: roundedDegrees(lat),
lon: roundedDegrees(lon),
from: from.toUtc(),
to: to.toUtc(),
))
WeatherHour(
time: hour.time.toUtc(),
temperature: hour.temperature,
feelsLike: hour.feelsLike,
humidity: hour.humidity,
precipitation: hour.precipitation,
code: hour.code,
wind: hour.wind,
gusts: hour.gusts,
windFrom: hour.windFrom,
),
];
} on protocol.WeatherRefused catch (refused) {
log('No weather: ${refused.reason}', name: 'Weather');
throw const WeatherUnavailable();
} on Exception catch (error) {
log('No weather: ${error.runtimeType}', name: 'Weather');
throw const WeatherUnavailable();
}
}
}

View file

@ -0,0 +1,44 @@
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
/// [weather] in a workout's payload (ADR 0036, D3): each hour a map of
/// short keys, its time in UTC milliseconds, the figures it has.
Map<String, Object?> workoutWeatherJson(WorkoutWeather weather) => {
'hours': [
for (final hour in weather.hours)
{
't': hour.time.toUtc().millisecondsSinceEpoch,
'temp': ?hour.temperature,
'feels': ?hour.feelsLike,
'hum': ?hour.humidity,
'prec': ?hour.precipitation,
'code': ?hour.code,
'wind': ?hour.wind,
'gust': ?hour.gusts,
'from': ?hour.windFrom,
},
],
};
/// The weather [json] holds; null where there is none, or none readable.
WorkoutWeather? workoutWeatherOf(Object? json) {
if (json case {'hours': final List<Object?> hours}) {
double? real(Object? value) => (value as num?)?.toDouble();
final read = [
for (final hour in hours)
if (hour case {'t': final int time})
WeatherHour(
time: DateTime.fromMillisecondsSinceEpoch(time, isUtc: true),
temperature: real(hour['temp']),
feelsLike: real(hour['feels']),
humidity: hour['hum'] as int?,
precipitation: real(hour['prec']),
code: hour['code'] as int?,
wind: real(hour['wind']),
gusts: real(hour['gust']),
windFrom: hour['from'] as int?,
),
];
return read.isEmpty ? null : WorkoutWeather(read);
}
return null;
}

View file

@ -0,0 +1,119 @@
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
/// An hour's weather where a workout was (ADR 0036): the hour's start in
/// UTC, then each figure Open-Meteo had, null where it had none: °C, %, mm,
/// the WMO weather code, m/s and the degrees the wind came from.
final class const WeatherHour({
required final DateTime time,
final double? temperature,
final double? feelsLike,
final int? humidity,
final double? precipitation,
final int? code,
final double? wind,
final double? gusts,
final int? windFrom,
}) with Equatable {
@override
List<Object?> get props => [
time,
temperature,
feelsLike,
humidity,
precipitation,
code,
wind,
gusts,
windFrom,
];
}
/// What the sky did, from the WMO weather code.
enum Conditions() {
clear,
partlyCloudy,
cloudy,
fog,
drizzle,
rain,
freezingRain,
snow,
rainShowers,
snowShowers,
thunderstorm;
/// The conditions [code] names; null for one WMO doesn't define.
static Conditions? of(int code) => switch (code) {
0 => clear,
1 || 2 => partlyCloudy,
3 => cloudy,
45 || 48 => fog,
51 || 53 || 55 || 56 || 57 => drizzle,
61 || 63 || 65 => rain,
66 || 67 => freezingRain,
71 || 73 || 75 || 77 => snow,
80 || 81 || 82 => rainShowers,
85 || 86 => snowShowers,
95 || 96 || 99 => thunderstorm,
_ => null,
};
}
/// A workout's weather (ADR 0036, D3): the hours over it, from the one it
/// started in to the one it ended in.
final class const WorkoutWeather(final List<WeatherHour> hours) with Equatable {
/// The hour it started in, else the first.
WeatherHour? get _first => hours.firstOrNull;
/// The lowest and the highest temperature over it; null without any.
(double, double)? get temperatures {
final known = [for (final hour in hours) ?hour.temperature];
return known.isEmpty
? null
: (known.reduce(math.min), known.reduce(math.max));
}
/// How the start felt.
double? get feelsLike =>
_first?.feelsLike ??
hours.map((hour) => hour.feelsLike).nonNulls.firstOrNull;
/// The most notable sky over it: the highest WMO code (thunder over snow
/// over rain over drizzle over fog over clouds).
Conditions? get conditions {
final codes = [for (final hour in hours) ?hour.code];
return codes.isEmpty ? null : Conditions.of(codes.reduce(math.max));
}
/// The windiest hour's wind and where it came from, and the strongest
/// gust; null without wind.
({double wind, int? from, double? gusts})? get wind {
final windy = [
for (final hour in hours)
if (hour.wind != null) hour,
];
if (windy.isEmpty) {
return null;
}
final strongest = windy.reduce(
(a, b) => b.wind! > a.wind! ? b : a,
);
final gusts = [for (final hour in hours) ?hour.gusts];
return (
wind: strongest.wind!,
from: strongest.windFrom,
gusts: gusts.isEmpty ? null : gusts.reduce(math.max),
);
}
/// What fell over its hours, in mm; null where nothing is known.
double? get precipitation {
final known = [for (final hour in hours) ?hour.precipitation];
return known.isEmpty ? null : known.reduce((a, b) => a + b);
}
@override
List<Object?> get props => [hours];
}

View file

@ -0,0 +1,143 @@
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
/// Below this, in m/s, the wind is hardly any.
const _calm = 1.0;
/// The icon of [conditions].
IconData conditionsIcon(Conditions? conditions) => switch (conditions) {
Conditions.clear => LucideIcons.sun,
Conditions.partlyCloudy => LucideIcons.cloudSun,
Conditions.cloudy || null => LucideIcons.cloud,
Conditions.fog => LucideIcons.cloudFog,
Conditions.drizzle => LucideIcons.cloudDrizzle,
Conditions.rain || Conditions.freezingRain => LucideIcons.cloudRain,
Conditions.rainShowers => LucideIcons.cloudSunRain,
Conditions.snow || Conditions.snowShowers => LucideIcons.cloudSnow,
Conditions.thunderstorm => LucideIcons.cloudLightning,
};
/// One of eight directions for [degrees] from north: `n`, `ne`, … `nw`.
String compassPoint(int degrees) => const [
'n',
'ne',
'e',
'se',
's',
'sw',
'w',
'nw',
][((degrees % 360) / 45).round() % 8];
/// A workout's weather on its page (ADR 0036, D3): the sky, the temperature
/// and how it felt, the wind, what fell, and the link to Open-Meteo its
/// licence asks for next to its data.
class const WorkoutWeatherView(final WorkoutWeather weather, {super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context);
final whole = NumberFormat.decimalPatternDigits(
locale: locale,
decimalDigits: 0,
);
final tenths = NumberFormat.decimalPatternDigits(
locale: locale,
decimalDigits: 1,
);
String kmh(double metresPerSecond) => whole.format(metresPerSecond * 3.6);
final conditions = weather.conditions;
final temperature = switch (weather.temperatures) {
(final low, final high) when (high - low) >= 1 =>
l10n.weatherTemperatureRange(whole.format(low), whole.format(high)),
(final low, _) => l10n.weatherTemperature(whole.format(low)),
null => null,
};
final wind = switch (weather.wind) {
null => null,
(:final wind, from: _, gusts: _) when wind < _calm => l10n.weatherCalm,
(:final wind, :final from, :final gusts) => [
if (from == null)
l10n.weatherWindNoDirection(kmh(wind))
else
l10n.weatherWind(
kmh(wind),
l10n.weatherDirection(compassPoint(from)),
),
if (gusts != null && gusts > wind) l10n.weatherGusts(kmh(gusts)),
].join(', '),
};
final fell = switch (weather.precipitation) {
null => null,
final amount when amount < 0.05 => l10n.weatherDry,
final amount => l10n.weatherPrecipitation(tenths.format(amount)),
};
final muted = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
Widget line(IconData icon, String text) => Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
),
);
return SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(conditionsIcon(conditions), size: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
[
if (conditions != null)
l10n.weatherConditions(conditions.name),
?temperature,
].join(' · '),
style: theme.textTheme.titleMedium,
),
if (weather.feelsLike case final feelsLike?)
Text(
l10n.weatherFeelsLike(whole.format(feelsLike)),
style: muted,
),
],
),
),
],
),
if (wind != null) line(LucideIcons.wind, wind),
if (fell != null) line(LucideIcons.droplets, fell),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
style: TextButton.styleFrom(padding: EdgeInsets.zero),
icon: const Icon(LucideIcons.externalLink, size: 16),
label: Text(l10n.weatherBy),
onPressed: () => launchUrl(
Uri.https('open-meteo.com'),
mode: LaunchMode.externalApplication,
),
),
),
],
),
);
}
}

View file

@ -5,6 +5,9 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_export.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
@ -12,10 +15,12 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// One workout's page (ADR 0030, D24): the workout and what its series show,
/// read again whenever the workouts change, and the instance's map source
/// where it has a track to show; the workout as a file (D49).
/// where it has a track to show; the workout as a file (D49); its weather
/// asked for once where it has a track and none yet (ADR 0036, D4).
class WorkoutCubit(
final WorkoutRepository _workouts,
final MapSourceRepository _maps,
final WeatherGateway _weather,
/// Which workout.
final String id,
@ -23,6 +28,7 @@ class WorkoutCubit(
this : super(const WorkoutState());
StreamSubscription<void>? _changes;
var _weatherAsked = false;
/// Reads the workout, and follows it.
Future<void> open() async {
@ -82,6 +88,13 @@ class WorkoutCubit(
map: state.map,
),
);
if (content.weather == null && !_weatherAsked) {
if (analysis.track.expand((piece) => piece).firstOrNull
case final start?) {
_weatherAsked = true;
unawaited(_askWeather(content, start));
}
}
if (analysis.track.isNotEmpty && state.map == null) {
final source = await _maps.source();
if (!isClosed) {
@ -94,6 +107,31 @@ class WorkoutCubit(
}
}
/// Asks for the weather where [start] was over [content]'s span, and
/// keeps it with the workout; none came: asked again on the next opening.
Future<void> _askWeather(WorkoutContent content, Fix start) async {
final List<WeatherHour> hours;
try {
hours = await _weather.hours(
lat: start.latE7 / 1e7,
lon: start.lonE7 / 1e7,
from: content.startedAt,
to: content.endedAt,
);
} on WeatherUnavailable {
return;
}
if (hours.isEmpty || isClosed) {
return;
}
// Read again: it may have been renamed meanwhile.
final current = state.content;
if (current == null || current.weather != null) {
return;
}
await _workouts.update(id, current.withWeather(WorkoutWeather(hours)));
}
@override
Future<void> close() async {
await _changes?.cancel();

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_payload.dart';
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The schema of a workout's payload this app writes and reads.
@ -50,6 +51,10 @@ ByteData encodeWorkout(WorkoutContent content) => ByteData.sublistView(
// that doesn't know the activity skips the workout whole.
if (content.strength case final strength?)
'strength': workoutStrengthJson(strength),
// The weather over it (ADR 0036, D3), within schema 1: an older app
// leaves it out.
if (content.weather case final weather?)
'weather': workoutWeatherJson(weather),
}),
),
),
@ -111,6 +116,7 @@ WorkoutContent? decodeWorkout(ByteData payload) {
_ => null,
},
strength: workoutStrengthOf(json['strength']),
weather: workoutWeatherOf(json['weather']),
);
} on Object catch (error) {
log('A workout not read: ${error.runtimeType}', name: 'Workouts');

View file

@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_done.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
/// What a workout was (ADR 0030, D17), each one of Health Connect's exercise
/// types. Stored by name, so one this app doesn't know is skipped and new ones
@ -103,7 +104,26 @@ final class const WorkoutContent({
/// The sets a strength workout did (ADR 0035, D3); null for any other.
final WorkoutStrength? strength,
/// The weather over it (ADR 0036); null until it is asked for, and for
/// one without a track.
final WorkoutWeather? weather,
}) with Equatable {
/// As it is, with [weather] (null: without).
WorkoutContent withWeather(WorkoutWeather? weather) => WorkoutContent(
activity: activity,
startedAt: startedAt,
endedAt: endedAt,
name: name,
notes: notes,
distanceMetres: distanceMetres,
movingTime: movingTime,
origin: origin,
plan: plan,
strength: strength,
weather: weather,
);
/// As it is, named [name] with [notes] (each null or blank: none).
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
String? text(String? value) =>
@ -119,6 +139,7 @@ final class const WorkoutContent({
origin: origin,
plan: plan,
strength: strength,
weather: weather,
);
}
@ -134,5 +155,6 @@ final class const WorkoutContent({
origin,
plan,
strength,
weather,
];
}

View file

@ -11,6 +11,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_texts.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/strength/widget/workout_strength_view.dart';
import 'package:halkyon_train_flutter/src/feature/weather/widget/workout_weather_view.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workout_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
@ -33,7 +34,8 @@ Future<void> openWorkoutScreen(BuildContext context, String id) =>
Widget workoutPage(BuildContext context, String id) {
final train = PersonScope.of<TrainPerson>(context);
return BlocProvider(
create: (context) => WorkoutCubit(train.workouts, train.maps, id)..open(),
create: (context) =>
WorkoutCubit(train.workouts, train.maps, train.weather, id)..open(),
child: const WorkoutScreen(),
);
}
@ -253,6 +255,10 @@ class const _Workout({
],
const SizedBox(height: 16),
_Totals(content: content, analysis: analysis),
if (content.weather case final weather?) ...[
const SizedBox(height: 16),
WorkoutWeatherView(weather),
],
if (content.notes case final notes?) ...[
const SizedBox(height: 16),
Text(notes, style: theme.textTheme.bodyLarge),

View file

@ -52,6 +52,8 @@ import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_re
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
@ -71,6 +73,7 @@ import '../support/fake_place_search_gateway.dart';
import '../support/fake_recorder_platform.dart';
import '../support/fake_route_files.dart';
import '../support/fake_routing_gateway.dart';
import '../support/fake_weather_gateway.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_day_gateway.dart';
import '../support/memory_place_gateway.dart';
@ -118,6 +121,7 @@ void main() {
StrengthSessionStore? strengthSession,
RoutingGateway? routing,
PlaceSearchGateway? placeSearch,
WeatherGateway? weather,
OfflineMaps? offlineMaps,
RouteFiles? routeFiles,
}) async {
@ -260,6 +264,7 @@ void main() {
routing: routing ?? FakeRoutingGateway(),
placeSearch:
placeSearch ?? FakePlaceSearchGateway(isOffered: false),
weather: weather ?? FakeWeatherGateway(),
recording: recording,
health: health,
loop: loop,
@ -3996,6 +4001,119 @@ void main() {
expect(find.text('Morning run'), findsNothing);
expect(await workouts.all(), isEmpty);
});
group('their weather (ADR 0036)', () {
late FakeWeatherGateway weather;
final morning = DateTime(2026, 10, 27, 7, 30).toUtc();
setUp(() {
final hour = DateTime.utc(
morning.year,
morning.month,
morning.day,
morning.hour,
);
weather = FakeWeatherGateway(
answer: [
WeatherHour(
time: hour,
temperature: 11.6,
feelsLike: 9.8,
precipitation: 0.6,
code: 61,
wind: 4.2,
gusts: 9.7,
windFrom: 220,
),
WeatherHour(
time: hour.add(const Duration(hours: 1)),
temperature: 13.8,
precipitation: 0.6,
code: 61,
wind: 3,
),
],
);
});
Future<void> openPage(WidgetTester tester, String name) async {
await tester.tap(find.text(name));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
}
testWidgets("a run's page asks once for the weather where it started, "
'keeps it, and shows it with Open-Meteo named', (tester) async {
final id = await saveRun(DateTime(2026, 10, 27, 7, 30));
await start(
tester,
usedBefore: true,
workouts: workouts,
weather: weather,
);
await openPage(tester, 'Morning run');
final [asked] = weather.asked;
expect((asked.lat, asked.lon), (0.0, 0.0));
expect(asked.from, morning);
await tester.scrollUntilVisible(
find.text('Weather data by Open-Meteo.com'),
200,
scrollable: find.byType(Scrollable).last,
);
expect(find.text('Rain · 12–14 °C'), findsOneWidget);
expect(find.text('Felt like 10 °C'), findsOneWidget);
expect(
find.text('Wind 15 km/h from the south-west, gusts up to 35 km/h'),
findsOneWidget,
);
expect(find.text('1.2 mm fell'), findsOneWidget);
final kept = (await tester.runAsync(workouts.all))!
.singleWhere((workout) => workout.id == id);
expect(kept.content!.weather?.hours, weather.answer);
await tester.pageBack();
await tester.pumpAndSettle();
await openPage(tester, 'Morning run');
expect(weather.asked, hasLength(1), reason: 'kept, not asked again');
});
testWidgets('a workout without a track asks for none', (tester) async {
await saveRow();
await start(
tester,
usedBefore: true,
workouts: workouts,
weather: weather,
);
await openPage(tester, 'Evening row');
expect(weather.asked, isEmpty);
expect(find.text('Weather data by Open-Meteo.com'), findsNothing);
});
testWidgets('where none came, the page shows none, and asks again as '
'it opens next', (tester) async {
weather.failing = true;
await saveRun(DateTime(2026, 10, 27, 7, 30));
await start(
tester,
usedBefore: true,
workouts: workouts,
weather: weather,
);
await openPage(tester, 'Morning run');
expect(
find.text('Weather data by Open-Meteo.com', skipOffstage: false),
findsNothing,
);
await tester.pageBack();
await tester.pumpAndSettle();
await openPage(tester, 'Morning run');
expect(weather.asked, hasLength(2));
});
});
});
}

View file

@ -6,6 +6,7 @@ import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
@ -173,6 +174,25 @@ void main() {
expect(platform.writes, writes + 1);
});
test('its weather kept (ADR 0036, D4), a workout is not written again, '
'as Health Connect holds none', () async {
final id = await workouts.save(run(0), const []);
final health = open();
await health.setWriting(on: true);
await settled(health);
final writes = platform.writes;
await workouts.update(
id,
run(0).withWeather(
WorkoutWeather([WeatherHour(time: start, temperature: 12)]),
),
);
await settled(health);
expect(platform.writes, writes);
});
test('written by an earlier Train, which wrote less of it, a workout is '
'written again as the app opens (D51)', () async {
await workouts.save(run(0), const []);

View file

@ -0,0 +1,112 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_payload.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
import 'package:halkyon_train_flutter/src/feature/weather/widget/workout_weather_view.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
final _start = DateTime.utc(2026, 11, 2, 7);
/// A morning's three hours: rain turning to showers, warming, the wind
/// rising from the south-west.
final weather = WorkoutWeather([
WeatherHour(
time: _start,
temperature: 11.6,
feelsLike: 9.8,
humidity: 88,
precipitation: 0.6,
code: 61,
wind: 3.1,
gusts: 7.4,
windFrom: 230,
),
WeatherHour(
time: _start.add(const Duration(hours: 1)),
temperature: 12.9,
feelsLike: 11,
precipitation: 0.4,
code: 80,
wind: 4.2,
gusts: 9.7,
windFrom: 220,
),
WeatherHour(
time: _start.add(const Duration(hours: 2)),
temperature: 13.8,
precipitation: 0.2,
code: 3,
wind: 2,
),
]);
/// A workout's weather (ADR 0036): what its page sums up, and how it is
/// kept in the workout's payload.
void main() {
test('sums up the span: the temperatures, how the start felt, the most '
'notable sky, the windiest hour, what fell', () {
expect(weather.temperatures, (11.6, 13.8));
expect(weather.feelsLike, 9.8);
expect(weather.conditions, Conditions.rainShowers);
expect(weather.wind, (wind: 4.2, from: 220, gusts: 9.7));
expect(weather.precipitation, closeTo(1.2, 1e-9));
expect(const WorkoutWeather([]).temperatures, isNull);
expect(const WorkoutWeather([]).wind, isNull);
expect(const WorkoutWeather([]).conditions, isNull);
});
test("names the sky from WMO's codes, and none for a code it lacks", () {
expect(Conditions.of(0), Conditions.clear);
expect(Conditions.of(2), Conditions.partlyCloudy);
expect(Conditions.of(48), Conditions.fog);
expect(Conditions.of(67), Conditions.freezingRain);
expect(Conditions.of(86), Conditions.snowShowers);
expect(Conditions.of(99), Conditions.thunderstorm);
expect(Conditions.of(4), isNull);
});
test('gives the wind one of eight directions', () {
expect(
[
for (final degrees in [0, 22, 23, 90, 200, 225, 338, 360])
compassPoint(degrees),
],
['n', 'n', 'ne', 'e', 's', 'sw', 'n', 'n'],
);
});
test('rounds a place to two decimals before it leaves the phone (D2)', () {
expect(roundedDegrees(52.471234), 52.47);
expect(roundedDegrees(13.405678), 13.41);
expect(roundedDegrees(-0.004), -0.0);
});
test("is kept in the workout's payload, and an older payload has none", () {
expect(workoutWeatherOf(workoutWeatherJson(weather)), weather);
expect(workoutWeatherOf(null), isNull);
expect(workoutWeatherOf({'hours': <Object?>[]}), isNull);
final content = WorkoutContent(
activity: Activity.running,
startedAt: _start.add(const Duration(minutes: 10)),
endedAt: _start.add(const Duration(hours: 2, minutes: 5)),
weather: weather,
);
final read = decodeWorkout(ByteData.sublistView(encodeWorkout(content)));
expect(read, content);
expect(
decodeWorkout(
encodeWorkout(
WorkoutContent(
activity: Activity.running,
startedAt: _start,
endedAt: _start.add(const Duration(hours: 1)),
),
),
)?.weather,
isNull,
);
});
}

View file

@ -0,0 +1,24 @@
import 'package:halkyon_train_flutter/src/feature/weather/data/weather_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/weather/model/workout_weather.dart';
/// The instance's weather as a test wants it (ADR 0036): [answer] for every
/// span, or [WeatherUnavailable] while [failing]; what it was asked.
class FakeWeatherGateway({var List<WeatherHour> answer = const []})
implements WeatherGateway {
bool failing = false;
final asked = <({double lat, double lon, DateTime from, DateTime to})>[];
@override
Future<List<WeatherHour>> hours({
required double lat,
required double lon,
required DateTime from,
required DateTime to,
}) async {
asked.add((lat: lat, lon: lon, from: from, to: to));
if (failing) {
throw const WeatherUnavailable();
}
return answer;
}
}