Merge branch 'train-days' into integration

# Conflicts:
#	apps/train/halkyon_train_flutter/lib/src/app/train_paths.dart
#	docs/adr/0030-halkyon-train.md
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 23:14:02 +01:00
commit 81adeb91cc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
68 changed files with 25705 additions and 410 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,94 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "body_weight",
"dartName": "BodyWeight",
"module": "halkyon_train",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "body_weight_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "body_weight_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -11,6 +11,7 @@ part '20261028211508606-plans/migration.dart';
part '20261028231042668-routes/migration.dart';
part '20261102103211357-training-places/migration.dart';
part '20261102113104496-strength-plans/migration.dart';
part '20261103204323683-body-weights/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -24,6 +25,7 @@ class MigrationRegistry {
_Migration20261028231042668_routes(),
_Migration20261102103211357_training_places(),
_Migration20261102113104496_strength_plans(),
_Migration20261103204323683_body_weights(),
];
/// List of all client-side database migration versions.

View file

@ -49,6 +49,7 @@ import 'search/search_refused.dart' as _ihbxkwke;
import 'strength/strength_plan.dart' as _imwvu2mn;
import 'weather/weather_hour.dart' as _i9m2f53e;
import 'weather/weather_refused.dart' as _ieu1vbq0;
import 'weight/body_weight.dart' as _i1mqkkn3;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'day/day.dart';
@ -73,6 +74,7 @@ export 'search/search_refused.dart';
export 'strength/strength_plan.dart';
export 'weather/weather_hour.dart';
export 'weather/weather_refused.dart';
export 'weight/body_weight.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
export 'client.dart';
@ -86,6 +88,73 @@ class Protocol extends _isd.DatabaseSerializationManager {
static final Protocol _instance = Protocol._().._registerHostProtocols();
static List<_isd.TableDefinition> get targetTableDefinitions => [
_isd.TableDefinition(
name: 'body_weight',
dartName: 'BodyWeight',
schema: 'public',
module: 'halkyon_train',
columns: [
_isd.ColumnDefinition(
name: 'id',
columnType: _isd.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random_v7',
),
_isd.ColumnDefinition(
name: 'spaceId',
columnType: _isd.ColumnType.bigint,
isNullable: true,
dartType: 'int?',
),
_isd.ColumnDefinition(
name: 'keyId',
columnType: _isd.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isd.ColumnDefinition(
name: 'payload',
columnType: _isd.ColumnType.bytea,
isNullable: false,
dartType: 'dart:typed_data:ByteData',
),
_isd.ColumnDefinition(
name: 'nonce',
columnType: _isd.ColumnType.bytea,
isNullable: true,
dartType: 'dart:typed_data:ByteData?',
),
],
foreignKeys: [
_isd.ForeignKeyDefinition(
constraintName: 'body_weight_fk_0',
columns: ['spaceId'],
referenceTable: 'offline_sync_spaces',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isd.ForeignKeyAction.noAction,
onDelete: _isd.ForeignKeyAction.cascade,
matchType: null,
),
],
indexes: [
_isd.IndexDefinition(
indexName: 'body_weight_space_idx',
tableSpace: null,
elements: [
_isd.IndexElementDefinition(
type: _isd.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isd.TableDefinition(
name: 'content_key',
dartName: 'ContentKey',
@ -992,6 +1061,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ieu1vbq0.WeatherRefused) {
return _ieu1vbq0.WeatherRefused.fromJson(data) as T;
}
if (t == _i1mqkkn3.BodyWeight) {
return _i1mqkkn3.BodyWeight.fromJson(data) as T;
}
if (t == _i5b9b17z.Workout) {
return _i5b9b17z.Workout.fromJson(data) as T;
}
@ -1070,6 +1142,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data != null ? _ieu1vbq0.WeatherRefused.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_i1mqkkn3.BodyWeight?>()) {
return (data != null ? _i1mqkkn3.BodyWeight.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i5b9b17z.Workout?>()) {
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
}
@ -1156,6 +1231,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_imwvu2mn.StrengthPlan => 'StrengthPlan',
_i9m2f53e.WeatherHour => 'WeatherHour',
_ieu1vbq0.WeatherRefused => 'WeatherRefused',
_i1mqkkn3.BodyWeight => 'BodyWeight',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
_ => null,
@ -1219,6 +1295,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'WeatherHour';
case _ieu1vbq0.WeatherRefused():
return 'WeatherRefused';
case _i1mqkkn3.BodyWeight():
return 'BodyWeight';
case _i5b9b17z.Workout():
return 'Workout';
case _i92mefto.WorkoutSeries():
@ -1325,6 +1403,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'WeatherRefused') {
return deserialize<_ieu1vbq0.WeatherRefused>(data['data']);
}
if (dataClassName == 'BodyWeight') {
return deserialize<_i1mqkkn3.BodyWeight>(data['data']);
}
if (dataClassName == 'Workout') {
return deserialize<_i5b9b17z.Workout>(data['data']);
}
@ -1428,6 +1509,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return _i0mgeh8t.SavedRoute.t;
case _imwvu2mn.StrengthPlan:
return _imwvu2mn.StrengthPlan.t;
case _i1mqkkn3.BodyWeight:
return _i1mqkkn3.BodyWeight.t;
case _i5b9b17z.Workout:
return _i5b9b17z.Workout.t;
case _i92mefto.WorkoutSeries:

View file

@ -20,6 +20,7 @@ import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'route/route.dart' as _i0mgeh8t;
import 'strength/strength_plan.dart' as _imwvu2mn;
import 'weight/body_weight.dart' as _i1mqkkn3;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
@ -27,6 +28,7 @@ import 'workout/workout_series.dart' as _i92mefto;
/// `serverpod_offline_sync` package, including the ones owned by modules and
/// shared packages.
final List<_isd.Table> syncTables = [
_i1mqkkn3.BodyWeight.t,
_imed3x5d.ContentKey.t,
_ikmv9f3y.Day.t,
_ir1ofny6.DaySeries.t,

View file

@ -0,0 +1,671 @@
/* 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 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'package:serverpod_database/serverpod_database.dart' as _isd;
/// A weight the person measured (ADR 0030, D64): when, how heavy and where
/// it came from (entered in Train, or read from Health Connect with that
/// record's ID, so that it is read once), in its sealed payload (D7); the
/// instance sees that it exists, its size and when it changed. A row each
/// measurement. Synced.
abstract class BodyWeight
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
BodyWeight._({
this.id,
this.spaceId,
this.keyId,
required this.payload,
this.nonce,
});
factory BodyWeight({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) = _BodyWeightImpl;
factory BodyWeight.fromJson(Map<String, dynamic> jsonSerialization) {
return BodyWeight(
id: jsonSerialization['id'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
spaceId: jsonSerialization['spaceId'] as int?,
keyId: jsonSerialization['keyId'] as String?,
payload: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['payload'],
),
nonce: jsonSerialization['nonce'] == null
? null
: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
);
}
static final t = BodyWeightTable();
static const db = BodyWeightRepository._();
@override
_isc.UuidValue? id;
/// The space owning this row. Maintained by the sync engine.
int? spaceId;
/// The key it is sealed with (ADR 0002's envelope); never null in Train.
String? keyId;
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
_idt.ByteData payload;
/// The sealing's nonce (ADR 0002's envelope).
_idt.ByteData? nonce;
@override
_isd.Table<_isc.UuidValue?> get table => t;
/// Returns a shallow copy of this [BodyWeight]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
BodyWeight copyWith({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'BodyWeight',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'BodyWeight',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
static BodyWeightInclude include() {
return BodyWeightInclude._();
}
static BodyWeightIncludeList includeList({
_isd.WhereExpressionBuilder<BodyWeightTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<BodyWeightTable>? orderBy,
_isd.OrderByListBuilder<BodyWeightTable>? orderByList,
BodyWeightInclude? include,
}) {
return BodyWeightIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(BodyWeight.t),
orderByList: orderByList?.call(BodyWeight.t),
include: include,
);
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _BodyWeightImpl extends BodyWeight {
_BodyWeightImpl({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) : super._(
id: id,
spaceId: spaceId,
keyId: keyId,
payload: payload,
nonce: nonce,
);
/// Returns a shallow copy of this [BodyWeight]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
BodyWeight copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
}) {
return BodyWeight(
id: id is _isc.UuidValue? ? id : this.id,
spaceId: spaceId is int? ? spaceId : this.spaceId,
keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
);
}
}
class BodyWeightUpdateTable extends _isd.UpdateTable<BodyWeightTable> {
BodyWeightUpdateTable(super.table);
_isd.ColumnValue<int, int> spaceId(int? value) => _isd.ColumnValue(
table.spaceId,
value,
);
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
table.keyId,
value,
);
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> payload(_idt.ByteData value) =>
_isd.ColumnValue(
table.payload,
value,
);
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
_isd.ColumnValue(
table.nonce,
value,
);
}
class BodyWeightTable extends _isd.Table<_isc.UuidValue?> {
BodyWeightTable({super.tableRelation}) : super(tableName: 'body_weight') {
updateTable = BodyWeightUpdateTable(this);
spaceId = _isd.ColumnInt(
'spaceId',
this,
);
keyId = _isd.ColumnString(
'keyId',
this,
);
payload = _isd.ColumnByteData(
'payload',
this,
);
nonce = _isd.ColumnByteData(
'nonce',
this,
);
}
late final BodyWeightUpdateTable updateTable;
/// The space owning this row. Maintained by the sync engine.
late final _isd.ColumnInt spaceId;
/// The key it is sealed with (ADR 0002's envelope); never null in Train.
late final _isd.ColumnString keyId;
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
late final _isd.ColumnByteData payload;
/// The sealing's nonce (ADR 0002's envelope).
late final _isd.ColumnByteData nonce;
@override
List<_isd.Column> get columns => [
id,
spaceId,
keyId,
payload,
nonce,
];
}
class BodyWeightInclude extends _isd.IncludeObject {
BodyWeightInclude._();
@override
Map<String, _isd.Include?> get includes => {};
@override
_isd.Table<_isc.UuidValue?> get table => BodyWeight.t;
}
class BodyWeightIncludeList extends _isd.IncludeList {
BodyWeightIncludeList._({
_isd.WhereExpressionBuilder<BodyWeightTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(BodyWeight.t);
}
@override
Map<String, _isd.Include?> get includes => include?.includes ?? {};
@override
_isd.Table<_isc.UuidValue?> get table => BodyWeight.t;
}
class BodyWeightRepository {
const BodyWeightRepository._();
/// Returns a list of [BodyWeight]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<BodyWeight>> find(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<BodyWeightTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<BodyWeightTable>? orderBy,
_isd.OrderByListBuilder<BodyWeightTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.find<BodyWeight>(
where: where?.call(BodyWeight.t),
orderBy: orderBy?.call(BodyWeight.t),
orderByList: orderByList?.call(BodyWeight.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [BodyWeight] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<BodyWeight?> findFirstRow(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<BodyWeightTable>? where,
int? offset,
_isd.OrderByBuilder<BodyWeightTable>? orderBy,
_isd.OrderByListBuilder<BodyWeightTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<BodyWeight>(
where: where?.call(BodyWeight.t),
orderBy: orderBy?.call(BodyWeight.t),
orderByList: orderByList?.call(BodyWeight.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [BodyWeight] by its [id] or null if no such row exists.
Future<BodyWeight?> findById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findById<BodyWeight>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [BodyWeight]s in the list and returns the inserted rows.
///
/// The returned [BodyWeight]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<BodyWeight>> insert(
_isd.DatabaseSession session,
List<BodyWeight> rows, {
_isd.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<BodyWeight>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [BodyWeight] and returns the inserted row.
///
/// The returned [BodyWeight] will have its `id` field set.
Future<BodyWeight> insertRow(
_isd.DatabaseSession session,
BodyWeight row, {
_isd.Transaction? transaction,
}) async {
return session.db.insertRow<BodyWeight>(
row,
transaction: transaction,
);
}
/// Upserts all [BodyWeight]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [BodyWeight]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<BodyWeight>> upsert(
_isd.DatabaseSession session,
List<BodyWeight> rows, {
required _isd.ColumnSelections<BodyWeightTable> conflictColumns,
_isd.ColumnSelections<BodyWeightTable>? updateColumns,
_isd.WhereExpressionBuilder<BodyWeightTable>? updateWhere,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<BodyWeight>(
rows,
conflictColumns: conflictColumns(BodyWeight.t),
updateColumns: updateColumns?.call(BodyWeight.t),
updateWhere: updateWhere?.call(BodyWeight.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [BodyWeight] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [BodyWeight] will have its `id` field set.
Future<BodyWeight?> upsertRow(
_isd.DatabaseSession session,
BodyWeight row, {
required _isd.ColumnSelections<BodyWeightTable> conflictColumns,
_isd.ColumnSelections<BodyWeightTable>? updateColumns,
_isd.WhereExpressionBuilder<BodyWeightTable>? updateWhere,
_isd.Transaction? transaction,
}) async {
return session.db.upsertRow<BodyWeight>(
row,
conflictColumns: conflictColumns(BodyWeight.t),
updateColumns: updateColumns?.call(BodyWeight.t),
updateWhere: updateWhere?.call(BodyWeight.t),
transaction: transaction,
);
}
/// Updates all [BodyWeight]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<BodyWeight>> update(
_isd.DatabaseSession session,
List<BodyWeight> rows, {
_isd.ColumnSelections<BodyWeightTable>? columns,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<BodyWeight>(
rows,
columns: columns?.call(BodyWeight.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [BodyWeight]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<BodyWeight> updateRow(
_isd.DatabaseSession session,
BodyWeight row, {
_isd.ColumnSelections<BodyWeightTable>? columns,
_isd.Transaction? transaction,
}) async {
return session.db.updateRow<BodyWeight>(
row,
columns: columns?.call(BodyWeight.t),
transaction: transaction,
);
}
/// Updates a single [BodyWeight] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<BodyWeight?> updateById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
required _isd.ColumnValueListBuilder<BodyWeightUpdateTable> columnValues,
_isd.Transaction? transaction,
}) async {
return session.db.updateById<BodyWeight>(
id,
columnValues: columnValues(BodyWeight.t.updateTable),
transaction: transaction,
);
}
/// Updates all [BodyWeight]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<BodyWeight>> updateWhere(
_isd.DatabaseSession session, {
required _isd.ColumnValueListBuilder<BodyWeightUpdateTable> columnValues,
required _isd.WhereExpressionBuilder<BodyWeightTable> where,
int? limit,
int? offset,
_isd.OrderByBuilder<BodyWeightTable>? orderBy,
_isd.OrderByListBuilder<BodyWeightTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<BodyWeight>(
columnValues: columnValues(BodyWeight.t.updateTable),
where: where(BodyWeight.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(BodyWeight.t),
orderByList: orderByList?.call(BodyWeight.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [BodyWeight]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<BodyWeight>> delete(
_isd.DatabaseSession session,
List<BodyWeight> rows, {
_isd.OrderByBuilder<BodyWeightTable>? orderBy,
_isd.OrderByListBuilder<BodyWeightTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<BodyWeight>(
rows,
orderBy: orderBy?.call(BodyWeight.t),
orderByList: orderByList?.call(BodyWeight.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [BodyWeight].
Future<BodyWeight> deleteRow(
_isd.DatabaseSession session,
BodyWeight row, {
_isd.Transaction? transaction,
}) async {
return session.db.deleteRow<BodyWeight>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<BodyWeight>> deleteWhere(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<BodyWeightTable> where,
_isd.OrderByBuilder<BodyWeightTable>? orderBy,
_isd.OrderByListBuilder<BodyWeightTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<BodyWeight>(
where: where(BodyWeight.t),
orderBy: orderBy?.call(BodyWeight.t),
orderByList: orderByList?.call(BodyWeight.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<BodyWeightTable>? where,
int? limit,
_isd.Transaction? transaction,
}) async {
return session.db.count<BodyWeight>(
where: where?.call(BodyWeight.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [BodyWeight] rows matching the [where] expression.
Future<void> lockRows(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<BodyWeightTable> where,
required _isd.LockMode lockMode,
required _isd.Transaction transaction,
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
}) async {
return session.db.lockRows<BodyWeight>(
where: where(BodyWeight.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -48,6 +48,8 @@
<uses-permission android:name="android.permission.health.READ_DISTANCE"/>
<uses-permission android:name="android.permission.health.READ_POWER"/>
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY"/>
<!-- The weights other apps wrote, a scale's say (ADR 0030, D64). -->
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>

View file

@ -19,6 +19,7 @@ import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
import androidx.health.connect.client.records.WeightRecord
import androidx.health.connect.client.records.metadata.DataOrigin
import androidx.health.connect.client.request.ChangesTokenRequest
import androidx.health.connect.client.request.ReadRecordsRequest
@ -120,6 +121,7 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
call.argument<Number>("end")!!.toLong(),
),
)
"weights" -> result.success(weights((call.arguments as Number).toLong()))
"route" -> route(call.arguments as String, result)
"openSettings" -> {
activity.startActivity(Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS))
@ -238,6 +240,19 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
.filter { it.metadata.dataOrigin.packageName != activity.packageName }
.map { HealthRecords.sessionOf(it, appNameOf(it.metadata.dataOrigin.packageName)) }
/** Other apps' weights since [from] (D64): Health Connect's ID, the time in UTC milliseconds, kilograms and the app. */
private suspend fun weights(from: Long): List<Map<String, Any?>> =
readAll(WeightRecord::class, Instant.ofEpochMilli(from), Instant.now().plusSeconds(60), null)
.filter { it.metadata.dataOrigin.packageName != activity.packageName }
.map {
mapOf(
"id" to it.metadata.id,
"at" to it.time.toEpochMilli(),
"kilograms" to it.weight.inKilograms,
"app" to it.metadata.dataOrigin.packageName,
)
}
/** What [app] recorded from [start] to [end]: its heart rate, distance and power. */
private suspend fun samples(app: String, start: Long, end: Long): Map<String, Any?> {
val (from, to) = Instant.ofEpochMilli(start) to Instant.ofEpochMilli(end)
@ -343,6 +358,7 @@ class HealthConnectBridge(private val activity: Activity) : MethodChannel.Method
HealthPermission.getReadPermission(PowerRecord::class),
),
"history" to setOf(HealthPermission.PERMISSION_READ_HEALTH_DATA_HISTORY),
"weight" to setOf(HealthPermission.getReadPermission(WeightRecord::class)),
)
/**

View file

@ -1,19 +1,13 @@
{
"@@locale": "de",
"appTitle": "Halkyon Train",
"launchProduct": "Train",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Trainings liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"homeEmptyTitle": "Noch keine Trainings",
"homeEmptyHint": "Trainings, die du auf deinem Telefon aufzeichnest, erscheinen hier.",
"accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über Halkyon Train",
"homeRecordHint": "Zeichne eins im Tab Aufzeichnen auf.",
"recordBack": "Zurück zur Aufzeichnung",
"activityRunning": "Laufen",
@ -60,7 +54,6 @@
"recordingCutOffMessage": "Eine Aufzeichnung von {start} wurde abgebrochen. Bis {end} speichern oder verwerfen?",
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert",
"workoutName": "{when, select, morning_strength{Krafttraining am Morgen} afternoon_strength{Krafttraining am Nachmittag} evening_strength{Krafttraining am Abend} night_strength{Krafttraining in der Nacht} morning_running{Morgenlauf} morning_walking{Morgenspaziergang} morning_hiking{Morgenwanderung} morning_cycling{Morgenradfahrt} morning_indoorRowing{Rudern am Morgen} afternoon_running{Nachmittagslauf} afternoon_walking{Nachmittagsspaziergang} afternoon_hiking{Nachmittagswanderung} afternoon_cycling{Nachmittagsradfahrt} afternoon_indoorRowing{Rudern am Nachmittag} evening_running{Abendlauf} evening_walking{Abendspaziergang} evening_hiking{Abendwanderung} evening_cycling{Abendradfahrt} evening_indoorRowing{Rudern am Abend} night_running{Nachtlauf} night_walking{Nachtspaziergang} night_hiking{Nachtwanderung} night_cycling{Nachtradfahrt} night_indoorRowing{Rudern in der Nacht} other{Training}}",
"workoutLocked": "Gesperrtes Training",
"workoutLockedHint": "Dieses Gerät hat den Schlüssel nicht, mit dem es versiegelt ist.",
@ -99,7 +92,6 @@
"mapCredits": "Kartennachweise",
"routeHelpTitle": "Eine Route planen",
"workoutNotFound": "Dieses Training gibt es nicht mehr.",
"recordHeartRate": "Herzfrequenz",
"recordNoStrap": "Kein Brustgurt",
"recordHeartLoop": "Vom Polar Loop",
@ -119,7 +111,6 @@
"workoutMaxHeartRate": "höchste Herzfrequenz",
"workoutBpmValue": "{bpm} bpm",
"workoutHeartRateChart": "Herzfrequenz",
"recordRower": "Rudergerät",
"recordNoRower": "Wähle dein Rudergerät",
"recordRowerRefused": "Train braucht Geräte in der Nähe, um von deinem Rudergerät aufzuzeichnen. Du kannst das in den Systemeinstellungen erlauben.",
@ -148,7 +139,6 @@
"workoutPowerChart": "Leistung",
"recordAutoPauseRowingHint": "Wenn du aufhörst zu rudern, weiter mit dem nächsten Schlag",
"recordingMetres": "Meter",
"healthTitle": "Health Connect",
"healthHint": "Trainings mit anderen Apps auf diesem Telefon teilen",
"healthUnavailable": "Health Connect ist auf diesem Telefon nicht da. Ab Android 14 ist es eingebaut, davor eine eigene App.",
@ -163,16 +153,17 @@
"healthRationaleWrite": "Train schreibt deine Trainings erst in Health Connect, wenn du es einschaltest: jedes mit Strecke, Herzfrequenz, Tempo, Leistung, Distanz und Anstieg, damit andere Apps auf diesem Telefon sie nutzen können. Bearbeitest oder löschst du ein Training in Train, geschieht das dort auch.",
"healthRationaleRead": "Train liest die Trainings anderer Apps erst, wenn du es einschaltest, und nur von den Apps, die du wählst: Ihre Herzfrequenz, Distanz und Leistung kommen als Trainings in Train, mit deinem Schlüssel versiegelt, bevor sie mit deiner Instanz abgeglichen werden, die sie nicht lesen kann.",
"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}",
"workoutFrom": "Von {app}, über Health Connect",
"workoutBringTrack": "Strecke holen",
"workoutNoTrack": "Health Connect hat keine Strecke dazu gegeben.",
"profileTitle": "Dein Körper",
"profileHint": "Woraus dein Armband und deine Herzfrequenzzonen rechnen",
"profileIntro": "Jede Angabe ist freiwillig und mit deinem Schlüssel versiegelt, wie deine Trainings; deine Instanz kann sie nicht lesen.",
@ -207,7 +198,6 @@
"profileSave": "Speichern",
"profileSaved": "Gespeichert",
"profileInvalid": "Prüfe die markierten Angaben.",
"profileNeeded": "Braucht dein Band",
"loopTitle": "Polar Loop",
"loopHint": "Dein Band: Einrichtung, Akku und Uhrzeit",
@ -374,11 +364,15 @@
"dayRatingSomewhatWell": "Du hast eher gut geschlafen",
"dayRatingWell": "Du hast gut geschlafen",
"dayRecharge": "Nightly Recharge",
"dayRechargeLevel": "Erholung {level} von 6",
"dayAnsStatus": "Autonomes Nervensystem {value}, von −10 bis +10",
"dayNightHeart": "Herzfrequenz {bpm} S/min, sonst {usual} S/min",
"dayHrv": "Herzfrequenzvariabilität {ms} ms, sonst {usual} ms",
"dayBreathing": "{rate} Atemzüge pro Minute",
"dayRecovery": "Erholung",
"dayRecoveryOf": "{level} von 6",
"dayAns": "Autonomes Nervensystem",
"dayHrvName": "Herzfrequenzvariabilität",
"dayBreathingName": "Atmung",
"dayPerMinute": "{rate} pro Minute",
"dayMs": "{ms} ms",
"dayUsual": "sonst {value}",
"dayRechargeHelp": "Nightly Recharge ist Polars Maß dafür, wie sich dein Körper in den ersten Stunden des Schlafs erholt hat, gemessen an deinem eigenen Üblichen. Die Erholung reicht von 1 bis 6. Der Status des autonomen Nervensystems reicht von −10 bis +10: über 0 hat es sich besser beruhigt als sonst. Die Herzfrequenzvariabilität ist, wie sehr die Zeit zwischen den Herzschlägen schwankt; höher als sonst spricht meist für eine bessere Erholung. Die Atmung zählt deine Atemzüge pro Minute. Was das Band noch nicht kennt, fehlt.",
"daySkin": "Hauttemperatur",
"dayCelsius": "{value} °C",
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst",
@ -583,10 +577,19 @@
"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",
"equipmentTitle": "Ausrüstung",
"equipmentHint": "Wo du trainierst und was es dort gibt",
"equipmentEmptyTitle": "Noch keine Orte",
@ -640,7 +643,6 @@
"plateNoChange": "Nichts ändert sich",
"plateMoves": "{count, plural, =0{Keine Scheibe bewegt sich} =1{1 Scheibe je Seite bewegt sich} other{{count} Scheiben je Seite bewegen sich}}",
"plateSet": "Satz {number}",
"exerciseName": "{id, select, backSquat{Kniebeuge} frontSquat{Frontkniebeuge} gobletSquat{Goblet Squat} kettlebellGobletSquat{Goblet Squat mit Kettlebell} bodyweightSquat{Kniebeuge ohne Gewicht} lunge{Ausfallschritt} bulgarianSplitSquat{Bulgarische Kniebeuge} bodyweightLunge{Ausfallschritt ohne Gewicht} deadlift{Kreuzheben} romanianDeadlift{Rumänisches Kreuzheben} trapBarDeadlift{Kreuzheben mit Trap Bar} dumbbellRomanianDeadlift{Rumänisches Kreuzheben mit Kurzhanteln} kettlebellSwing{Kettlebell Swing} hipThrust{Hip Thrust} gluteBridge{Glute Bridge} benchPress{Bankdrücken} inclineBenchPress{Schrägbankdrücken} dumbbellBenchPress{Kurzhantel-Bankdrücken} pushUp{Liegestütz} dip{Dip} overheadPress{Schulterdrücken} dumbbellShoulderPress{Kurzhantel-Schulterdrücken} pikePushUp{Pike-Liegestütz} barbellRow{Langhantelrudern} dumbbellRow{Einarmiges Kurzhantelrudern} cableRow{Rudern am Kabelzug} invertedRow{Umgekehrtes Rudern} bandPullApart{Band Pull-Apart} facePull{Face Pull} pullUp{Klimmzug} chinUp{Klimmzug im Untergriff} latPulldown{Latzug} bandPulldown{Latzug mit Band} barbellCurl{Langhantel-Curl} ezBarCurl{SZ-Curl} dumbbellCurl{Kurzhantel-Curl} hammerCurl{Hammer-Curl} tricepsPushdown{Trizepsdrücken am Kabel} skullCrusher{French Press} overheadTricepsExtension{Trizepsstrecken über Kopf} benchDip{Dip an der Bank} lateralRaise{Seitheben} cableLateralRaise{Seitheben am Kabel} calfRaise{Wadenheben} plank{Unterarmstütz} sidePlank{Seitstütz} hangingLegRaise{Beinheben im Hang} crunch{Crunch} farmersCarry{Farmer’s Walk} other{Übung}}",
"exerciseNeed": "{need, select, barbell{eine Langhantel} ezBar{eine SZ-Stange} trapBar{eine Trap Bar} dumbbells{Kurzhanteln} kettlebell{eine Kettlebell} bench{eine Bank} rack{ein Rack} pullUpBar{eine Klimmzugstange} dipBars{eine Dipstation} cableMachine{einen Kabelzug} bands{Widerstandsbänder} other{etwas}}",
"exercisePattern": "{pattern, select, squat{Kniebeugen} lunge{Ausfallschritte} hinge{Hüftstreckung} horizontalPush{Drücken} verticalPush{Drücken über Kopf} horizontalPull{Rudern} verticalPull{Klimmzüge und Latzug} curl{Curls} extension{Trizeps} raise{Heben} calf{Waden} core{Rumpf} carry{Tragen} other{Andere}}",

File diff suppressed because it is too large Load diff

View file

@ -1030,6 +1030,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:
@ -2266,35 +2284,59 @@ abstract class AppLocalizations {
/// **'Nightly Recharge'**
String get dayRecharge;
/// No description provided for @dayRechargeLevel.
/// No description provided for @dayRecovery.
///
/// In en, this message translates to:
/// **'Recovery {level} of 6'**
String dayRechargeLevel(int level);
/// **'Recovery'**
String get dayRecovery;
/// No description provided for @dayAnsStatus.
/// No description provided for @dayRecoveryOf.
///
/// In en, this message translates to:
/// **'Autonomic nervous system {value}, from −10 to +10'**
String dayAnsStatus(String value);
/// **'{level} of 6'**
String dayRecoveryOf(int level);
/// No description provided for @dayNightHeart.
/// No description provided for @dayAns.
///
/// In en, this message translates to:
/// **'Heart rate {bpm} bpm, your usual {usual} bpm'**
String dayNightHeart(int bpm, int usual);
/// **'Autonomic nervous system'**
String get dayAns;
/// No description provided for @dayHrv.
/// No description provided for @dayHrvName.
///
/// In en, this message translates to:
/// **'Heart rate variability {ms} ms, your usual {usual} ms'**
String dayHrv(int ms, int usual);
/// **'Heart rate variability'**
String get dayHrvName;
/// No description provided for @dayBreathing.
/// No description provided for @dayBreathingName.
///
/// In en, this message translates to:
/// **'Breathing {rate} a minute'**
String dayBreathing(String rate);
/// **'Breathing'**
String get dayBreathingName;
/// No description provided for @dayPerMinute.
///
/// In en, this message translates to:
/// **'{rate} a minute'**
String dayPerMinute(String rate);
/// No description provided for @dayMs.
///
/// In en, this message translates to:
/// **'{ms} ms'**
String dayMs(int ms);
/// Below a figure of the night: the person's own usual, as Polar learnt it.
///
/// In en, this message translates to:
/// **'usually {value}'**
String dayUsual(String value);
/// No description provided for @dayRechargeHelp.
///
/// In en, this message translates to:
/// **'Nightly Recharge is Polar\'s measure of how your body recovered in the first hours of sleep, against your own usual. Recovery runs from 1 to 6. The autonomic nervous system\'s status runs from −10 to +10: above 0, it settled better than usual. Heart rate variability is how much the time between heartbeats varies; higher than usual tends to mean a better recovery. Breathing counts your breaths a minute. A figure the band has none of yet is left out.'**
String get dayRechargeHelp;
/// No description provided for @daySkin.
///
@ -3520,6 +3562,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:

View file

@ -577,6 +577,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';
@ -1352,29 +1363,40 @@ class AppLocalizationsDe extends AppLocalizations {
String get dayRecharge => 'Nightly Recharge';
@override
String dayRechargeLevel(int level) {
return 'Erholung $level von 6';
String get dayRecovery => 'Erholung';
@override
String dayRecoveryOf(int level) {
return '$level von 6';
}
@override
String dayAnsStatus(String value) {
return 'Autonomes Nervensystem $value, von −10 bis +10';
String get dayAns => 'Autonomes Nervensystem';
@override
String get dayHrvName => 'Herzfrequenzvariabilität';
@override
String get dayBreathingName => 'Atmung';
@override
String dayPerMinute(String rate) {
return '$rate pro Minute';
}
@override
String dayNightHeart(int bpm, int usual) {
return 'Herzfrequenz $bpm S/min, sonst $usual S/min';
String dayMs(int ms) {
return '$ms ms';
}
@override
String dayHrv(int ms, int usual) {
return 'Herzfrequenzvariabilität $ms ms, sonst $usual ms';
String dayUsual(String value) {
return 'sonst $value';
}
@override
String dayBreathing(String rate) {
return '$rate Atemzüge pro Minute';
}
String get dayRechargeHelp =>
'Nightly Recharge ist Polars Maß dafür, wie sich dein Körper in den ersten Stunden des Schlafs erholt hat, gemessen an deinem eigenen Üblichen. Die Erholung reicht von 1 bis 6. Der Status des autonomen Nervensystems reicht von −10 bis +10: über 0 hat es sich besser beruhigt als sonst. Die Herzfrequenzvariabilität ist, wie sehr die Zeit zwischen den Herzschlägen schwankt; höher als sonst spricht meist für eine bessere Erholung. Die Atmung zählt deine Atemzüge pro Minute. Was das Band noch nicht kennt, fehlt.';
@override
String get daySkin => 'Hauttemperatur';
@ -2119,6 +2141,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';

View file

@ -573,6 +573,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';
@ -1345,29 +1356,40 @@ class AppLocalizationsEn extends AppLocalizations {
String get dayRecharge => 'Nightly Recharge';
@override
String dayRechargeLevel(int level) {
return 'Recovery $level of 6';
String get dayRecovery => 'Recovery';
@override
String dayRecoveryOf(int level) {
return '$level of 6';
}
@override
String dayAnsStatus(String value) {
return 'Autonomic nervous system $value, from −10 to +10';
String get dayAns => 'Autonomic nervous system';
@override
String get dayHrvName => 'Heart rate variability';
@override
String get dayBreathingName => 'Breathing';
@override
String dayPerMinute(String rate) {
return '$rate a minute';
}
@override
String dayNightHeart(int bpm, int usual) {
return 'Heart rate $bpm bpm, your usual $usual bpm';
String dayMs(int ms) {
return '$ms ms';
}
@override
String dayHrv(int ms, int usual) {
return 'Heart rate variability $ms ms, your usual $usual ms';
String dayUsual(String value) {
return 'usually $value';
}
@override
String dayBreathing(String rate) {
return 'Breathing $rate a minute';
}
String get dayRechargeHelp =>
'Nightly Recharge is Polar\'s measure of how your body recovered in the first hours of sleep, against your own usual. Recovery runs from 1 to 6. The autonomic nervous system\'s status runs from −10 to +10: above 0, it settled better than usual. Heart rate variability is how much the time between heartbeats varies; higher than usual tends to mean a better recovery. Breathing counts your breaths a minute. A figure the band has none of yet is left out.';
@override
String get daySkin => 'Skin temperature';
@ -2107,6 +2129,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';

View file

@ -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';
@ -106,6 +107,12 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
),
// Where sync stands: Settings' card and the dot on the cog, as
// Learn's and Todo's.
BlocProvider(
create: (context) =>
SyncStatusBloc(PersonScope.of<TrainPerson>(context).sync),
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
@ -132,6 +139,13 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
return PlacesCubit(train.places, train.chosenPlace)..open();
},
),
// Their weights, which Progress and Your body share (D64).
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);

View file

@ -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';
@ -75,6 +77,10 @@ class TrainPerson(
/// Whose they are.
final SignedIn person, {
/// What sends their changes and brings their other devices' (D14):
/// Settings' Sync card and the cog's dot follow it.
required final SyncService sync,
/// Their workouts, sealed and synced.
required final WorkoutRepository workouts,
@ -98,6 +104,10 @@ class TrainPerson(
/// The place last chosen on this device (ADR 0034, D4).
required final ChosenPlaceStore chosenPlace,
/// Their weights (D64), 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,
@ -440,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,
@ -459,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;
@ -496,6 +508,7 @@ Future<TrainPerson> _openPersonData(
: AppLifecycleListener(onResume: () => unawaited(keptMusic.sync()));
return TrainPerson(
person,
sync: sync,
workouts: workouts,
profile: profile,
days: days,
@ -503,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')),
@ -552,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();

View file

@ -1,8 +1,8 @@
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The app's locations (ADR 0030, D60): the five tabs, a day's page in
/// Profile, and a workout's page (D63), the editors and the recording over
/// the tabs, and Settings.
/// The app's locations (ADR 0030, D60): the five tabs, a workout's page
/// (D63) and a day's page (D64), the editors and the recording over the
/// tabs, and Settings.
abstract final class TrainPaths() {
/// Home, where the app opens: the workouts.
static const home = '/';
@ -22,8 +22,9 @@ abstract final class TrainPaths() {
/// A workout's page, in Home, over the tabs (D63).
static String workout(String id) => '/workouts/$id';
/// The page of the day [date], in Profile, by the band's name for it.
static String day(DateTime date) => '/profile/days/${dayKey(date)}';
/// The page of the day [date], over the tabs (D64), by the band's name for
/// it.
static String day(DateTime date) => '/days/${dayKey(date)}';
/// A route, to plan, over the tabs: the stored one [id], or a new one.
static String route([String? id]) => '/routes/${id ?? 'new'}';

View file

@ -84,15 +84,6 @@ GoRouter _router(GlobalKey<NavigatorState> root) => GoRouter(
GoRoute(
path: TrainPaths.profile,
builder: (context, state) => const ProfileTab(),
routes: [
GoRoute(
path: 'days/:date',
builder: (context, state) => dayPage(
context,
dateOfKey(state.pathParameters['date'])!,
),
),
],
),
],
),
@ -114,6 +105,13 @@ GoRouter _router(GlobalKey<NavigatorState> root) => GoRouter(
id: _stored(state),
),
),
// A day's page over the tabs, without the bar, its way back at its top
// left (the developer's review of 3 Nov 2026).
GoRoute(
path: '/days/:date',
builder: (context, state) =>
dayPage(context, dateOfKey(state.pathParameters['date'])!),
),
GoRoute(
path: TrainPaths.recording,
builder: (context, state) => const RecordingScreen(),

Some files were not shown because too many files have changed in this diff Show more