feat(train): the body's figures in a sealed, synced profile (ADR 0030, D33, D34)

A person's profile, one row per person (its ID derived from theirs),
sealed like a workout: sex, birth date, height, weight, resting and
maximum heart rate, VO2max, training background, typical day and sleep
goal, each optional. "Your body" in the account sheet edits it; the
Loop's setup is to reuse its form. The migration only adds the synced
table `profile`, deleted with the person's sync space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 18:02:04 +01:00
commit de6fe5c15a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
36 changed files with 20185 additions and 9 deletions

View file

@ -0,0 +1,401 @@
{
"__className__": "serverpod.DatabaseDefinition",
"schemaVersion": 2,
"moduleName": "halkyon_train",
"tables": [
{
"__className__": "serverpod.TableDefinition",
"name": "content_key",
"dartName": "ContentKey",
"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": "dataKeyId",
"fieldName": "dataKeyId",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sealedKey",
"fieldName": "sealedKey",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "content_key_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "content_key_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "profile",
"dartName": "Profile",
"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": "profile_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "profile_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "workout",
"dartName": "Workout",
"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": "workout_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "workout_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "workout_series",
"dartName": "WorkoutSeries",
"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": "workoutId",
"fieldName": "workoutId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__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": "workout_series_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "workout_series_fk_1",
"columns": [
"workoutId"
],
"referenceTable": "workout",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "workout_series_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "workout_series_workout_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "workoutId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
],
"installedModules": [
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_base",
"version": "20261026140329453-assistant-budgets"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod",
"version": "20260824182259319"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_idp",
"version": "20260924105404509"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_offline_sync",
"version": "20260914143806119"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "halkyon_oidc",
"version": "20261023180644988"
},
{
"__className__": "serverpod.DatabaseMigrationVersion",
"module": "serverpod_auth_core",
"version": "20260924105232991"
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,94 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "profile",
"dartName": "Profile",
"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": "profile_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "profile_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -5,6 +5,7 @@ library;
import 'package:serverpod_database/serverpod_database.dart';
part '20261028101012641-workouts/migration.dart';
part '20261028164640000-profile/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -12,6 +13,7 @@ class MigrationRegistry {
/// New migrations are always appended at the end.
static final List<MigrationVersionSql> migrations = [
_Migration20261028101012641_workouts(),
_Migration20261028164640000_profile(),
];
/// List of all client-side database migration versions.

View file

@ -0,0 +1,673 @@
/* 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 person's profile (ADR 0030, D33, D34): the figures of their body that the
/// Polar Loop's setup needs and calories and heart-rate zones will (sex as
/// Polar's formulas take it, birth date, height, weight, resting and highest
/// heart rate, VO2max, training background, typical day, sleep goal), each
/// optional, all in its sealed payload (D7). One row per person, its ID made
/// from theirs on the device, so two devices write the same row, the last
/// writer winning. Synced.
abstract class Profile
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
Profile._({
this.id,
this.spaceId,
this.keyId,
required this.payload,
this.nonce,
});
factory Profile({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) = _ProfileImpl;
factory Profile.fromJson(Map<String, dynamic> jsonSerialization) {
return Profile(
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 = ProfileTable();
static const db = ProfileRepository._();
@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 [Profile]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
Profile copyWith({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'Profile',
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__': 'Profile',
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 ProfileInclude include() {
return ProfileInclude._();
}
static ProfileIncludeList includeList({
_isd.WhereExpressionBuilder<ProfileTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<ProfileTable>? orderBy,
_isd.OrderByListBuilder<ProfileTable>? orderByList,
ProfileInclude? include,
}) {
return ProfileIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(Profile.t),
orderByList: orderByList?.call(Profile.t),
include: include,
);
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ProfileImpl extends Profile {
_ProfileImpl({
_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 [Profile]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
Profile copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
}) {
return Profile(
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 ProfileUpdateTable extends _isd.UpdateTable<ProfileTable> {
ProfileUpdateTable(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 ProfileTable extends _isd.Table<_isc.UuidValue?> {
ProfileTable({super.tableRelation}) : super(tableName: 'profile') {
updateTable = ProfileUpdateTable(this);
spaceId = _isd.ColumnInt(
'spaceId',
this,
);
keyId = _isd.ColumnString(
'keyId',
this,
);
payload = _isd.ColumnByteData(
'payload',
this,
);
nonce = _isd.ColumnByteData(
'nonce',
this,
);
}
late final ProfileUpdateTable 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 ProfileInclude extends _isd.IncludeObject {
ProfileInclude._();
@override
Map<String, _isd.Include?> get includes => {};
@override
_isd.Table<_isc.UuidValue?> get table => Profile.t;
}
class ProfileIncludeList extends _isd.IncludeList {
ProfileIncludeList._({
_isd.WhereExpressionBuilder<ProfileTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(Profile.t);
}
@override
Map<String, _isd.Include?> get includes => include?.includes ?? {};
@override
_isd.Table<_isc.UuidValue?> get table => Profile.t;
}
class ProfileRepository {
const ProfileRepository._();
/// Returns a list of [Profile]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<Profile>> find(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<ProfileTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<ProfileTable>? orderBy,
_isd.OrderByListBuilder<ProfileTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.find<Profile>(
where: where?.call(Profile.t),
orderBy: orderBy?.call(Profile.t),
orderByList: orderByList?.call(Profile.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [Profile] 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<Profile?> findFirstRow(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<ProfileTable>? where,
int? offset,
_isd.OrderByBuilder<ProfileTable>? orderBy,
_isd.OrderByListBuilder<ProfileTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<Profile>(
where: where?.call(Profile.t),
orderBy: orderBy?.call(Profile.t),
orderByList: orderByList?.call(Profile.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [Profile] by its [id] or null if no such row exists.
Future<Profile?> findById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findById<Profile>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [Profile]s in the list and returns the inserted rows.
///
/// The returned [Profile]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<Profile>> insert(
_isd.DatabaseSession session,
List<Profile> rows, {
_isd.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<Profile>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [Profile] and returns the inserted row.
///
/// The returned [Profile] will have its `id` field set.
Future<Profile> insertRow(
_isd.DatabaseSession session,
Profile row, {
_isd.Transaction? transaction,
}) async {
return session.db.insertRow<Profile>(
row,
transaction: transaction,
);
}
/// Upserts all [Profile]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 [Profile]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<Profile>> upsert(
_isd.DatabaseSession session,
List<Profile> rows, {
required _isd.ColumnSelections<ProfileTable> conflictColumns,
_isd.ColumnSelections<ProfileTable>? updateColumns,
_isd.WhereExpressionBuilder<ProfileTable>? updateWhere,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<Profile>(
rows,
conflictColumns: conflictColumns(Profile.t),
updateColumns: updateColumns?.call(Profile.t),
updateWhere: updateWhere?.call(Profile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [Profile] 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 [Profile] will have its `id` field set.
Future<Profile?> upsertRow(
_isd.DatabaseSession session,
Profile row, {
required _isd.ColumnSelections<ProfileTable> conflictColumns,
_isd.ColumnSelections<ProfileTable>? updateColumns,
_isd.WhereExpressionBuilder<ProfileTable>? updateWhere,
_isd.Transaction? transaction,
}) async {
return session.db.upsertRow<Profile>(
row,
conflictColumns: conflictColumns(Profile.t),
updateColumns: updateColumns?.call(Profile.t),
updateWhere: updateWhere?.call(Profile.t),
transaction: transaction,
);
}
/// Updates all [Profile]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<Profile>> update(
_isd.DatabaseSession session,
List<Profile> rows, {
_isd.ColumnSelections<ProfileTable>? columns,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<Profile>(
rows,
columns: columns?.call(Profile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [Profile]. 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<Profile> updateRow(
_isd.DatabaseSession session,
Profile row, {
_isd.ColumnSelections<ProfileTable>? columns,
_isd.Transaction? transaction,
}) async {
return session.db.updateRow<Profile>(
row,
columns: columns?.call(Profile.t),
transaction: transaction,
);
}
/// Updates a single [Profile] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<Profile?> updateById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
required _isd.ColumnValueListBuilder<ProfileUpdateTable> columnValues,
_isd.Transaction? transaction,
}) async {
return session.db.updateById<Profile>(
id,
columnValues: columnValues(Profile.t.updateTable),
transaction: transaction,
);
}
/// Updates all [Profile]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<Profile>> updateWhere(
_isd.DatabaseSession session, {
required _isd.ColumnValueListBuilder<ProfileUpdateTable> columnValues,
required _isd.WhereExpressionBuilder<ProfileTable> where,
int? limit,
int? offset,
_isd.OrderByBuilder<ProfileTable>? orderBy,
_isd.OrderByListBuilder<ProfileTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<Profile>(
columnValues: columnValues(Profile.t.updateTable),
where: where(Profile.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(Profile.t),
orderByList: orderByList?.call(Profile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [Profile]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<Profile>> delete(
_isd.DatabaseSession session,
List<Profile> rows, {
_isd.OrderByBuilder<ProfileTable>? orderBy,
_isd.OrderByListBuilder<ProfileTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<Profile>(
rows,
orderBy: orderBy?.call(Profile.t),
orderByList: orderByList?.call(Profile.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [Profile].
Future<Profile> deleteRow(
_isd.DatabaseSession session,
Profile row, {
_isd.Transaction? transaction,
}) async {
return session.db.deleteRow<Profile>(
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<Profile>> deleteWhere(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<ProfileTable> where,
_isd.OrderByBuilder<ProfileTable>? orderBy,
_isd.OrderByListBuilder<ProfileTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<Profile>(
where: where(Profile.t),
orderBy: orderBy?.call(Profile.t),
orderByList: orderByList?.call(Profile.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<ProfileTable>? where,
int? limit,
_isd.Transaction? transaction,
}) async {
return session.db.count<Profile>(
where: where?.call(Profile.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [Profile] rows matching the [where] expression.
Future<void> lockRows(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<ProfileTable> where,
required _isd.LockMode lockMode,
required _isd.Transaction transaction,
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
}) async {
return session.db.lockRows<Profile>(
where: where(Profile.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -23,10 +23,12 @@ import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart
as _ipulbpi2;
import 'map/map_source.dart' as _ikw0qg8x;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'map/map_source.dart';
export 'privacy/content_key.dart';
export 'profile/profile.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
export 'client.dart';
@ -101,6 +103,73 @@ class Protocol extends _isd.DatabaseSerializationManager {
],
managed: true,
),
_isd.TableDefinition(
name: 'profile',
dartName: 'Profile',
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: 'profile_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: 'profile_space_idx',
tableSpace: null,
elements: [
_isd.IndexElementDefinition(
type: _isd.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isd.TableDefinition(
name: 'workout',
dartName: 'Workout',
@ -320,6 +389,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _imed3x5d.ContentKey) {
return _imed3x5d.ContentKey.fromJson(data) as T;
}
if (t == _ia5j8fch.Profile) {
return _ia5j8fch.Profile.fromJson(data) as T;
}
if (t == _i5b9b17z.Workout) {
return _i5b9b17z.Workout.fromJson(data) as T;
}
@ -332,6 +404,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_imed3x5d.ContentKey?>()) {
return (data != null ? _imed3x5d.ContentKey.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ia5j8fch.Profile?>()) {
return (data != null ? _ia5j8fch.Profile.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i5b9b17z.Workout?>()) {
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
}
@ -361,6 +436,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
return switch (type) {
_ikw0qg8x.MapSource => 'MapSource',
_imed3x5d.ContentKey => 'ContentKey',
_ia5j8fch.Profile => 'Profile',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
_ => null,
@ -384,6 +460,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'MapSource';
case _imed3x5d.ContentKey():
return 'ContentKey';
case _ia5j8fch.Profile():
return 'Profile';
case _i5b9b17z.Workout():
return 'Workout';
case _i92mefto.WorkoutSeries():
@ -430,6 +508,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'ContentKey') {
return deserialize<_imed3x5d.ContentKey>(data['data']);
}
if (dataClassName == 'Profile') {
return deserialize<_ia5j8fch.Profile>(data['data']);
}
if (dataClassName == 'Workout') {
return deserialize<_i5b9b17z.Workout>(data['data']);
}
@ -517,6 +598,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
switch (t) {
case _imed3x5d.ContentKey:
return _imed3x5d.ContentKey.t;
case _ia5j8fch.Profile:
return _ia5j8fch.Profile.t;
case _i5b9b17z.Workout:
return _i5b9b17z.Workout.t;
case _i92mefto.WorkoutSeries:

View file

@ -12,6 +12,7 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_database/serverpod_database.dart' as _isd;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
@ -20,6 +21,7 @@ import 'workout/workout_series.dart' as _i92mefto;
/// shared packages.
final List<_isd.Table> syncTables = [
_imed3x5d.ContentKey.t,
_ia5j8fch.Profile.t,
_i5b9b17z.Workout.t,
_i92mefto.WorkoutSeries.t,
];

View file

@ -153,5 +153,40 @@
"workoutFromList": "von {app}",
"workoutFrom": "Von {app}, über Health Connect",
"workoutBringTrack": "Strecke holen",
"workoutNoTrack": "Health Connect hat keine Strecke dazu gegeben."
"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.",
"profileLocked": "Dein Profil ist mit einem Schlüssel versiegelt, den dieses Gerät nicht hat.",
"profileSex": "Geschlecht",
"profileSexHint": "Wie Polars Formeln es nehmen: das, dem die Werte deines Körpers folgen",
"profileSexFemale": "Weiblich",
"profileSexMale": "Männlich",
"profileNotGiven": "Keine Angabe",
"profileBirthDate": "Geburtsdatum",
"profileHeight": "Größe",
"profileHeightUnit": "cm",
"profileWeight": "Gewicht",
"profileWeightUnit": "kg",
"profileRestingHeartRate": "Ruhepuls",
"profileMaxHeartRate": "Höchste Herzfrequenz",
"profileBpmUnit": "S/min",
"profileVo2Max": "VO2max",
"profileVo2MaxUnit": "ml/kg/min",
"profileTrainingBackground": "Trainingshintergrund",
"trainingOccasional": "Gelegentlich",
"trainingRegular": "Regelmäßig",
"trainingFrequent": "Häufig",
"trainingHeavy": "Intensiv",
"trainingSemiPro": "Halbprofi",
"trainingPro": "Profi",
"profileTypicalDay": "Typischer Tag",
"typicalDaySitting": "Meist sitzend",
"typicalDayStanding": "Meist stehend",
"typicalDayMoving": "Meist in Bewegung",
"profileSleepGoal": "Schlafziel",
"profileSave": "Speichern",
"profileSaved": "Gespeichert",
"profileInvalid": "Prüfe die markierten Angaben."
}

View file

@ -203,5 +203,41 @@
"@workoutFrom": {"description": "On an imported workout's page.", "placeholders": {"app": {"type": "String", "example": "Gadgetbridge"}}},
"workoutBringTrack": "Bring in the track",
"@workoutBringTrack": {"description": "On an imported workout's page: asks Health Connect for its route."},
"workoutNoTrack": "Health Connect gave no track for it."
"workoutNoTrack": "Health Connect gave no track for it.",
"profileTitle": "Your body",
"profileHint": "What your band and your heart-rate zones work from",
"@profileHint": {"description": "Under Your body in the account sheet."},
"profileIntro": "Each figure is optional and sealed with your key, as your workouts are; your instance can't read them.",
"profileLocked": "Your profile is sealed with a key this device doesn't hold.",
"profileSex": "Sex",
"profileSexHint": "As Polar's formulas take it: the one your body's figures follow",
"profileSexFemale": "Female",
"profileSexMale": "Male",
"profileNotGiven": "Not given",
"profileBirthDate": "Birth date",
"profileHeight": "Height",
"profileHeightUnit": "cm",
"profileWeight": "Weight",
"profileWeightUnit": "kg",
"profileRestingHeartRate": "Resting heart rate",
"profileMaxHeartRate": "Highest heart rate",
"profileBpmUnit": "bpm",
"profileVo2Max": "VO2max",
"profileVo2MaxUnit": "ml/kg/min",
"profileTrainingBackground": "Training background",
"trainingOccasional": "Occasional",
"trainingRegular": "Regular",
"trainingFrequent": "Frequent",
"trainingHeavy": "Heavy",
"trainingSemiPro": "Semi-pro",
"trainingPro": "Pro",
"profileTypicalDay": "Typical day",
"typicalDaySitting": "Mostly sitting",
"typicalDayStanding": "Mostly standing",
"typicalDayMoving": "Mostly moving",
"profileSleepGoal": "Sleep goal",
"profileSave": "Save",
"profileSaved": "Saved",
"profileInvalid": "Check the figures marked."
}

View file

@ -957,6 +957,210 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Health Connect gave no track for it.'**
String get workoutNoTrack;
/// No description provided for @profileTitle.
///
/// In en, this message translates to:
/// **'Your body'**
String get profileTitle;
/// Under Your body in the account sheet.
///
/// In en, this message translates to:
/// **'What your band and your heart-rate zones work from'**
String get profileHint;
/// No description provided for @profileIntro.
///
/// In en, this message translates to:
/// **'Each figure is optional and sealed with your key, as your workouts are; your instance can\'t read them.'**
String get profileIntro;
/// No description provided for @profileLocked.
///
/// In en, this message translates to:
/// **'Your profile is sealed with a key this device doesn\'t hold.'**
String get profileLocked;
/// No description provided for @profileSex.
///
/// In en, this message translates to:
/// **'Sex'**
String get profileSex;
/// No description provided for @profileSexHint.
///
/// In en, this message translates to:
/// **'As Polar\'s formulas take it: the one your body\'s figures follow'**
String get profileSexHint;
/// No description provided for @profileSexFemale.
///
/// In en, this message translates to:
/// **'Female'**
String get profileSexFemale;
/// No description provided for @profileSexMale.
///
/// In en, this message translates to:
/// **'Male'**
String get profileSexMale;
/// No description provided for @profileNotGiven.
///
/// In en, this message translates to:
/// **'Not given'**
String get profileNotGiven;
/// No description provided for @profileBirthDate.
///
/// In en, this message translates to:
/// **'Birth date'**
String get profileBirthDate;
/// No description provided for @profileHeight.
///
/// In en, this message translates to:
/// **'Height'**
String get profileHeight;
/// No description provided for @profileHeightUnit.
///
/// In en, this message translates to:
/// **'cm'**
String get profileHeightUnit;
/// No description provided for @profileWeight.
///
/// In en, this message translates to:
/// **'Weight'**
String get profileWeight;
/// No description provided for @profileWeightUnit.
///
/// In en, this message translates to:
/// **'kg'**
String get profileWeightUnit;
/// No description provided for @profileRestingHeartRate.
///
/// In en, this message translates to:
/// **'Resting heart rate'**
String get profileRestingHeartRate;
/// No description provided for @profileMaxHeartRate.
///
/// In en, this message translates to:
/// **'Highest heart rate'**
String get profileMaxHeartRate;
/// No description provided for @profileBpmUnit.
///
/// In en, this message translates to:
/// **'bpm'**
String get profileBpmUnit;
/// No description provided for @profileVo2Max.
///
/// In en, this message translates to:
/// **'VO2max'**
String get profileVo2Max;
/// No description provided for @profileVo2MaxUnit.
///
/// In en, this message translates to:
/// **'ml/kg/min'**
String get profileVo2MaxUnit;
/// No description provided for @profileTrainingBackground.
///
/// In en, this message translates to:
/// **'Training background'**
String get profileTrainingBackground;
/// No description provided for @trainingOccasional.
///
/// In en, this message translates to:
/// **'Occasional'**
String get trainingOccasional;
/// No description provided for @trainingRegular.
///
/// In en, this message translates to:
/// **'Regular'**
String get trainingRegular;
/// No description provided for @trainingFrequent.
///
/// In en, this message translates to:
/// **'Frequent'**
String get trainingFrequent;
/// No description provided for @trainingHeavy.
///
/// In en, this message translates to:
/// **'Heavy'**
String get trainingHeavy;
/// No description provided for @trainingSemiPro.
///
/// In en, this message translates to:
/// **'Semi-pro'**
String get trainingSemiPro;
/// No description provided for @trainingPro.
///
/// In en, this message translates to:
/// **'Pro'**
String get trainingPro;
/// No description provided for @profileTypicalDay.
///
/// In en, this message translates to:
/// **'Typical day'**
String get profileTypicalDay;
/// No description provided for @typicalDaySitting.
///
/// In en, this message translates to:
/// **'Mostly sitting'**
String get typicalDaySitting;
/// No description provided for @typicalDayStanding.
///
/// In en, this message translates to:
/// **'Mostly standing'**
String get typicalDayStanding;
/// No description provided for @typicalDayMoving.
///
/// In en, this message translates to:
/// **'Mostly moving'**
String get typicalDayMoving;
/// No description provided for @profileSleepGoal.
///
/// In en, this message translates to:
/// **'Sleep goal'**
String get profileSleepGoal;
/// No description provided for @profileSave.
///
/// In en, this message translates to:
/// **'Save'**
String get profileSave;
/// No description provided for @profileSaved.
///
/// In en, this message translates to:
/// **'Saved'**
String get profileSaved;
/// No description provided for @profileInvalid.
///
/// In en, this message translates to:
/// **'Check the figures marked.'**
String get profileInvalid;
}
class _AppLocalizationsDelegate

View file

@ -531,4 +531,110 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workoutNoTrack => 'Health Connect hat keine Strecke dazu gegeben.';
@override
String get profileTitle => 'Dein Körper';
@override
String get profileHint =>
'Woraus dein Armband und deine Herzfrequenzzonen rechnen';
@override
String get profileIntro =>
'Jede Angabe ist freiwillig und mit deinem Schlüssel versiegelt, wie deine Trainings; deine Instanz kann sie nicht lesen.';
@override
String get profileLocked =>
'Dein Profil ist mit einem Schlüssel versiegelt, den dieses Gerät nicht hat.';
@override
String get profileSex => 'Geschlecht';
@override
String get profileSexHint =>
'Wie Polars Formeln es nehmen: das, dem die Werte deines Körpers folgen';
@override
String get profileSexFemale => 'Weiblich';
@override
String get profileSexMale => 'Männlich';
@override
String get profileNotGiven => 'Keine Angabe';
@override
String get profileBirthDate => 'Geburtsdatum';
@override
String get profileHeight => 'Größe';
@override
String get profileHeightUnit => 'cm';
@override
String get profileWeight => 'Gewicht';
@override
String get profileWeightUnit => 'kg';
@override
String get profileRestingHeartRate => 'Ruhepuls';
@override
String get profileMaxHeartRate => 'Höchste Herzfrequenz';
@override
String get profileBpmUnit => 'S/min';
@override
String get profileVo2Max => 'VO2max';
@override
String get profileVo2MaxUnit => 'ml/kg/min';
@override
String get profileTrainingBackground => 'Trainingshintergrund';
@override
String get trainingOccasional => 'Gelegentlich';
@override
String get trainingRegular => 'Regelmäßig';
@override
String get trainingFrequent => 'Häufig';
@override
String get trainingHeavy => 'Intensiv';
@override
String get trainingSemiPro => 'Halbprofi';
@override
String get trainingPro => 'Profi';
@override
String get profileTypicalDay => 'Typischer Tag';
@override
String get typicalDaySitting => 'Meist sitzend';
@override
String get typicalDayStanding => 'Meist stehend';
@override
String get typicalDayMoving => 'Meist in Bewegung';
@override
String get profileSleepGoal => 'Schlafziel';
@override
String get profileSave => 'Speichern';
@override
String get profileSaved => 'Gespeichert';
@override
String get profileInvalid => 'Prüfe die markierten Angaben.';
}

View file

@ -527,6 +527,112 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutNoTrack => 'Health Connect gave no track for it.';
@override
String get profileTitle => 'Your body';
@override
String get profileHint =>
'What your band and your heart-rate zones work from';
@override
String get profileIntro =>
'Each figure is optional and sealed with your key, as your workouts are; your instance can\'t read them.';
@override
String get profileLocked =>
'Your profile is sealed with a key this device doesn\'t hold.';
@override
String get profileSex => 'Sex';
@override
String get profileSexHint =>
'As Polar\'s formulas take it: the one your body\'s figures follow';
@override
String get profileSexFemale => 'Female';
@override
String get profileSexMale => 'Male';
@override
String get profileNotGiven => 'Not given';
@override
String get profileBirthDate => 'Birth date';
@override
String get profileHeight => 'Height';
@override
String get profileHeightUnit => 'cm';
@override
String get profileWeight => 'Weight';
@override
String get profileWeightUnit => 'kg';
@override
String get profileRestingHeartRate => 'Resting heart rate';
@override
String get profileMaxHeartRate => 'Highest heart rate';
@override
String get profileBpmUnit => 'bpm';
@override
String get profileVo2Max => 'VO2max';
@override
String get profileVo2MaxUnit => 'ml/kg/min';
@override
String get profileTrainingBackground => 'Training background';
@override
String get trainingOccasional => 'Occasional';
@override
String get trainingRegular => 'Regular';
@override
String get trainingFrequent => 'Frequent';
@override
String get trainingHeavy => 'Heavy';
@override
String get trainingSemiPro => 'Semi-pro';
@override
String get trainingPro => 'Pro';
@override
String get profileTypicalDay => 'Typical day';
@override
String get typicalDaySitting => 'Mostly sitting';
@override
String get typicalDayStanding => 'Mostly standing';
@override
String get typicalDayMoving => 'Mostly moving';
@override
String get profileSleepGoal => 'Sleep goal';
@override
String get profileSave => 'Save';
@override
String get profileSaved => 'Saved';
@override
String get profileInvalid => 'Check the figures marked.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -3,6 +3,7 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -43,6 +44,14 @@ class const _AccountSheet() extends StatelessWidget {
children: [SimpleLoginSection(), AccountSecuritySection()],
),
),
// The figures of the person's body (ADR 0030, D33), here until
// Train has settings.
ListTile(
leading: const Icon(LucideIcons.personStanding),
title: Text(l10n.profileTitle),
subtitle: Text(l10n.profileHint),
onTap: () => openProfileScreen(context),
),
// Health Connect on this phone (ADR 0030, D31), here until Train
// has settings.
if (PersonScope.of<TrainPerson>(context).health != null)

View file

@ -13,6 +13,8 @@ import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dar
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -47,6 +49,9 @@ class TrainPerson(
/// Their workouts, sealed and synced.
required final WorkoutRepository workouts,
/// The figures of their body (D33), sealed and synced.
required final ProfileRepository profile,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -300,6 +305,11 @@ Future<TrainPerson> _openPersonData(
);
await keys.refresh();
final workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
final profile = ProfileRepository(
SyncProfileGateway(db, sync),
keys,
person: person.authUserId,
);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -311,6 +321,7 @@ Future<TrainPerson> _openPersonData(
return TrainPerson(
person,
workouts: workouts,
profile: profile,
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: Platform.isAndroid
? (
@ -324,6 +335,7 @@ Future<TrainPerson> _openPersonData(
onDispose: () async {
try {
await workouts.dispose();
await profile.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -0,0 +1,48 @@
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/profile/model/profile.dart';
/// Where *Your body* stands.
enum ProfilePhase() {
loading,
loaded,
locked,
saved,
failed,
}
/// What *Your body* shows (ADR 0030, D33).
final class const ProfileState({
final ProfilePhase phase = ProfilePhase.loading,
final ProfileContent profile = const ProfileContent(),
}) with Equatable {
@override
List<Object?> get props => [phase, profile];
}
/// Reads the person's profile and keeps what they changed.
class ProfileCubit(final ProfileRepository _profile)
extends Cubit<ProfileState> {
this : super(const ProfileState());
Future<void> open() async {
final profile = await _profile.read();
if (!isClosed) {
emit(
profile == null
? const ProfileState(phase: ProfilePhase.locked)
: ProfileState(phase: ProfilePhase.loaded, profile: profile),
);
}
}
Future<void> save(ProfileContent profile) async {
try {
await _profile.save(profile);
emit(ProfileState(phase: ProfilePhase.saved, profile: profile));
} on Object {
emit(ProfileState(phase: ProfilePhase.failed, profile: state.profile));
}
}
}

View file

@ -0,0 +1,14 @@
import 'package:halkyon_app/halkyon_app.dart';
/// Where the person's profile is kept: their synced `profile` row. The
/// repository seals and opens; this only keeps.
abstract interface class ProfileGateway() {
/// The envelope of the row [id]; null where there is none.
Future<Envelope?> read(String id);
/// Keeps [envelope] as the row [id], made where there is none.
Future<void> write(String id, Envelope envelope);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,67 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
/// The schema of a profile's payload this app writes and reads.
const profileSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, the birth
/// date as `YYYY-MM-DD`, the sleep goal in minutes, a figure not given left
/// out.
ByteData encodeProfile(ProfileContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': profileSchemaVersion,
'sex': ?content.sex?.name,
if (content.birthDate case final born?)
'birthDate': born.toIso8601String().substring(0, 10),
'heightCm': ?content.heightCm,
'weightKg': ?content.weightKg,
'restingHeartRate': ?content.restingHeartRate,
'maxHeartRate': ?content.maxHeartRate,
'vo2Max': ?content.vo2Max,
'trainingBackground': ?content.trainingBackground?.name,
'typicalDay': ?content.typicalDay?.name,
'sleepGoalMinutes': ?content.sleepGoal?.inMinutes,
}),
),
),
);
/// The profile [payload] holds, opened; null for one of a newer schema or
/// one it can't read. A value it doesn't know is no value.
ProfileContent? decodeProfile(ByteData payload) {
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > profileSchemaVersion) {
log('A profile of schema $version skipped', name: 'Profile');
return null;
}
final sleep = json['sleepGoalMinutes'] as int?;
return ProfileContent(
sex: Sex.values.asNameMap()[json['sex']],
birthDate: switch (json['birthDate']) {
final String date => DateTime.parse('${date}T00:00:00Z'),
_ => null,
},
heightCm: json['heightCm'] as int?,
weightKg: (json['weightKg'] as num?)?.toDouble(),
restingHeartRate: json['restingHeartRate'] as int?,
maxHeartRate: json['maxHeartRate'] as int?,
vo2Max: json['vo2Max'] as int?,
trainingBackground: TrainingBackground.values
.asNameMap()[json['trainingBackground']],
typicalDay: TypicalDay.values.asNameMap()[json['typicalDay']],
sleepGoal: sleep == null ? null : Duration(minutes: sleep),
);
} on Object catch (error) {
log('A profile not read: ${error.runtimeType}', name: 'Profile');
return null;
}
}

View file

@ -0,0 +1,72 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart'
show Namespace, Uuid;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_payload.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
/// The person's profile (ADR 0030, D33), sealed with their key as their
/// workouts are (D7), in one row whose ID is made from theirs, so that
/// every device of theirs writes the same one.
class ProfileRepository(
final ProfileGateway _gateway,
final TrainKeys _keys, {
/// Whose profile it is: their auth user ID.
required final String person,
}) {
late final String _id = const Uuid().v5(
Namespace.url.value,
'https://halkyon.app/train/profile/$person',
);
late final _envelope = PayloadEnvelope(_keys.cipher);
StreamSubscription<void>? _remote;
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
/// Fires when the profile changed, here or on another device.
Stream<void> get changes => _changes.stream;
/// The profile; an empty one where none was written, null where one is
/// there that doesn't open on this device.
Future<ProfileContent?> read() async {
await _keys.refresh();
final envelope = await _gateway.read(_id);
if (envelope == null) {
return const ProfileContent();
}
if (envelope.keyId == null) {
// Train writes nothing unsealed: a readable row is the instance's.
return null;
}
return switch (_envelope.open(envelope, table: 'profile', id: _id)) {
Readable(:final payload) => decodeProfile(payload),
Locked() => null,
};
}
/// Keeps [content] as the profile; throws [KeysMissing] where this device
/// can't seal.
Future<void> save(ProfileContent content) async {
final keyId = await _keys.keyToWrite();
await _gateway.write(
_id,
_envelope.seal(
encodeProfile(content),
keyId: keyId,
table: 'profile',
id: _id,
),
);
_changes.add(null);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
}

View file

@ -0,0 +1,49 @@
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/profile/data/profile_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ProfileGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncProfileGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ProfileGateway {
@override
Future<Envelope?> read(String id) async => switch (await protocol.Profile.db
.findById(_db, protocol.UuidValue.withValidation(id))) {
final row? => (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
null => null,
};
@override
Future<void> write(String id, Envelope envelope) async {
final uuid = protocol.UuidValue.withValidation(id);
if (await protocol.Profile.db.findById(_db, uuid) case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Profile.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
} else {
await protocol.Profile.db.insertRow(
_db,
protocol.Profile(
id: uuid,
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
);
}
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

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