feat(train): weight, entered on Progress and read from Health Connect, sealed and synced (ADR 0030, D63)
A person's weights are each a sealed `body_weight` row, as the days and
the places: the weight in grams, when it was measured (UTC), where it
came from and, for Health Connect's, the record's id and app are the
payload; the server sees the space and the ciphertext alone.
Progress gets a Weight card after Your body: the latest and its day,
its line over time (held, each weight and its day), the plus to enter
one (a comma taken as a point, 20 to 400 kg, when measured, today by
default) and All, the list to delete one. The latest weight is also
Your body's, which the band's and the zones' figures work from. In
kilograms alone: Train is metric throughout and has no units setting
yet.
Health Connect's weight records are read in when switched on in its
screen (READ_WEIGHT, asked as the others): each record once, its id kept
in health_connect.json also after it is deleted here, never Train's own,
and none within 10 minutes and 0.1 kg of a weight entered in Train.
The migration (20261103204323683-body-weights):
- creates (or replaces, unchanged) `gen_random_uuid_v7()`, the
default of every UUID id, as each of Serverpod's migrations does;
- CREATE TABLE "body_weight": "id" uuid primary key defaulting to a
UUID v7, "spaceId" bigint (the sync space), "keyId" text (the key
that sealed it), "payload" bytea NOT NULL (the sealed content),
"nonce" bytea;
- CREATE INDEX "body_weight_space_idx" on "spaceId", for a space's
rows as the sync reads them;
- a foreign key from "spaceId" to "offline_sync_spaces"("id"),
ON DELETE CASCADE, so that an account's deletion takes its weights
with its space;
- the version rows of Train's and every module's migrations.
Nothing existing changes, so nothing is lost.
Tests: the row written and read back in its owner's space, and the
denials: another person reads none of them, and writing one into
another's space throws OfflineSyncSpaceMembershipException and leaves
it empty; an account's deletion takes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
984527a632
commit
b975659690
22 changed files with 23591 additions and 9 deletions
|
|
@ -151,6 +151,9 @@
|
|||
"healthRationaleNothingElse": "Was Train dort liest, wird dir gezeigt und niemandem sonst, und für nichts anderes genutzt. Schaltest du einen Schalter aus oder nimmst eine Erlaubnis in Health Connect zurück, hört es auf.",
|
||||
"healthRead": "Von anderen Apps lesen",
|
||||
"healthReadHint": "Ihre Trainings kommen in Train, von den Apps, die du unten wählst",
|
||||
"healthReadWeight": "Mein Gewicht lesen",
|
||||
"healthReadWeightHint": "Was andere Apps an Gewicht schrieben, etwa die einer Waage, kommt in Train",
|
||||
"healthRationaleWeight": "Train liest das Gewicht, das andere Apps schrieben, erst, wenn du es einschaltest: Jeder Wert kommt einmal in Train, mit deinem Schlüssel versiegelt, bevor er mit deiner Instanz synchronisiert wird, die ihn nicht lesen kann.",
|
||||
"healthApps": "Apps",
|
||||
"healthAppsEmpty": "Noch keine App hat Trainings in Health Connect geschrieben.",
|
||||
"workoutFromList": "von {app}",
|
||||
|
|
@ -570,6 +573,16 @@
|
|||
"profileWorkouts": "{count, plural, =1{1 Training} other{{count} Trainings}}",
|
||||
"profileYearEmpty": "Dieses Jahr noch keine Trainings.",
|
||||
"bodyWeight": "{kg} kg",
|
||||
"weightTitle": "Gewicht",
|
||||
"weightAdd": "Gewicht eintragen",
|
||||
"weightAll": "Alle",
|
||||
"weightEmpty": "Trag dein Gewicht ein, um es hier zu verfolgen; das neueste ist auch das Gewicht deines Körpers.",
|
||||
"weightOverTime": "Im Verlauf",
|
||||
"weightField": "Gewicht",
|
||||
"weightInvalid": "Ein Gewicht zwischen 20 und 400 kg",
|
||||
"weightWhen": "Gemessen",
|
||||
"weightDelete": "Löschen",
|
||||
"weightFromHealth": "von {app}",
|
||||
"bodyResting": "Ruhepuls {bpm}",
|
||||
"bodyMax": "Maximalpuls {bpm}",
|
||||
"homeToday": "Heute",
|
||||
|
|
|
|||
|
|
@ -434,6 +434,9 @@
|
|||
"healthRationaleNothingElse": "What Train reads there is shown to you and nobody else, and isn't used for anything else. Turning a switch off, or taking a permission back in Health Connect, stops it.",
|
||||
"healthRead": "Read from other apps",
|
||||
"healthReadHint": "Their workouts come into Train, from the apps you choose below",
|
||||
"healthReadWeight": "Read my weight",
|
||||
"healthReadWeightHint": "Weights other apps wrote, a scale's say, come into Train",
|
||||
"healthRationaleWeight": "Train reads the weights other apps wrote only once you turn it on: each comes into Train once, sealed with your key before it's synced to your instance, which can't read it.",
|
||||
"healthApps": "Apps",
|
||||
"@healthApps": {
|
||||
"description": "Over the apps that wrote workouts to Health Connect, each with its switch."
|
||||
|
|
@ -1597,6 +1600,25 @@
|
|||
},
|
||||
"profileYearEmpty": "No workouts this year yet.",
|
||||
"bodyWeight": "{kg} kg",
|
||||
"weightTitle": "Weight",
|
||||
"weightAdd": "Add weight",
|
||||
"weightAll": "All",
|
||||
"weightEmpty": "Add your weight to follow it here; the latest is also your body's weight.",
|
||||
"weightOverTime": "Over time",
|
||||
"weightField": "Weight",
|
||||
"weightInvalid": "A weight between 20 and 400 kg",
|
||||
"weightWhen": "Measured",
|
||||
"weightDelete": "Delete",
|
||||
"weightFromHealth": "from {app}",
|
||||
"@weightFromHealth": {
|
||||
"description": "A weight read from Health Connect: the package of the app that wrote it.",
|
||||
"placeholders": {
|
||||
"app": {
|
||||
"type": "String",
|
||||
"example": "com.example.scale"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@bodyWeight": {
|
||||
"placeholders": {
|
||||
"kg": {
|
||||
|
|
|
|||
|
|
@ -1006,6 +1006,24 @@ abstract class AppLocalizations {
|
|||
/// **'Their workouts come into Train, from the apps you choose below'**
|
||||
String get healthReadHint;
|
||||
|
||||
/// No description provided for @healthReadWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Read my weight'**
|
||||
String get healthReadWeight;
|
||||
|
||||
/// No description provided for @healthReadWeightHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weights other apps wrote, a scale\'s say, come into Train'**
|
||||
String get healthReadWeightHint;
|
||||
|
||||
/// No description provided for @healthRationaleWeight.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Train reads the weights other apps wrote only once you turn it on: each comes into Train once, sealed with your key before it\'s synced to your instance, which can\'t read it.'**
|
||||
String get healthRationaleWeight;
|
||||
|
||||
/// Over the apps that wrote workouts to Health Connect, each with its switch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3520,6 +3538,66 @@ abstract class AppLocalizations {
|
|||
/// **'{kg} kg'**
|
||||
String bodyWeight(String kg);
|
||||
|
||||
/// No description provided for @weightTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight'**
|
||||
String get weightTitle;
|
||||
|
||||
/// No description provided for @weightAdd.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add weight'**
|
||||
String get weightAdd;
|
||||
|
||||
/// No description provided for @weightAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All'**
|
||||
String get weightAll;
|
||||
|
||||
/// No description provided for @weightEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add your weight to follow it here; the latest is also your body\'s weight.'**
|
||||
String get weightEmpty;
|
||||
|
||||
/// No description provided for @weightOverTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Over time'**
|
||||
String get weightOverTime;
|
||||
|
||||
/// No description provided for @weightField.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Weight'**
|
||||
String get weightField;
|
||||
|
||||
/// No description provided for @weightInvalid.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A weight between 20 and 400 kg'**
|
||||
String get weightInvalid;
|
||||
|
||||
/// No description provided for @weightWhen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Measured'**
|
||||
String get weightWhen;
|
||||
|
||||
/// No description provided for @weightDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get weightDelete;
|
||||
|
||||
/// A weight read from Health Connect: the package of the app that wrote it.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'from {app}'**
|
||||
String weightFromHealth(String app);
|
||||
|
||||
/// No description provided for @bodyResting.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -565,6 +565,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get healthReadHint =>
|
||||
'Ihre Trainings kommen in Train, von den Apps, die du unten wählst';
|
||||
|
||||
@override
|
||||
String get healthReadWeight => 'Mein Gewicht lesen';
|
||||
|
||||
@override
|
||||
String get healthReadWeightHint =>
|
||||
'Was andere Apps an Gewicht schrieben, etwa die einer Waage, kommt in Train';
|
||||
|
||||
@override
|
||||
String get healthRationaleWeight =>
|
||||
'Train liest das Gewicht, das andere Apps schrieben, erst, wenn du es einschaltest: Jeder Wert kommt einmal in Train, mit deinem Schlüssel versiegelt, bevor er mit deiner Instanz synchronisiert wird, die ihn nicht lesen kann.';
|
||||
|
||||
@override
|
||||
String get healthApps => 'Apps';
|
||||
|
||||
|
|
@ -2118,6 +2129,39 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$kg kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String get weightTitle => 'Gewicht';
|
||||
|
||||
@override
|
||||
String get weightAdd => 'Gewicht eintragen';
|
||||
|
||||
@override
|
||||
String get weightAll => 'Alle';
|
||||
|
||||
@override
|
||||
String get weightEmpty =>
|
||||
'Trag dein Gewicht ein, um es hier zu verfolgen; das neueste ist auch das Gewicht deines Körpers.';
|
||||
|
||||
@override
|
||||
String get weightOverTime => 'Im Verlauf';
|
||||
|
||||
@override
|
||||
String get weightField => 'Gewicht';
|
||||
|
||||
@override
|
||||
String get weightInvalid => 'Ein Gewicht zwischen 20 und 400 kg';
|
||||
|
||||
@override
|
||||
String get weightWhen => 'Gemessen';
|
||||
|
||||
@override
|
||||
String get weightDelete => 'Löschen';
|
||||
|
||||
@override
|
||||
String weightFromHealth(String app) {
|
||||
return 'von $app';
|
||||
}
|
||||
|
||||
@override
|
||||
String bodyResting(int bpm) {
|
||||
return 'Ruhepuls $bpm';
|
||||
|
|
|
|||
|
|
@ -561,6 +561,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get healthReadHint =>
|
||||
'Their workouts come into Train, from the apps you choose below';
|
||||
|
||||
@override
|
||||
String get healthReadWeight => 'Read my weight';
|
||||
|
||||
@override
|
||||
String get healthReadWeightHint =>
|
||||
'Weights other apps wrote, a scale\'s say, come into Train';
|
||||
|
||||
@override
|
||||
String get healthRationaleWeight =>
|
||||
'Train reads the weights other apps wrote only once you turn it on: each comes into Train once, sealed with your key before it\'s synced to your instance, which can\'t read it.';
|
||||
|
||||
@override
|
||||
String get healthApps => 'Apps';
|
||||
|
||||
|
|
@ -2106,6 +2117,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$kg kg';
|
||||
}
|
||||
|
||||
@override
|
||||
String get weightTitle => 'Weight';
|
||||
|
||||
@override
|
||||
String get weightAdd => 'Add weight';
|
||||
|
||||
@override
|
||||
String get weightAll => 'All';
|
||||
|
||||
@override
|
||||
String get weightEmpty =>
|
||||
'Add your weight to follow it here; the latest is also your body\'s weight.';
|
||||
|
||||
@override
|
||||
String get weightOverTime => 'Over time';
|
||||
|
||||
@override
|
||||
String get weightField => 'Weight';
|
||||
|
||||
@override
|
||||
String get weightInvalid => 'A weight between 20 and 400 kg';
|
||||
|
||||
@override
|
||||
String get weightWhen => 'Measured';
|
||||
|
||||
@override
|
||||
String get weightDelete => 'Delete';
|
||||
|
||||
@override
|
||||
String weightFromHealth(String app) {
|
||||
return 'from $app';
|
||||
}
|
||||
|
||||
@override
|
||||
String bodyResting(int bpm) {
|
||||
return 'resting $bpm bpm';
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_tex
|
|||
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/widget/route_speech.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/bloc/strength_plans_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -138,6 +139,13 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
|
|||
return PlacesCubit(train.places, train.chosenPlace)..open();
|
||||
},
|
||||
),
|
||||
// Their weights, which Progress and Your body share (D63).
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
return WeightsCubit(train.weights, train.profile)..open();
|
||||
},
|
||||
),
|
||||
BlocProvider(
|
||||
create: (context) {
|
||||
final train = PersonScope.of<TrainPerson>(context);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,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/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/weight/data/sync_weight_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.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';
|
||||
|
|
@ -102,6 +104,10 @@ class TrainPerson(
|
|||
/// The place last chosen on this device (ADR 0034, D4).
|
||||
required final ChosenPlaceStore chosenPlace,
|
||||
|
||||
/// Their weights (D63), entered or read from Health Connect, sealed and
|
||||
/// synced.
|
||||
required final WeightRepository weights,
|
||||
|
||||
/// Their strength plans (ADR 0035), sealed and synced.
|
||||
required final StrengthPlanRepository strengthPlans,
|
||||
|
||||
|
|
@ -444,6 +450,7 @@ Future<TrainPerson> _openPersonData(
|
|||
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
|
||||
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
|
||||
final places = PlaceRepository(SyncPlaceGateway(db, sync), keys);
|
||||
final weights = WeightRepository(SyncWeightGateway(db, sync), keys);
|
||||
final strengthPlans = StrengthPlanRepository(
|
||||
SyncStrengthPlanGateway(db, sync),
|
||||
keys,
|
||||
|
|
@ -463,6 +470,7 @@ Future<TrainPerson> _openPersonData(
|
|||
AndroidHealthPlatform(),
|
||||
HealthStore(File(p.join(folder.path, 'health_connect.json'))),
|
||||
workouts,
|
||||
weights: weights,
|
||||
)..start())
|
||||
: null;
|
||||
final recorder = Platform.isAndroid ? AndroidRecorderPlatform() : null;
|
||||
|
|
@ -508,6 +516,7 @@ Future<TrainPerson> _openPersonData(
|
|||
routes: routes,
|
||||
places: places,
|
||||
chosenPlace: ChosenPlaceStore(File(p.join(folder.path, 'place.json'))),
|
||||
weights: weights,
|
||||
strengthPlans: strengthPlans,
|
||||
strengthSession: StrengthSessionStore(
|
||||
File(p.join(folder.path, 'strength_session.json')),
|
||||
|
|
@ -557,6 +566,7 @@ Future<TrainPerson> _openPersonData(
|
|||
await plans.dispose();
|
||||
await routes.dispose();
|
||||
await places.dispose();
|
||||
await weights.dispose();
|
||||
await strengthPlans.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
|
|
|
|||
|
|
@ -69,10 +69,23 @@ class HealthCubit(final HealthConnect _health) extends Cubit<HealthState> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Turns reading other apps' weights [on] (D63), Health Connect asking
|
||||
/// first.
|
||||
Future<void> setReadingWeights({required bool on}) async {
|
||||
emit(state.copyWith(asking: on, refused: false));
|
||||
final reads = await _health.setReadingWeights(on: on);
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(asking: false, refused: on && !reads));
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes [app]'s sessions, or no longer.
|
||||
Future<void> setApp(String app, {required bool on}) =>
|
||||
_health.setApp(app, on: on);
|
||||
|
||||
/// Whether weights read have somewhere to go on this phone.
|
||||
bool get readsWeightsHere => _health.weights != null;
|
||||
|
||||
/// Opens Health Connect's settings.
|
||||
Future<void> openSettings() => _health.platform.openSettings();
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,26 @@ class AndroidHealthPlatform() implements HealthPlatform {
|
|||
return route == null ? null : routeOf(route);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HealthWeight>> weights({required DateTime from}) async => [
|
||||
for (final weight in (await _call<List<Object?>>(
|
||||
'weights',
|
||||
from.millisecondsSinceEpoch,
|
||||
))!)
|
||||
if (weight case {
|
||||
'id': final String id,
|
||||
'at': final int at,
|
||||
'kilograms': final num kilograms,
|
||||
'app': final String app,
|
||||
})
|
||||
HealthWeight(
|
||||
id: id,
|
||||
at: DateTime.fromMillisecondsSinceEpoch(at, isUtc: true),
|
||||
kilograms: kilograms.toDouble(),
|
||||
app: app,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> openSettings() => _call<void>('openSettings');
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import 'package:halkyon_train_flutter/src/feature/health/data/health_import.dart
|
|||
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_records.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.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';
|
||||
|
|
@ -19,14 +21,18 @@ final _always = DateTime.utc(2000);
|
|||
/// A person's workouts and Health Connect on this phone (ADR 0030, D31):
|
||||
/// with writing on, every workout of Train's written there, and written
|
||||
/// again or deleted as it changes; with reading on, the sessions of the
|
||||
/// apps the person chose brought in as workouts. Train's own store stays
|
||||
/// what counts.
|
||||
/// apps the person chose brought in as workouts; with reading weights on,
|
||||
/// other apps' weights brought in to [weights] (D63). Train's own store
|
||||
/// stays what counts.
|
||||
class HealthConnect(
|
||||
/// Health Connect itself.
|
||||
final HealthPlatform platform,
|
||||
final HealthStore _store,
|
||||
final WorkoutRepository _workouts,
|
||||
) {
|
||||
final WorkoutRepository _workouts, {
|
||||
|
||||
/// Where weights read come in; none, none are read.
|
||||
final WeightRepository? weights,
|
||||
}) {
|
||||
late HealthSettings _settings = _store.read();
|
||||
late final _following = CoalescingRunner(
|
||||
_follow,
|
||||
|
|
@ -103,6 +109,24 @@ class HealthConnect(
|
|||
return reads;
|
||||
}
|
||||
|
||||
/// Turns reading other apps' weights [on] (D63), asking Health Connect
|
||||
/// first; whether it is on.
|
||||
Future<bool> setReadingWeights({required bool on}) async {
|
||||
if (!on) {
|
||||
_update(_settings.copyWith(readsWeights: false));
|
||||
return false;
|
||||
}
|
||||
final granted = await platform.ask({HealthAccess.weight});
|
||||
final reads = granted.contains(HealthAccess.weight);
|
||||
_update(
|
||||
_settings.copyWith(readsWeights: reads, weightsRefused: !reads),
|
||||
);
|
||||
if (reads) {
|
||||
await _reading.run();
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
/// Takes the sessions of [app], or no longer; turned on, those it wrote
|
||||
/// before come in too.
|
||||
Future<void> setApp(String app, {required bool on}) async {
|
||||
|
|
@ -249,9 +273,67 @@ class HealthConnect(
|
|||
}
|
||||
}
|
||||
|
||||
/// Reads what other apps wrote: their sessions, then their weights.
|
||||
Future<void> _read() async {
|
||||
await _readSessions();
|
||||
await _readWeights();
|
||||
}
|
||||
|
||||
/// Other apps' weights not read before (D63), each a weight of Train's
|
||||
/// own; one within ten minutes and a tenth of a kilogram of one the
|
||||
/// person entered in Train is the same, and stays out.
|
||||
Future<void> _readWeights() async {
|
||||
final store = weights;
|
||||
if (store == null || !_settings.readsWeights) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final found = await platform.weights(from: _always);
|
||||
final kept = await store.all();
|
||||
final own = [
|
||||
for (final weight in kept)
|
||||
if (weight.content case final content?
|
||||
when content.source == WeightSource.own)
|
||||
content,
|
||||
];
|
||||
final taken = {..._settings.takenWeights};
|
||||
final known = {
|
||||
...taken,
|
||||
for (final weight in kept) ?weight.content?.healthId,
|
||||
};
|
||||
for (final weight in found) {
|
||||
if (known.contains(weight.id)) {
|
||||
continue;
|
||||
}
|
||||
known.add(weight.id);
|
||||
taken.add(weight.id);
|
||||
final double = own.any(
|
||||
(entered) =>
|
||||
entered.at.difference(weight.at).abs() <=
|
||||
const Duration(minutes: 10) &&
|
||||
(entered.kilograms - weight.kilograms).abs() <= 0.1 + 1e-9,
|
||||
);
|
||||
if (!double) {
|
||||
await store.save(
|
||||
WeightContent(
|
||||
at: weight.at,
|
||||
kilograms: weight.kilograms,
|
||||
source: WeightSource.healthConnect,
|
||||
healthId: weight.id,
|
||||
app: weight.app,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_update(_settings.copyWith(takenWeights: taken));
|
||||
} on HealthRefused {
|
||||
_update(_settings.copyWith(readsWeights: false, weightsRefused: true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the sessions written since the last read, through the changes
|
||||
/// token; all of them where there is none yet, or it expired.
|
||||
Future<void> _read() => _guarded(() async {
|
||||
Future<void> _readSessions() => _guarded(() async {
|
||||
if (!_settings.reads) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ enum HealthAccess() {
|
|||
|
||||
/// Reading what is older than 30 days before reading was granted.
|
||||
history,
|
||||
|
||||
/// Reading the weights other apps wrote (D63): a scale's, say.
|
||||
weight,
|
||||
}
|
||||
|
||||
/// The kinds of record Train writes, by their names on the channel.
|
||||
|
|
@ -191,6 +194,18 @@ final class const HealthChanges({
|
|||
List<Object?> get props => [token, sessions, expired];
|
||||
}
|
||||
|
||||
/// A weight another app wrote (D63): Health Connect's ID of it, when it was
|
||||
/// measured, how heavy, and the app's package.
|
||||
final class const HealthWeight({
|
||||
required final String id,
|
||||
required final DateTime at,
|
||||
required final double kilograms,
|
||||
required final String app,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, at, kilograms, app];
|
||||
}
|
||||
|
||||
/// The person refused what Health Connect needed, or took it back.
|
||||
final class HealthRefused() implements Exception;
|
||||
|
||||
|
|
@ -233,6 +248,11 @@ abstract interface class HealthPlatform() {
|
|||
/// null where they refused or there is none.
|
||||
Future<List<Fix>?> route(String sessionId);
|
||||
|
||||
/// The weights other apps wrote since [from] (as far back as Health
|
||||
/// Connect lets Train read); throws [HealthRefused] without the
|
||||
/// permission.
|
||||
Future<List<HealthWeight>> weights({required DateTime from});
|
||||
|
||||
/// Opens Health Connect's settings, where what Train is granted changes.
|
||||
Future<void> openSettings();
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,17 @@ final class const HealthSettings({
|
|||
/// Sessions taken and then deleted in Train, by Health Connect's ID, so
|
||||
/// that they don't come back.
|
||||
final Set<String> dismissed = const {},
|
||||
|
||||
/// Whether Train reads other apps' weights from there (D63).
|
||||
final bool readsWeights = false,
|
||||
|
||||
/// Whether Health Connect refused the weights or took them back, which
|
||||
/// turned reading them off.
|
||||
final bool weightsRefused = false,
|
||||
|
||||
/// The weights read on this phone, by Health Connect's ID: each comes in
|
||||
/// once, so one deleted in Train stays out.
|
||||
final Set<String> takenWeights = const {},
|
||||
}) with Equatable {
|
||||
HealthSettings copyWith({
|
||||
bool? writes,
|
||||
|
|
@ -68,6 +79,9 @@ final class const HealthSettings({
|
|||
Map<String, SourceApp>? apps,
|
||||
Map<String, String>? imported,
|
||||
Set<String>? dismissed,
|
||||
bool? readsWeights,
|
||||
bool? weightsRefused,
|
||||
Set<String>? takenWeights,
|
||||
}) => HealthSettings(
|
||||
writes: writes ?? this.writes,
|
||||
writeRefused: writeRefused ?? this.writeRefused,
|
||||
|
|
@ -78,6 +92,9 @@ final class const HealthSettings({
|
|||
apps: apps ?? this.apps,
|
||||
imported: imported ?? this.imported,
|
||||
dismissed: dismissed ?? this.dismissed,
|
||||
readsWeights: readsWeights ?? this.readsWeights,
|
||||
weightsRefused: weightsRefused ?? this.weightsRefused,
|
||||
takenWeights: takenWeights ?? this.takenWeights,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -91,6 +108,9 @@ final class const HealthSettings({
|
|||
apps,
|
||||
imported,
|
||||
dismissed,
|
||||
readsWeights,
|
||||
weightsRefused,
|
||||
takenWeights,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +161,12 @@ class HealthStore(final File _file) {
|
|||
dismissed: {
|
||||
...(json['dismissed'] as List<Object?>? ?? const []).cast<String>(),
|
||||
},
|
||||
readsWeights: json['readsWeights'] == true,
|
||||
weightsRefused: json['weightsRefused'] == true,
|
||||
takenWeights: {
|
||||
...(json['takenWeights'] as List<Object?>? ?? const [])
|
||||
.cast<String>(),
|
||||
},
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
|
|
@ -176,6 +202,9 @@ class HealthStore(final File _file) {
|
|||
},
|
||||
'imported': settings.imported,
|
||||
'dismissed': settings.dismissed.toList(),
|
||||
'readsWeights': settings.readsWeights,
|
||||
'weightsRefused': settings.weightsRefused,
|
||||
'takenWeights': settings.takenWeights.toList(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,26 @@ class const HealthScreen({super.key}) extends StatelessWidget {
|
|||
.setApp(app, on: on),
|
||||
),
|
||||
],
|
||||
// Other apps' weights (D63), where Train has a
|
||||
// store for them.
|
||||
if (context.select<HealthCubit, bool>(
|
||||
(cubit) => cubit.readsWeightsHere,
|
||||
))
|
||||
SwitchListTile(
|
||||
secondary: const Icon(LucideIcons.weight),
|
||||
title: Text(l10n.healthReadWeight),
|
||||
subtitle: Text(
|
||||
settings.weightsRefused && !settings.readsWeights
|
||||
? l10n.healthTakenBack
|
||||
: l10n.healthReadWeightHint,
|
||||
),
|
||||
value: settings.readsWeights,
|
||||
onChanged: state.asking
|
||||
? null
|
||||
: (on) => context
|
||||
.read<HealthCubit>()
|
||||
.setReadingWeights(on: on),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.settings),
|
||||
|
|
@ -172,6 +192,7 @@ class const HealthRationaleScreen({super.key}) extends StatelessWidget {
|
|||
for (final paragraph in [
|
||||
l10n.healthRationaleWrite,
|
||||
l10n.healthRationaleRead,
|
||||
l10n.healthRationaleWeight,
|
||||
l10n.healthRationaleNothingElse,
|
||||
])
|
||||
Padding(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,21 @@ final class const ProfileContent({
|
|||
/// How long they mean to sleep a night, 5 to 11 hours.
|
||||
final Duration? sleepGoal,
|
||||
}) with Equatable {
|
||||
/// The same figures with the weight [kilograms], as a weight measured
|
||||
/// last sets it (D63).
|
||||
ProfileContent withWeight(double kilograms) => ProfileContent(
|
||||
sex: sex,
|
||||
birthDate: birthDate,
|
||||
heightCm: heightCm,
|
||||
weightKg: kilograms,
|
||||
restingHeartRate: restingHeartRate,
|
||||
maxHeartRate: maxHeartRate,
|
||||
vo2Max: vo2Max,
|
||||
trainingBackground: trainingBackground,
|
||||
typicalDay: typicalDay,
|
||||
sleepGoal: sleepGoal,
|
||||
);
|
||||
|
||||
/// Their age on [day], in whole years; null without a birth date.
|
||||
int? ageOn(DateTime day) {
|
||||
final born = birthDate;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
|
||||
/// The person's weights (ADR 0030, D63), the earliest first, read again
|
||||
/// whenever they change, here or on another device.
|
||||
final class const WeightsState({
|
||||
final bool loaded = false,
|
||||
|
||||
/// Those that open on this device.
|
||||
final List<StoredWeight> weights = const [],
|
||||
}) with Equatable {
|
||||
/// The weight measured last; null without one.
|
||||
WeightContent? get latest => weights.lastOrNull?.content;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [loaded, weights];
|
||||
}
|
||||
|
||||
/// Reads the person's weights, keeps one they enter and deletes one; a
|
||||
/// weight entered as the latest is *Your body*'s weight from then on, which
|
||||
/// the Loop's setup and the calories take (D33).
|
||||
class WeightsCubit(
|
||||
final WeightRepository _weights,
|
||||
final ProfileRepository _profile,
|
||||
) extends Cubit<WeightsState> {
|
||||
this : super(const WeightsState());
|
||||
|
||||
StreamSubscription<void>? _changes;
|
||||
|
||||
Future<void> open() async {
|
||||
_changes = _weights.changes.listen((_) => unawaited(_read()));
|
||||
await _read();
|
||||
}
|
||||
|
||||
/// Keeps [kilograms] measured [at].
|
||||
Future<void> add(double kilograms, DateTime at) async {
|
||||
final before = state.latest;
|
||||
await _weights.save(WeightContent(at: at.toUtc(), kilograms: kilograms));
|
||||
if (before == null || !at.isBefore(before.at)) {
|
||||
// Where the profile opens here: one this device can't open stays as
|
||||
// it is.
|
||||
if (await _profile.read() case final profile?) {
|
||||
await _profile.save(profile.withWeight(kilograms));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(String id) => _weights.delete(id);
|
||||
|
||||
Future<void> _read() async {
|
||||
final all = await _weights.all();
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
WeightsState(
|
||||
loaded: true,
|
||||
weights: [
|
||||
for (final weight in all)
|
||||
if (weight.content != null) weight,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _changes?.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [WeightGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncWeightGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements WeightGateway {
|
||||
@override
|
||||
Future<List<WeightRow>> weights() async => [
|
||||
for (final weight in await protocol.BodyWeight.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
(
|
||||
id: weight.id!.uuid,
|
||||
envelope: (
|
||||
keyId: weight.keyId,
|
||||
nonce: weight.nonce,
|
||||
payload: weight.payload,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(WeightRow weight) async {
|
||||
await protocol.BodyWeight.db.insertRow(
|
||||
_db,
|
||||
protocol.BodyWeight(
|
||||
id: protocol.UuidValue.withValidation(weight.id),
|
||||
keyId: weight.envelope.keyId,
|
||||
nonce: weight.envelope.nonce,
|
||||
payload: weight.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.BodyWeight.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final weight?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.BodyWeight.db.updateRow(
|
||||
_db,
|
||||
weight.copyWith(
|
||||
keyId: envelope.keyId,
|
||||
nonce: envelope.nonce,
|
||||
payload: envelope.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
final uuid = protocol.UuidValue.withValidation(id);
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes.
|
||||
if (await protocol.BodyWeight.db.findById(
|
||||
_db,
|
||||
uuid,
|
||||
transaction: transaction,
|
||||
)
|
||||
case final weight?) {
|
||||
await protocol.BodyWeight.db.updateRow(
|
||||
_db,
|
||||
protocol.BodyWeight(
|
||||
id: weight.id,
|
||||
spaceId: weight.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.BodyWeight.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.equals(uuid),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A `body_weight` row as kept: its id and its envelope, sealed (ADR 0030,
|
||||
/// D7).
|
||||
typedef WeightRow = ({String id, Envelope envelope});
|
||||
|
||||
/// Where the person's weights are kept: their synced
|
||||
/// `body_weight` rows (ADR 0030, D63). The repository seals and opens;
|
||||
/// this only keeps.
|
||||
abstract interface class WeightGateway() {
|
||||
Future<List<WeightRow>> weights();
|
||||
|
||||
Future<void> insert(WeightRow weight);
|
||||
|
||||
/// Replaces the weight [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
/// Deletes the weight [id], its payload blanked first (ADR 0002, rule 2).
|
||||
Future<void> delete(String id);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
|
||||
/// The schema of a weight's payload this app writes and reads.
|
||||
const weightSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing (ADR 0030, D63): UTF-8
|
||||
/// JSON, the weight in grams, its time in UTC.
|
||||
ByteData encodeWeight(WeightContent content) => ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'schemaVersion': weightSchemaVersion,
|
||||
'at': content.at.toUtc().toIso8601String(),
|
||||
'grams': (content.kilograms * 1000).round(),
|
||||
if (content.source != WeightSource.own) 'source': content.source.name,
|
||||
'healthId': ?content.healthId,
|
||||
'app': ?content.app,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// The weight [payload] holds, opened; null for one of a newer schema or
|
||||
/// one it can't read.
|
||||
WeightContent? decodeWeight(ByteData payload) {
|
||||
try {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(payload)),
|
||||
) as Map<String, Object?>;
|
||||
final version = json['schemaVersion']! as int;
|
||||
if (version > weightSchemaVersion) {
|
||||
log('A weight of schema $version skipped', name: 'Weight');
|
||||
return null;
|
||||
}
|
||||
final grams = json['grams']! as int;
|
||||
if (grams <= 0) {
|
||||
return null;
|
||||
}
|
||||
return WeightContent(
|
||||
at: DateTime.parse(json['at']! as String).toUtc(),
|
||||
kilograms: grams / 1000,
|
||||
source: switch (json['source']) {
|
||||
final String name =>
|
||||
WeightSource.values.asNameMap()[name] ?? WeightSource.own,
|
||||
_ => WeightSource.own,
|
||||
},
|
||||
healthId: json['healthId'] as String?,
|
||||
app: json['app'] as String?,
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log('A weight not read: ${error.runtimeType}', name: 'Weight');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' show Uuid;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
|
||||
|
||||
/// The weights the person measured (ADR 0030, D63), each a row sealed with
|
||||
/// their key for that row, as workouts are (D7); a readable row is one the
|
||||
/// instance made up, and shows locked.
|
||||
class WeightRepository(final WeightGateway _gateway, final TrainKeys _keys) {
|
||||
StreamSubscription<void>? _remote;
|
||||
|
||||
/// This device's changes, and those a sync brings while anyone listens.
|
||||
late final StreamController<void> _changes = StreamController.broadcast(
|
||||
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
||||
onCancel: () => _remote?.cancel(),
|
||||
);
|
||||
|
||||
late final _envelope = PayloadEnvelope(_keys.cipher);
|
||||
|
||||
/// Fires when the weights changed, here or on the person's other devices.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// Every weight, the earliest first; those that don't open here last,
|
||||
/// with their content null.
|
||||
Future<List<StoredWeight>> all() async {
|
||||
await _keys.refresh();
|
||||
final rows = await _gateway.weights();
|
||||
final weights = [
|
||||
for (final row in rows)
|
||||
StoredWeight(row.id, switch (_open(row.envelope, row.id)) {
|
||||
final payload? => decodeWeight(payload),
|
||||
null => null,
|
||||
}),
|
||||
];
|
||||
return weights..sort((a, b) {
|
||||
final (first, second) = (a.content, b.content);
|
||||
if (first == null || second == null) {
|
||||
return first == null && second == null
|
||||
? a.id.compareTo(b.id)
|
||||
: (first == null ? 1 : -1);
|
||||
}
|
||||
return first.at.compareTo(second.at);
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps a new weight and returns its id; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> save(WeightContent content) async {
|
||||
final id = const Uuid().v7();
|
||||
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
|
||||
_changes.add(null);
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> update(String id, WeightContent content) async {
|
||||
await _gateway.update(id, await _sealed(id, content));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _gateway.delete(id);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _changes.close();
|
||||
|
||||
Future<Envelope> _sealed(String id, WeightContent content) async =>
|
||||
_envelope.seal(
|
||||
encodeWeight(content),
|
||||
keyId: await _keys.keyToWrite(),
|
||||
table: 'body_weight',
|
||||
id: id,
|
||||
);
|
||||
|
||||
ByteData? _open(Envelope envelope, String id) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(envelope, table: 'body_weight', id: id)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Where a weight came from (ADR 0030, D63).
|
||||
enum WeightSource() {
|
||||
/// Entered in Train.
|
||||
own,
|
||||
|
||||
/// Read from Health Connect on a phone of the person's: another app's,
|
||||
/// a scale's say.
|
||||
healthConnect,
|
||||
}
|
||||
|
||||
/// A weight the person measured (D63): when, how heavy, and where it came
|
||||
/// from; one read from Health Connect with that record's ID, so that it is
|
||||
/// read once, and the app that wrote it.
|
||||
final class const WeightContent({
|
||||
required final DateTime at,
|
||||
required final double kilograms,
|
||||
final WeightSource source = WeightSource.own,
|
||||
final String? healthId,
|
||||
final String? app,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [at, kilograms, source, healthId, app];
|
||||
}
|
||||
|
||||
/// A weight as kept: its row's ID and its content, null where it doesn't
|
||||
/// open on this device.
|
||||
final class const StoredWeight(final String id, final WeightContent? content)
|
||||
with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue