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
41 changed files with 2503 additions and 7 deletions
File diff suppressed because one or more lines are too long
|
|
@ -26,6 +26,8 @@ import 'package:halkyon_train_client/src/protocol/routing/route_profile.dart'
|
||||||
as _isvxstnj;
|
as _isvxstnj;
|
||||||
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
||||||
as _inm997sc;
|
as _inm997sc;
|
||||||
|
import 'package:halkyon_train_client/src/protocol/weather/weather_hour.dart'
|
||||||
|
as _ig77jxou;
|
||||||
import 'package:http/http.dart' as _i85jenna;
|
import 'package:http/http.dart' as _i85jenna;
|
||||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||||
as _iacc;
|
as _iacc;
|
||||||
|
|
@ -179,6 +181,46 @@ class EndpointSearch extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A workout's weather from Open-Meteo (ADR 0036), for those signed in: the
|
||||||
|
/// server asks in their place, so Open-Meteo never sees their address.
|
||||||
|
/// Neither the place nor the weather is kept or logged. Refusals are
|
||||||
|
/// `WeatherRefused`.
|
||||||
|
/// {@category Endpoint}
|
||||||
|
class EndpointWeather extends _isc.EndpointRef {
|
||||||
|
EndpointWeather(_isc.EndpointCaller caller) : super(caller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'weather';
|
||||||
|
|
||||||
|
/// Whether the instance answers the weather.
|
||||||
|
_ida.Future<bool> offered() => caller.callServerEndpoint<bool>(
|
||||||
|
'weather',
|
||||||
|
'offered',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The hours from [from] to [to] at [lat], [lon] (rounded to about a
|
||||||
|
/// kilometre). Refused where the operator switched the weather off, for a
|
||||||
|
/// place off the earth, a span empty, longer than `WorkoutWeather.maxSpan`,
|
||||||
|
/// before 1940 or more than a day ahead, beyond `WorkoutWeather.perMinute`
|
||||||
|
/// requests a minute, and where Open-Meteo doesn't answer.
|
||||||
|
_ida.Future<List<_ig77jxou.WeatherHour>> hours({
|
||||||
|
required double lat,
|
||||||
|
required double lon,
|
||||||
|
required DateTime from,
|
||||||
|
required DateTime to,
|
||||||
|
}) => caller.callServerEndpoint<List<_ig77jxou.WeatherHour>>(
|
||||||
|
'weather',
|
||||||
|
'hours',
|
||||||
|
{
|
||||||
|
'lat': lat,
|
||||||
|
'lon': lon,
|
||||||
|
'from': from,
|
||||||
|
'to': to,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class Modules {
|
class Modules {
|
||||||
Modules(Client client) {
|
Modules(Client client) {
|
||||||
base = _ihk4p5t7.Caller(client);
|
base = _ihk4p5t7.Caller(client);
|
||||||
|
|
@ -230,6 +272,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
map = EndpointMap(this);
|
map = EndpointMap(this);
|
||||||
routing = EndpointRouting(this);
|
routing = EndpointRouting(this);
|
||||||
search = EndpointSearch(this);
|
search = EndpointSearch(this);
|
||||||
|
weather = EndpointWeather(this);
|
||||||
modules = Modules(this);
|
modules = Modules(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,6 +284,8 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
|
|
||||||
late final EndpointSearch search;
|
late final EndpointSearch search;
|
||||||
|
|
||||||
|
late final EndpointWeather weather;
|
||||||
|
|
||||||
late final Modules modules;
|
late final Modules modules;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -249,6 +294,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
'map': map,
|
'map': map,
|
||||||
'routing': routing,
|
'routing': routing,
|
||||||
'search': search,
|
'search': search,
|
||||||
|
'weather': weather,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import 'package:halkyon_train_client/src/protocol/routing/route_point.dart'
|
||||||
as _iq1flhug;
|
as _iq1flhug;
|
||||||
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
import 'package:halkyon_train_client/src/protocol/search/place.dart'
|
||||||
as _inm997sc;
|
as _inm997sc;
|
||||||
|
import 'package:halkyon_train_client/src/protocol/weather/weather_hour.dart'
|
||||||
|
as _ig77jxou;
|
||||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||||
as _iacc;
|
as _iacc;
|
||||||
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
||||||
|
|
@ -45,6 +47,8 @@ import 'routing/route_stretch.dart' as _ic7f85bc;
|
||||||
import 'search/place.dart' as _iadh29yw;
|
import 'search/place.dart' as _iadh29yw;
|
||||||
import 'search/search_refused.dart' as _ihbxkwke;
|
import 'search/search_refused.dart' as _ihbxkwke;
|
||||||
import 'strength/strength_plan.dart' as _imwvu2mn;
|
import 'strength/strength_plan.dart' as _imwvu2mn;
|
||||||
|
import 'weather/weather_hour.dart' as _i9m2f53e;
|
||||||
|
import 'weather/weather_refused.dart' as _ieu1vbq0;
|
||||||
import 'workout/workout.dart' as _i5b9b17z;
|
import 'workout/workout.dart' as _i5b9b17z;
|
||||||
import 'workout/workout_series.dart' as _i92mefto;
|
import 'workout/workout_series.dart' as _i92mefto;
|
||||||
export 'day/day.dart';
|
export 'day/day.dart';
|
||||||
|
|
@ -67,6 +71,8 @@ export 'routing/route_stretch.dart';
|
||||||
export 'search/place.dart';
|
export 'search/place.dart';
|
||||||
export 'search/search_refused.dart';
|
export 'search/search_refused.dart';
|
||||||
export 'strength/strength_plan.dart';
|
export 'strength/strength_plan.dart';
|
||||||
|
export 'weather/weather_hour.dart';
|
||||||
|
export 'weather/weather_refused.dart';
|
||||||
export 'workout/workout.dart';
|
export 'workout/workout.dart';
|
||||||
export 'workout/workout_series.dart';
|
export 'workout/workout_series.dart';
|
||||||
export 'client.dart';
|
export 'client.dart';
|
||||||
|
|
@ -980,6 +986,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _imwvu2mn.StrengthPlan) {
|
if (t == _imwvu2mn.StrengthPlan) {
|
||||||
return _imwvu2mn.StrengthPlan.fromJson(data) as T;
|
return _imwvu2mn.StrengthPlan.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _i9m2f53e.WeatherHour) {
|
||||||
|
return _i9m2f53e.WeatherHour.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _ieu1vbq0.WeatherRefused) {
|
||||||
|
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
|
||||||
|
}
|
||||||
if (t == _i5b9b17z.Workout) {
|
if (t == _i5b9b17z.Workout) {
|
||||||
return _i5b9b17z.Workout.fromJson(data) as T;
|
return _i5b9b17z.Workout.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -1051,6 +1063,13 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _isc.getType<_imwvu2mn.StrengthPlan?>()) {
|
if (t == _isc.getType<_imwvu2mn.StrengthPlan?>()) {
|
||||||
return (data != null ? _imwvu2mn.StrengthPlan.fromJson(data) : null) as T;
|
return (data != null ? _imwvu2mn.StrengthPlan.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _isc.getType<_i9m2f53e.WeatherHour?>()) {
|
||||||
|
return (data != null ? _i9m2f53e.WeatherHour.fromJson(data) : null) as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_ieu1vbq0.WeatherRefused?>()) {
|
||||||
|
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
if (t == _isc.getType<_i5b9b17z.Workout?>()) {
|
if (t == _isc.getType<_i5b9b17z.Workout?>()) {
|
||||||
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
|
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -1089,6 +1108,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
return (data as List).map((e) => deserialize<_inm997sc.Place>(e)).toList()
|
return (data as List).map((e) => deserialize<_inm997sc.Place>(e)).toList()
|
||||||
as T;
|
as T;
|
||||||
}
|
}
|
||||||
|
if (t == List<_ig77jxou.WeatherHour>) {
|
||||||
|
return (data as List)
|
||||||
|
.map((e) => deserialize<_ig77jxou.WeatherHour>(e))
|
||||||
|
.toList()
|
||||||
|
as T;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
|
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
|
||||||
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
||||||
|
|
@ -1129,6 +1154,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
_iadh29yw.Place => 'Place',
|
_iadh29yw.Place => 'Place',
|
||||||
_ihbxkwke.SearchRefused => 'SearchRefused',
|
_ihbxkwke.SearchRefused => 'SearchRefused',
|
||||||
_imwvu2mn.StrengthPlan => 'StrengthPlan',
|
_imwvu2mn.StrengthPlan => 'StrengthPlan',
|
||||||
|
_i9m2f53e.WeatherHour => 'WeatherHour',
|
||||||
|
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
|
||||||
_i5b9b17z.Workout => 'Workout',
|
_i5b9b17z.Workout => 'Workout',
|
||||||
_i92mefto.WorkoutSeries => 'WorkoutSeries',
|
_i92mefto.WorkoutSeries => 'WorkoutSeries',
|
||||||
_ => null,
|
_ => null,
|
||||||
|
|
@ -1188,6 +1215,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
return 'SearchRefused';
|
return 'SearchRefused';
|
||||||
case _imwvu2mn.StrengthPlan():
|
case _imwvu2mn.StrengthPlan():
|
||||||
return 'StrengthPlan';
|
return 'StrengthPlan';
|
||||||
|
case _i9m2f53e.WeatherHour():
|
||||||
|
return 'WeatherHour';
|
||||||
|
case _ieu1vbq0.WeatherRefused():
|
||||||
|
return 'WeatherRefused';
|
||||||
case _i5b9b17z.Workout():
|
case _i5b9b17z.Workout():
|
||||||
return 'Workout';
|
return 'Workout';
|
||||||
case _i92mefto.WorkoutSeries():
|
case _i92mefto.WorkoutSeries():
|
||||||
|
|
@ -1288,6 +1319,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (dataClassName == 'StrengthPlan') {
|
if (dataClassName == 'StrengthPlan') {
|
||||||
return deserialize<_imwvu2mn.StrengthPlan>(data['data']);
|
return deserialize<_imwvu2mn.StrengthPlan>(data['data']);
|
||||||
}
|
}
|
||||||
|
if (dataClassName == 'WeatherHour') {
|
||||||
|
return deserialize<_i9m2f53e.WeatherHour>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'WeatherRefused') {
|
||||||
|
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
|
||||||
|
}
|
||||||
if (dataClassName == 'Workout') {
|
if (dataClassName == 'Workout') {
|
||||||
return deserialize<_i5b9b17z.Workout>(data['data']);
|
return deserialize<_i5b9b17z.Workout>(data['data']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// An hour's weather at a place (ADR 0036): what Open-Meteo answers for the
|
||||||
|
/// hour starting at [time], each figure null where it has none.
|
||||||
|
abstract class WeatherHour
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
WeatherHour._({
|
||||||
|
required this.time,
|
||||||
|
this.temperature,
|
||||||
|
this.feelsLike,
|
||||||
|
this.humidity,
|
||||||
|
this.precipitation,
|
||||||
|
this.code,
|
||||||
|
this.wind,
|
||||||
|
this.gusts,
|
||||||
|
this.windFrom,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory WeatherHour({
|
||||||
|
required DateTime time,
|
||||||
|
double? temperature,
|
||||||
|
double? feelsLike,
|
||||||
|
int? humidity,
|
||||||
|
double? precipitation,
|
||||||
|
int? code,
|
||||||
|
double? wind,
|
||||||
|
double? gusts,
|
||||||
|
int? windFrom,
|
||||||
|
}) = _WeatherHourImpl;
|
||||||
|
|
||||||
|
factory WeatherHour.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return WeatherHour(
|
||||||
|
time: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['time']),
|
||||||
|
temperature: (jsonSerialization['temperature'] as num?)?.toDouble(),
|
||||||
|
feelsLike: (jsonSerialization['feelsLike'] as num?)?.toDouble(),
|
||||||
|
humidity: jsonSerialization['humidity'] as int?,
|
||||||
|
precipitation: (jsonSerialization['precipitation'] as num?)?.toDouble(),
|
||||||
|
code: jsonSerialization['code'] as int?,
|
||||||
|
wind: (jsonSerialization['wind'] as num?)?.toDouble(),
|
||||||
|
gusts: (jsonSerialization['gusts'] as num?)?.toDouble(),
|
||||||
|
windFrom: jsonSerialization['windFrom'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hour's start, in UTC.
|
||||||
|
DateTime time;
|
||||||
|
|
||||||
|
/// At two metres, in °C.
|
||||||
|
double? temperature;
|
||||||
|
|
||||||
|
/// How it felt, in °C.
|
||||||
|
double? feelsLike;
|
||||||
|
|
||||||
|
/// Relative humidity, in percent.
|
||||||
|
int? humidity;
|
||||||
|
|
||||||
|
/// What fell over the hour (rain, showers, snow as water), in mm.
|
||||||
|
double? precipitation;
|
||||||
|
|
||||||
|
/// The WMO weather code (0 clear, 1–3 clouds, 45–48 fog, 51–67 drizzle and
|
||||||
|
/// rain, 71–77 snow, 80–86 showers, 95–99 thunderstorms).
|
||||||
|
int? code;
|
||||||
|
|
||||||
|
/// The wind at ten metres, in m/s, and its gusts.
|
||||||
|
double? wind;
|
||||||
|
|
||||||
|
double? gusts;
|
||||||
|
|
||||||
|
/// Where the wind came from, in degrees from north.
|
||||||
|
int? windFrom;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [WeatherHour]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
WeatherHour copyWith({
|
||||||
|
DateTime? time,
|
||||||
|
double? temperature,
|
||||||
|
double? feelsLike,
|
||||||
|
int? humidity,
|
||||||
|
double? precipitation,
|
||||||
|
int? code,
|
||||||
|
double? wind,
|
||||||
|
double? gusts,
|
||||||
|
int? windFrom,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'WeatherHour',
|
||||||
|
'time': time.toJson(),
|
||||||
|
if (temperature != null) 'temperature': temperature,
|
||||||
|
if (feelsLike != null) 'feelsLike': feelsLike,
|
||||||
|
if (humidity != null) 'humidity': humidity,
|
||||||
|
if (precipitation != null) 'precipitation': precipitation,
|
||||||
|
if (code != null) 'code': code,
|
||||||
|
if (wind != null) 'wind': wind,
|
||||||
|
if (gusts != null) 'gusts': gusts,
|
||||||
|
if (windFrom != null) 'windFrom': windFrom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'WeatherHour',
|
||||||
|
'time': time.toJson(),
|
||||||
|
if (temperature != null) 'temperature': temperature,
|
||||||
|
if (feelsLike != null) 'feelsLike': feelsLike,
|
||||||
|
if (humidity != null) 'humidity': humidity,
|
||||||
|
if (precipitation != null) 'precipitation': precipitation,
|
||||||
|
if (code != null) 'code': code,
|
||||||
|
if (wind != null) 'wind': wind,
|
||||||
|
if (gusts != null) 'gusts': gusts,
|
||||||
|
if (windFrom != null) 'windFrom': windFrom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Undefined {}
|
||||||
|
|
||||||
|
class _WeatherHourImpl extends WeatherHour {
|
||||||
|
_WeatherHourImpl({
|
||||||
|
required DateTime time,
|
||||||
|
double? temperature,
|
||||||
|
double? feelsLike,
|
||||||
|
int? humidity,
|
||||||
|
double? precipitation,
|
||||||
|
int? code,
|
||||||
|
double? wind,
|
||||||
|
double? gusts,
|
||||||
|
int? windFrom,
|
||||||
|
}) : super._(
|
||||||
|
time: time,
|
||||||
|
temperature: temperature,
|
||||||
|
feelsLike: feelsLike,
|
||||||
|
humidity: humidity,
|
||||||
|
precipitation: precipitation,
|
||||||
|
code: code,
|
||||||
|
wind: wind,
|
||||||
|
gusts: gusts,
|
||||||
|
windFrom: windFrom,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [WeatherHour]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
WeatherHour copyWith({
|
||||||
|
DateTime? time,
|
||||||
|
Object? temperature = _Undefined,
|
||||||
|
Object? feelsLike = _Undefined,
|
||||||
|
Object? humidity = _Undefined,
|
||||||
|
Object? precipitation = _Undefined,
|
||||||
|
Object? code = _Undefined,
|
||||||
|
Object? wind = _Undefined,
|
||||||
|
Object? gusts = _Undefined,
|
||||||
|
Object? windFrom = _Undefined,
|
||||||
|
}) {
|
||||||
|
return WeatherHour(
|
||||||
|
time: time ?? this.time,
|
||||||
|
temperature: temperature is double? ? temperature : this.temperature,
|
||||||
|
feelsLike: feelsLike is double? ? feelsLike : this.feelsLike,
|
||||||
|
humidity: humidity is int? ? humidity : this.humidity,
|
||||||
|
precipitation: precipitation is double?
|
||||||
|
? precipitation
|
||||||
|
: this.precipitation,
|
||||||
|
code: code is int? ? code : this.code,
|
||||||
|
wind: wind is double? ? wind : this.wind,
|
||||||
|
gusts: gusts is double? ? gusts : this.gusts,
|
||||||
|
windFrom: windFrom is int? ? windFrom : this.windFrom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// Why the instance answers no weather (ADR 0036): `off` where the operator
|
||||||
|
/// switched it off, `invalid` for a place off the earth or a span it doesn't
|
||||||
|
/// take (empty, longer than `WorkoutWeather.maxSpan`, before 1940 or more
|
||||||
|
/// than a day ahead), `tooOften` beyond `WorkoutWeather.perMinute` requests
|
||||||
|
/// a minute, `unreachable` where Open-Meteo didn't answer.
|
||||||
|
abstract class WeatherRefused
|
||||||
|
implements
|
||||||
|
_isc.SerializableException,
|
||||||
|
_isc.SerializableModel,
|
||||||
|
_isc.ProtocolSerialization {
|
||||||
|
WeatherRefused._({required this.reason});
|
||||||
|
|
||||||
|
factory WeatherRefused({required String reason}) = _WeatherRefusedImpl;
|
||||||
|
|
||||||
|
factory WeatherRefused.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return WeatherRefused(reason: jsonSerialization['reason'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
String reason;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [WeatherRefused]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
WeatherRefused copyWith({String? reason});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'WeatherRefused',
|
||||||
|
'reason': reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'WeatherRefused',
|
||||||
|
'reason': reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'WeatherRefused(reason: $reason)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WeatherRefusedImpl extends WeatherRefused {
|
||||||
|
_WeatherRefusedImpl({required String reason}) : super._(reason: reason);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [WeatherRefused]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
WeatherRefused copyWith({String? reason}) {
|
||||||
|
return WeatherRefused(reason: reason ?? this.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -707,5 +707,18 @@
|
||||||
"strengthNextUp": "Als Nächstes: {exercise}",
|
"strengthNextUp": "Als Nächstes: {exercise}",
|
||||||
"strengthBarAndPlates": "{bar}-kg-Stange, jede Seite: {plates}",
|
"strengthBarAndPlates": "{bar}-kg-Stange, jede Seite: {plates}",
|
||||||
"strengthHandlesAndPlates": "{bar}-kg-Griffe, 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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -890,5 +890,27 @@
|
||||||
"strengthHandlesAndPlates": "{bar} kg handles, each side: {plates}",
|
"strengthHandlesAndPlates": "{bar} kg handles, each side: {plates}",
|
||||||
"@strengthHandlesAndPlates": {"placeholders": {"bar": {"type": "String"}, "plates": {"type": "String"}}},
|
"@strengthHandlesAndPlates": {"placeholders": {"bar": {"type": "String"}, "plates": {"type": "String"}}},
|
||||||
"strengthWeightShort": "{weight} kg below what was planned",
|
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4257,6 +4257,84 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{weight} kg below what was planned'**
|
/// **'{weight} kg below what was planned'**
|
||||||
String strengthWeightShort(String weight);
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2735,4 +2735,92 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String strengthWeightShort(String weight) {
|
String strengthWeightShort(String weight) {
|
||||||
return '$weight kg unter dem Plan';
|
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';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2722,6 +2722,94 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String strengthWeightShort(String weight) {
|
String strengthWeightShort(String weight) {
|
||||||
return '$weight kg below what was planned';
|
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`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -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_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/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/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/sync_workout_gateway.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.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).
|
/// The instance's search for places, for the planner (D50).
|
||||||
required final PlaceSearchGateway placeSearch,
|
required final PlaceSearchGateway placeSearch,
|
||||||
|
|
||||||
|
/// Workouts' weather through the instance (ADR 0036).
|
||||||
|
required final WeatherGateway weather,
|
||||||
|
|
||||||
/// The instance's map source (D22).
|
/// The instance's map source (D22).
|
||||||
required final MapSourceRepository maps,
|
required final MapSourceRepository maps,
|
||||||
|
|
||||||
|
|
@ -498,6 +502,7 @@ Future<TrainPerson> _openPersonData(
|
||||||
),
|
),
|
||||||
routing: ServerpodRoutingGateway(client),
|
routing: ServerpodRoutingGateway(client),
|
||||||
placeSearch: ServerpodPlaceSearchGateway(client),
|
placeSearch: ServerpodPlaceSearchGateway(client),
|
||||||
|
weather: ServerpodWeatherGateway(client),
|
||||||
maps: maps,
|
maps: maps,
|
||||||
mapTiles: keptVectorTiles(offlineMaps),
|
mapTiles: keptVectorTiles(offlineMaps),
|
||||||
offlineMaps: offlineMaps,
|
offlineMaps: offlineMaps,
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,11 @@ class HealthConnect(
|
||||||
!_settings.writes) {
|
!_settings.writes) {
|
||||||
continue;
|
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];
|
final before = _settings.written[workout.id];
|
||||||
if (before?.fingerprint == fingerprint) {
|
if (before?.fingerprint == fingerprint) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,9 @@ import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show MapSource;
|
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/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_analysis.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_export.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';
|
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,
|
/// 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
|
/// 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(
|
class WorkoutCubit(
|
||||||
final WorkoutRepository _workouts,
|
final WorkoutRepository _workouts,
|
||||||
final MapSourceRepository _maps,
|
final MapSourceRepository _maps,
|
||||||
|
final WeatherGateway _weather,
|
||||||
|
|
||||||
/// Which workout.
|
/// Which workout.
|
||||||
final String id,
|
final String id,
|
||||||
|
|
@ -23,6 +28,7 @@ class WorkoutCubit(
|
||||||
this : super(const WorkoutState());
|
this : super(const WorkoutState());
|
||||||
|
|
||||||
StreamSubscription<void>? _changes;
|
StreamSubscription<void>? _changes;
|
||||||
|
var _weatherAsked = false;
|
||||||
|
|
||||||
/// Reads the workout, and follows it.
|
/// Reads the workout, and follows it.
|
||||||
Future<void> open() async {
|
Future<void> open() async {
|
||||||
|
|
@ -82,6 +88,13 @@ class WorkoutCubit(
|
||||||
map: state.map,
|
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) {
|
if (analysis.track.isNotEmpty && state.map == null) {
|
||||||
final source = await _maps.source();
|
final source = await _maps.source();
|
||||||
if (!isClosed) {
|
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
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _changes?.cancel();
|
await _changes?.cancel();
|
||||||
|
|
|
||||||
|
|
@ -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/plan/data/plan_payload.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_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';
|
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||||
|
|
||||||
/// The schema of a workout's payload this app writes and reads.
|
/// 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.
|
// that doesn't know the activity skips the workout whole.
|
||||||
if (content.strength case final strength?)
|
if (content.strength case final strength?)
|
||||||
'strength': workoutStrengthJson(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,
|
_ => null,
|
||||||
},
|
},
|
||||||
strength: workoutStrengthOf(json['strength']),
|
strength: workoutStrengthOf(json['strength']),
|
||||||
|
weather: workoutWeatherOf(json['weather']),
|
||||||
);
|
);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
|
log('A workout not read: ${error.runtimeType}', name: 'Workouts');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.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/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
|
/// 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
|
/// 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.
|
/// The sets a strength workout did (ADR 0035, D3); null for any other.
|
||||||
final WorkoutStrength? strength,
|
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 {
|
}) 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).
|
/// As it is, named [name] with [notes] (each null or blank: none).
|
||||||
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
|
WorkoutContent withText({String? name, String? notes, Activity? activity}) {
|
||||||
String? text(String? value) =>
|
String? text(String? value) =>
|
||||||
|
|
@ -119,6 +139,7 @@ final class const WorkoutContent({
|
||||||
origin: origin,
|
origin: origin,
|
||||||
plan: plan,
|
plan: plan,
|
||||||
strength: strength,
|
strength: strength,
|
||||||
|
weather: weather,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,5 +155,6 @@ final class const WorkoutContent({
|
||||||
origin,
|
origin,
|
||||||
plan,
|
plan,
|
||||||
strength,
|
strength,
|
||||||
|
weather,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/model/recording.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.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/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/workout_cubit.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_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';
|
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) {
|
Widget workoutPage(BuildContext context, String id) {
|
||||||
final train = PersonScope.of<TrainPerson>(context);
|
final train = PersonScope.of<TrainPerson>(context);
|
||||||
return BlocProvider(
|
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(),
|
child: const WorkoutScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +255,10 @@ class const _Workout({
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_Totals(content: content, analysis: analysis),
|
_Totals(content: content, analysis: analysis),
|
||||||
|
if (content.weather case final weather?) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WorkoutWeatherView(weather),
|
||||||
|
],
|
||||||
if (content.notes case final notes?) ...[
|
if (content.notes case final notes?) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(notes, style: theme.textTheme.bodyLarge),
|
Text(notes, style: theme.textTheme.bodyLarge),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue