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

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