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:
parent
508994be3a
commit
3f7b409e80
4 changed files with 2503 additions and 7 deletions
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 []);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue