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
2 changed files with 2503 additions and 7 deletions

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,
);
});
}