feat(train): routes kept as sealed rows, planned on the map (ADR 0030, D44)

Routes are synced `route` rows (the model `SavedRoute`, as Flutter has a
`Route`), sealed as plans are; the migration `routes` only creates the
table, its space index and its cascade to the sync engine's spaces. A
route's payload holds its name, activity, profile, waypoints and the way
planned, points and elevations delta-coded and gzipped.

Home gets a fourth tab, Routes, and the planner: waypoints tapped on the
map, held and dragged, the last undone, a new route's first where the
phone is (GPS through the recorder's channel), the way planned by the
instance's BRouter after each change, an earlier answer dropped; its
distance, climb, descent, elevation and surfaces under the map.

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-29 00:37:44 +01:00
commit aea99c68ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
52 changed files with 24035 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,853 @@
{
"__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": "day",
"dartName": "Day",
"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": "day_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "day_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "day_series",
"dartName": "DaySeries",
"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": "dayId",
"fieldName": "dayId",
"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": "day_series_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "day_series_fk_1",
"columns": [
"dayId"
],
"referenceTable": "day",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "day_series_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "day_series_day_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "dayId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "night",
"dartName": "Night",
"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": "night_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "night_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
},
{
"__className__": "serverpod.TableDefinition",
"name": "plan",
"dartName": "Plan",
"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": "plan_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "plan_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": "route",
"dartName": "SavedRoute",
"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": "route_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "route_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": "route",
"dartName": "SavedRoute",
"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": "route_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "route_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -8,6 +8,7 @@ part '20261028101012641-workouts/migration.dart';
part '20261028164640000-profile/migration.dart';
part '20261028181405893-days/migration.dart';
part '20261028211508606-plans/migration.dart';
part '20261028231042668-routes/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -18,6 +19,7 @@ class MigrationRegistry {
_Migration20261028164640000_profile(),
_Migration20261028181405893_days(),
_Migration20261028211508606_plans(),
_Migration20261028231042668_routes(),
];
/// List of all client-side database migration versions.

View file

@ -32,6 +32,7 @@ import 'map/map_source.dart' as _ikw0qg8x;
import 'plan/plan.dart' as _i45prw73;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'route/route.dart' as _i0mgeh8t;
import 'routing/planned_route.dart' as _ikjgxraq;
import 'routing/route_hint.dart' as _if6974nb;
import 'routing/route_point.dart' as _isqtfus6;
@ -49,6 +50,7 @@ export 'map/map_source.dart';
export 'plan/plan.dart';
export 'privacy/content_key.dart';
export 'profile/profile.dart';
export 'route/route.dart';
export 'routing/planned_route.dart';
export 'routing/route_hint.dart';
export 'routing/route_point.dart';
@ -494,6 +496,73 @@ class Protocol extends _isd.DatabaseSerializationManager {
],
managed: true,
),
_isd.TableDefinition(
name: 'route',
dartName: 'SavedRoute',
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: 'route_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: 'route_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',
@ -734,6 +803,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ia5j8fch.Profile) {
return _ia5j8fch.Profile.fromJson(data) as T;
}
if (t == _i0mgeh8t.SavedRoute) {
return _i0mgeh8t.SavedRoute.fromJson(data) as T;
}
if (t == _ikjgxraq.PlannedRoute) {
return _ikjgxraq.PlannedRoute.fromJson(data) as T;
}
@ -788,6 +860,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_ia5j8fch.Profile?>()) {
return (data != null ? _ia5j8fch.Profile.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i0mgeh8t.SavedRoute?>()) {
return (data != null ? _i0mgeh8t.SavedRoute.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ikjgxraq.PlannedRoute?>()) {
return (data != null ? _ikjgxraq.PlannedRoute.fromJson(data) : null) as T;
}
@ -869,6 +944,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i45prw73.Plan => 'Plan',
_imed3x5d.ContentKey => 'ContentKey',
_ia5j8fch.Profile => 'Profile',
_i0mgeh8t.SavedRoute => 'SavedRoute',
_ikjgxraq.PlannedRoute => 'PlannedRoute',
_if6974nb.RouteHint => 'RouteHint',
_isqtfus6.RoutePoint => 'RoutePoint',
@ -912,6 +988,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'ContentKey';
case _ia5j8fch.Profile():
return 'Profile';
case _i0mgeh8t.SavedRoute():
return 'SavedRoute';
case _ikjgxraq.PlannedRoute():
return 'PlannedRoute';
case _if6974nb.RouteHint():
@ -991,6 +1069,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Profile') {
return deserialize<_ia5j8fch.Profile>(data['data']);
}
if (dataClassName == 'SavedRoute') {
return deserialize<_i0mgeh8t.SavedRoute>(data['data']);
}
if (dataClassName == 'PlannedRoute') {
return deserialize<_ikjgxraq.PlannedRoute>(data['data']);
}
@ -1106,6 +1187,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return _imed3x5d.ContentKey.t;
case _ia5j8fch.Profile:
return _ia5j8fch.Profile.t;
case _i0mgeh8t.SavedRoute:
return _i0mgeh8t.SavedRoute.t;
case _i5b9b17z.Workout:
return _i5b9b17z.Workout.t;
case _i92mefto.WorkoutSeries:

View file

@ -0,0 +1,670 @@
/* 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 planned route (ADR 0030, D44): its name, activity, profile, waypoints
/// and the route BRouter planned through them, in its sealed payload (D7);
/// the instance sees that it exists, its size and when it changed. Changed
/// when edited, the last writer winning. Synced.
abstract class SavedRoute
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
SavedRoute._({
this.id,
this.spaceId,
this.keyId,
required this.payload,
this.nonce,
});
factory SavedRoute({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) = _SavedRouteImpl;
factory SavedRoute.fromJson(Map<String, dynamic> jsonSerialization) {
return SavedRoute(
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 = SavedRouteTable();
static const db = SavedRouteRepository._();
@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 [SavedRoute]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
SavedRoute copyWith({
_isc.UuidValue? id,
int? spaceId,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'SavedRoute',
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__': 'SavedRoute',
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 SavedRouteInclude include() {
return SavedRouteInclude._();
}
static SavedRouteIncludeList includeList({
_isd.WhereExpressionBuilder<SavedRouteTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<SavedRouteTable>? orderBy,
_isd.OrderByListBuilder<SavedRouteTable>? orderByList,
SavedRouteInclude? include,
}) {
return SavedRouteIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(SavedRoute.t),
orderByList: orderByList?.call(SavedRoute.t),
include: include,
);
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _SavedRouteImpl extends SavedRoute {
_SavedRouteImpl({
_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 [SavedRoute]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
SavedRoute copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
}) {
return SavedRoute(
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 SavedRouteUpdateTable extends _isd.UpdateTable<SavedRouteTable> {
SavedRouteUpdateTable(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 SavedRouteTable extends _isd.Table<_isc.UuidValue?> {
SavedRouteTable({super.tableRelation}) : super(tableName: 'route') {
updateTable = SavedRouteUpdateTable(this);
spaceId = _isd.ColumnInt(
'spaceId',
this,
);
keyId = _isd.ColumnString(
'keyId',
this,
);
payload = _isd.ColumnByteData(
'payload',
this,
);
nonce = _isd.ColumnByteData(
'nonce',
this,
);
}
late final SavedRouteUpdateTable 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 SavedRouteInclude extends _isd.IncludeObject {
SavedRouteInclude._();
@override
Map<String, _isd.Include?> get includes => {};
@override
_isd.Table<_isc.UuidValue?> get table => SavedRoute.t;
}
class SavedRouteIncludeList extends _isd.IncludeList {
SavedRouteIncludeList._({
_isd.WhereExpressionBuilder<SavedRouteTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(SavedRoute.t);
}
@override
Map<String, _isd.Include?> get includes => include?.includes ?? {};
@override
_isd.Table<_isc.UuidValue?> get table => SavedRoute.t;
}
class SavedRouteRepository {
const SavedRouteRepository._();
/// Returns a list of [SavedRoute]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<SavedRoute>> find(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<SavedRouteTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<SavedRouteTable>? orderBy,
_isd.OrderByListBuilder<SavedRouteTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.find<SavedRoute>(
where: where?.call(SavedRoute.t),
orderBy: orderBy?.call(SavedRoute.t),
orderByList: orderByList?.call(SavedRoute.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [SavedRoute] 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<SavedRoute?> findFirstRow(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<SavedRouteTable>? where,
int? offset,
_isd.OrderByBuilder<SavedRouteTable>? orderBy,
_isd.OrderByListBuilder<SavedRouteTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<SavedRoute>(
where: where?.call(SavedRoute.t),
orderBy: orderBy?.call(SavedRoute.t),
orderByList: orderByList?.call(SavedRoute.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [SavedRoute] by its [id] or null if no such row exists.
Future<SavedRoute?> findById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findById<SavedRoute>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [SavedRoute]s in the list and returns the inserted rows.
///
/// The returned [SavedRoute]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<SavedRoute>> insert(
_isd.DatabaseSession session,
List<SavedRoute> rows, {
_isd.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<SavedRoute>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [SavedRoute] and returns the inserted row.
///
/// The returned [SavedRoute] will have its `id` field set.
Future<SavedRoute> insertRow(
_isd.DatabaseSession session,
SavedRoute row, {
_isd.Transaction? transaction,
}) async {
return session.db.insertRow<SavedRoute>(
row,
transaction: transaction,
);
}
/// Upserts all [SavedRoute]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 [SavedRoute]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<SavedRoute>> upsert(
_isd.DatabaseSession session,
List<SavedRoute> rows, {
required _isd.ColumnSelections<SavedRouteTable> conflictColumns,
_isd.ColumnSelections<SavedRouteTable>? updateColumns,
_isd.WhereExpressionBuilder<SavedRouteTable>? updateWhere,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<SavedRoute>(
rows,
conflictColumns: conflictColumns(SavedRoute.t),
updateColumns: updateColumns?.call(SavedRoute.t),
updateWhere: updateWhere?.call(SavedRoute.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [SavedRoute] 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 [SavedRoute] will have its `id` field set.
Future<SavedRoute?> upsertRow(
_isd.DatabaseSession session,
SavedRoute row, {
required _isd.ColumnSelections<SavedRouteTable> conflictColumns,
_isd.ColumnSelections<SavedRouteTable>? updateColumns,
_isd.WhereExpressionBuilder<SavedRouteTable>? updateWhere,
_isd.Transaction? transaction,
}) async {
return session.db.upsertRow<SavedRoute>(
row,
conflictColumns: conflictColumns(SavedRoute.t),
updateColumns: updateColumns?.call(SavedRoute.t),
updateWhere: updateWhere?.call(SavedRoute.t),
transaction: transaction,
);
}
/// Updates all [SavedRoute]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<SavedRoute>> update(
_isd.DatabaseSession session,
List<SavedRoute> rows, {
_isd.ColumnSelections<SavedRouteTable>? columns,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<SavedRoute>(
rows,
columns: columns?.call(SavedRoute.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [SavedRoute]. 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<SavedRoute> updateRow(
_isd.DatabaseSession session,
SavedRoute row, {
_isd.ColumnSelections<SavedRouteTable>? columns,
_isd.Transaction? transaction,
}) async {
return session.db.updateRow<SavedRoute>(
row,
columns: columns?.call(SavedRoute.t),
transaction: transaction,
);
}
/// Updates a single [SavedRoute] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<SavedRoute?> updateById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
required _isd.ColumnValueListBuilder<SavedRouteUpdateTable> columnValues,
_isd.Transaction? transaction,
}) async {
return session.db.updateById<SavedRoute>(
id,
columnValues: columnValues(SavedRoute.t.updateTable),
transaction: transaction,
);
}
/// Updates all [SavedRoute]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<SavedRoute>> updateWhere(
_isd.DatabaseSession session, {
required _isd.ColumnValueListBuilder<SavedRouteUpdateTable> columnValues,
required _isd.WhereExpressionBuilder<SavedRouteTable> where,
int? limit,
int? offset,
_isd.OrderByBuilder<SavedRouteTable>? orderBy,
_isd.OrderByListBuilder<SavedRouteTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<SavedRoute>(
columnValues: columnValues(SavedRoute.t.updateTable),
where: where(SavedRoute.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(SavedRoute.t),
orderByList: orderByList?.call(SavedRoute.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [SavedRoute]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<SavedRoute>> delete(
_isd.DatabaseSession session,
List<SavedRoute> rows, {
_isd.OrderByBuilder<SavedRouteTable>? orderBy,
_isd.OrderByListBuilder<SavedRouteTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<SavedRoute>(
rows,
orderBy: orderBy?.call(SavedRoute.t),
orderByList: orderByList?.call(SavedRoute.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [SavedRoute].
Future<SavedRoute> deleteRow(
_isd.DatabaseSession session,
SavedRoute row, {
_isd.Transaction? transaction,
}) async {
return session.db.deleteRow<SavedRoute>(
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<SavedRoute>> deleteWhere(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<SavedRouteTable> where,
_isd.OrderByBuilder<SavedRouteTable>? orderBy,
_isd.OrderByListBuilder<SavedRouteTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<SavedRoute>(
where: where(SavedRoute.t),
orderBy: orderBy?.call(SavedRoute.t),
orderByList: orderByList?.call(SavedRoute.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<SavedRouteTable>? where,
int? limit,
_isd.Transaction? transaction,
}) async {
return session.db.count<SavedRoute>(
where: where?.call(SavedRoute.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [SavedRoute] rows matching the [where] expression.
Future<void> lockRows(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<SavedRouteTable> where,
required _isd.LockMode lockMode,
required _isd.Transaction transaction,
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
}) async {
return session.db.lockRows<SavedRoute>(
where: where(SavedRoute.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -17,6 +17,7 @@ import 'day/night.dart' as _i17cgxdf;
import 'plan/plan.dart' as _i45prw73;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'route/route.dart' as _i0mgeh8t;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
@ -30,6 +31,7 @@ final List<_isd.Table> syncTables = [
_i17cgxdf.Night.t,
_i45prw73.Plan.t,
_ia5j8fch.Profile.t,
_i0mgeh8t.SavedRoute.t,
_i5b9b17z.Workout.t,
_i92mefto.WorkoutSeries.t,
];

View file

@ -3,8 +3,14 @@ package app.halkyon.train
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
@ -67,6 +73,7 @@ class MainActivity : FlutterActivity() {
"stop" -> result.success(RecorderService.stop().let { null })
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
"isRecording" -> result.success(RecorderService.isRecording())
"position" -> position(result)
else -> result.notImplemented()
}
}
@ -166,7 +173,59 @@ class MainActivity : FlutterActivity() {
else -> emptyList()
}
/**
* Where the phone is, for a route's first waypoint (ADR 0030, D44): GPS's
* last fix where it is at most two minutes old, else a new one, waited
* for 15 s at most; null without the location permission or a fix.
*/
@Suppress("MissingPermission") // Checked first.
private fun position(result: MethodChannel.Result) {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
result.success(null)
return
}
val manager = getSystemService(LocationManager::class.java)
var answered = false
fun answer(location: Location?) {
if (answered) return
answered = true
result.success(location?.let { mapOf("lat" to it.latitude, "lon" to it.longitude) })
}
val last = manager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (last != null && System.currentTimeMillis() - last.time <= FRESH_MS) {
answer(last)
return
}
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
answer(last)
return
}
val handler = Handler(Looper.getMainLooper())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancel = CancellationSignal()
handler.postDelayed({
cancel.cancel()
answer(last)
}, WAIT_MS)
manager.getCurrentLocation(LocationManager.GPS_PROVIDER, cancel, mainExecutor) { answer(it ?: last) }
} else {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
manager.removeUpdates(this)
answer(location)
}
}
handler.postDelayed({
manager.removeUpdates(listener)
answer(last)
}, WAIT_MS)
manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, listener, Looper.getMainLooper())
}
}
private companion object {
const val RECORDER_REQUEST = 1
const val FRESH_MS = 120_000L
const val WAIT_MS = 15_000L
}
}

View file

@ -376,5 +376,45 @@
"dayBreathing": "{rate} Atemzüge pro Minute",
"daySkin": "Hauttemperatur",
"dayCelsius": "{value} °C",
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst"
"daySkinDeviation": "{value} °C gegenüber deinen Nächten sonst",
"homeRoutes": "Routen",
"routesNew": "Neue Route",
"routesEmptyTitle": "Noch keine Routen",
"routesEmptyHint": "Tipp auf die Karte, um Wegpunkte zu setzen, und deine Instanz plant den Weg dazwischen über Wege und Straßen, mit Anstieg und Untergrund.",
"routeLocked": "Eine Route, die sich hier nicht öffnet",
"routeTitle": "Route",
"routeNewTitle": "Neue Route",
"routeName": "Name",
"routeActivity": "Für",
"routeActivityAny": "Jede Aktivität",
"routeProfile": "Geplant für",
"routeProfileRun": "Laufen",
"routeProfileHike": "Wandern",
"routeProfileRoadBike": "Rennrad",
"routeProfileTrekkingBike": "Trekkingrad",
"routeProfileGravel": "Gravelbike",
"routeProfileMtb": "Mountainbike",
"routeTapHint": "Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.",
"routeUndo": "Letzten Wegpunkt entfernen",
"routePlanning": "Wird geplant …",
"routeRefusedInvalid": "Ein Wegpunkt zu viel, oder einer außerhalb der Karte.",
"routeRefusedTooOften": "Zu oft geplant: versuch es in einer Minute wieder.",
"routeRefusedNoRoute": "Dort wurde kein Weg gefunden: Ein Wegpunkt liegt vielleicht abseits der Wege oder außerhalb des Gebiets, in dem deine Instanz plant.",
"routeRefusedUnreachable": "Deine Instanz ist nicht erreichbar, um den Weg zu planen.",
"routeNotOffered": "Deine Instanz plant keine Routen.",
"routeAscent": "{metres} m bergauf",
"routeDescent": "{metres} m bergab",
"routeSummary": "{distance} · {ascent}",
"routeSurfaces": "Untergrund",
"surfacePaved": "Befestigt",
"surfaceCompacted": "Verdichtet",
"surfaceGravel": "Schotter",
"surfaceNatural": "Naturboden",
"surfaceUnknown": "Unbekannt",
"routeSave": "Speichern",
"routeNotSaved": "Die Route wurde nicht gespeichert.",
"routeDelete": "Löschen",
"routeDeleteTitle": "Diese Route löschen?",
"routeDeleteMessage": "Sie wird auf all deinen Geräten gelöscht.",
"routeDeleted": "Route gelöscht"
}

View file

@ -481,5 +481,48 @@
"dayCelsius": "{value} °C",
"@dayCelsius": {"placeholders": {"value": {"type": "String", "example": "33.4"}}},
"daySkinDeviation": "{value} °C from your usual at night",
"@daySkinDeviation": {"placeholders": {"value": {"type": "String", "example": "−0.2"}}}
"@daySkinDeviation": {"placeholders": {"value": {"type": "String", "example": "−0.2"}}},
"homeRoutes": "Routes",
"routesNew": "New route",
"routesEmptyTitle": "No routes yet",
"routesEmptyHint": "Tap the map to set waypoints, and your instance plans the way between them on paths and roads, with its climb and surfaces.",
"routeLocked": "A route that doesn't open here",
"routeTitle": "Route",
"routeNewTitle": "New route",
"routeName": "Name",
"routeActivity": "For",
"routeActivityAny": "Any activity",
"routeProfile": "Planned for",
"routeProfileRun": "Running",
"routeProfileHike": "Hiking",
"routeProfileRoadBike": "Road bike",
"routeProfileTrekkingBike": "Trekking bike",
"routeProfileGravel": "Gravel bike",
"routeProfileMtb": "Mountain bike",
"routeTapHint": "Tap the map to add a waypoint; hold one and drag it to move it.",
"routeUndo": "Take the last waypoint off",
"routePlanning": "Planning…",
"routeRefusedInvalid": "A waypoint too many, or one off the map.",
"routeRefusedTooOften": "Planned too often: try again in a minute.",
"routeRefusedNoRoute": "No way found there: a waypoint may be off the paths, or outside the region your instance plans in.",
"routeRefusedUnreachable": "Your instance can't be reached to plan the way.",
"routeNotOffered": "Your instance doesn't plan routes.",
"routeAscent": "{metres} m up",
"@routeAscent": {"placeholders": {"metres": {"type": "int"}}},
"routeDescent": "{metres} m down",
"@routeDescent": {"placeholders": {"metres": {"type": "int"}}},
"routeSummary": "{distance} · {ascent}",
"@routeSummary": {"description": "A route in the list: its distance and climb.", "placeholders": {"distance": {"type": "String", "example": "12.3 km"}, "ascent": {"type": "String", "example": "120 m up"}}},
"routeSurfaces": "Surfaces",
"surfacePaved": "Paved",
"surfaceCompacted": "Compacted",
"surfaceGravel": "Gravel",
"surfaceNatural": "Natural ground",
"surfaceUnknown": "Not known",
"routeSave": "Save",
"routeNotSaved": "The route wasn't saved.",
"routeDelete": "Delete",
"routeDeleteTitle": "Delete this route?",
"routeDeleteMessage": "It's deleted on all your devices.",
"routeDeleted": "Route deleted"
}

View file

@ -2283,6 +2283,246 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{value} °C from your usual at night'**
String daySkinDeviation(String value);
/// No description provided for @homeRoutes.
///
/// In en, this message translates to:
/// **'Routes'**
String get homeRoutes;
/// No description provided for @routesNew.
///
/// In en, this message translates to:
/// **'New route'**
String get routesNew;
/// No description provided for @routesEmptyTitle.
///
/// In en, this message translates to:
/// **'No routes yet'**
String get routesEmptyTitle;
/// No description provided for @routesEmptyHint.
///
/// In en, this message translates to:
/// **'Tap the map to set waypoints, and your instance plans the way between them on paths and roads, with its climb and surfaces.'**
String get routesEmptyHint;
/// No description provided for @routeLocked.
///
/// In en, this message translates to:
/// **'A route that doesn\'t open here'**
String get routeLocked;
/// No description provided for @routeTitle.
///
/// In en, this message translates to:
/// **'Route'**
String get routeTitle;
/// No description provided for @routeNewTitle.
///
/// In en, this message translates to:
/// **'New route'**
String get routeNewTitle;
/// No description provided for @routeName.
///
/// In en, this message translates to:
/// **'Name'**
String get routeName;
/// No description provided for @routeActivity.
///
/// In en, this message translates to:
/// **'For'**
String get routeActivity;
/// No description provided for @routeActivityAny.
///
/// In en, this message translates to:
/// **'Any activity'**
String get routeActivityAny;
/// No description provided for @routeProfile.
///
/// In en, this message translates to:
/// **'Planned for'**
String get routeProfile;
/// No description provided for @routeProfileRun.
///
/// In en, this message translates to:
/// **'Running'**
String get routeProfileRun;
/// No description provided for @routeProfileHike.
///
/// In en, this message translates to:
/// **'Hiking'**
String get routeProfileHike;
/// No description provided for @routeProfileRoadBike.
///
/// In en, this message translates to:
/// **'Road bike'**
String get routeProfileRoadBike;
/// No description provided for @routeProfileTrekkingBike.
///
/// In en, this message translates to:
/// **'Trekking bike'**
String get routeProfileTrekkingBike;
/// No description provided for @routeProfileGravel.
///
/// In en, this message translates to:
/// **'Gravel bike'**
String get routeProfileGravel;
/// No description provided for @routeProfileMtb.
///
/// In en, this message translates to:
/// **'Mountain bike'**
String get routeProfileMtb;
/// No description provided for @routeTapHint.
///
/// In en, this message translates to:
/// **'Tap the map to add a waypoint; hold one and drag it to move it.'**
String get routeTapHint;
/// No description provided for @routeUndo.
///
/// In en, this message translates to:
/// **'Take the last waypoint off'**
String get routeUndo;
/// No description provided for @routePlanning.
///
/// In en, this message translates to:
/// **'Planning…'**
String get routePlanning;
/// No description provided for @routeRefusedInvalid.
///
/// In en, this message translates to:
/// **'A waypoint too many, or one off the map.'**
String get routeRefusedInvalid;
/// No description provided for @routeRefusedTooOften.
///
/// In en, this message translates to:
/// **'Planned too often: try again in a minute.'**
String get routeRefusedTooOften;
/// No description provided for @routeRefusedNoRoute.
///
/// In en, this message translates to:
/// **'No way found there: a waypoint may be off the paths, or outside the region your instance plans in.'**
String get routeRefusedNoRoute;
/// No description provided for @routeRefusedUnreachable.
///
/// In en, this message translates to:
/// **'Your instance can\'t be reached to plan the way.'**
String get routeRefusedUnreachable;
/// No description provided for @routeNotOffered.
///
/// In en, this message translates to:
/// **'Your instance doesn\'t plan routes.'**
String get routeNotOffered;
/// No description provided for @routeAscent.
///
/// In en, this message translates to:
/// **'{metres} m up'**
String routeAscent(int metres);
/// No description provided for @routeDescent.
///
/// In en, this message translates to:
/// **'{metres} m down'**
String routeDescent(int metres);
/// A route in the list: its distance and climb.
///
/// In en, this message translates to:
/// **'{distance} · {ascent}'**
String routeSummary(String distance, String ascent);
/// No description provided for @routeSurfaces.
///
/// In en, this message translates to:
/// **'Surfaces'**
String get routeSurfaces;
/// No description provided for @surfacePaved.
///
/// In en, this message translates to:
/// **'Paved'**
String get surfacePaved;
/// No description provided for @surfaceCompacted.
///
/// In en, this message translates to:
/// **'Compacted'**
String get surfaceCompacted;
/// No description provided for @surfaceGravel.
///
/// In en, this message translates to:
/// **'Gravel'**
String get surfaceGravel;
/// No description provided for @surfaceNatural.
///
/// In en, this message translates to:
/// **'Natural ground'**
String get surfaceNatural;
/// No description provided for @surfaceUnknown.
///
/// In en, this message translates to:
/// **'Not known'**
String get surfaceUnknown;
/// No description provided for @routeSave.
///
/// In en, this message translates to:
/// **'Save'**
String get routeSave;
/// No description provided for @routeNotSaved.
///
/// In en, this message translates to:
/// **'The route wasn\'t saved.'**
String get routeNotSaved;
/// No description provided for @routeDelete.
///
/// In en, this message translates to:
/// **'Delete'**
String get routeDelete;
/// No description provided for @routeDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete this route?'**
String get routeDeleteTitle;
/// No description provided for @routeDeleteMessage.
///
/// In en, this message translates to:
/// **'It\'s deleted on all your devices.'**
String get routeDeleteMessage;
/// No description provided for @routeDeleted.
///
/// In en, this message translates to:
/// **'Route deleted'**
String get routeDeleted;
}
class _AppLocalizationsDelegate

View file

@ -1367,4 +1367,136 @@ class AppLocalizationsDe extends AppLocalizations {
String daySkinDeviation(String value) {
return '$value °C gegenüber deinen Nächten sonst';
}
@override
String get homeRoutes => 'Routen';
@override
String get routesNew => 'Neue Route';
@override
String get routesEmptyTitle => 'Noch keine Routen';
@override
String get routesEmptyHint =>
'Tipp auf die Karte, um Wegpunkte zu setzen, und deine Instanz plant den Weg dazwischen über Wege und Straßen, mit Anstieg und Untergrund.';
@override
String get routeLocked => 'Eine Route, die sich hier nicht öffnet';
@override
String get routeTitle => 'Route';
@override
String get routeNewTitle => 'Neue Route';
@override
String get routeName => 'Name';
@override
String get routeActivity => 'Für';
@override
String get routeActivityAny => 'Jede Aktivität';
@override
String get routeProfile => 'Geplant für';
@override
String get routeProfileRun => 'Laufen';
@override
String get routeProfileHike => 'Wandern';
@override
String get routeProfileRoadBike => 'Rennrad';
@override
String get routeProfileTrekkingBike => 'Trekkingrad';
@override
String get routeProfileGravel => 'Gravelbike';
@override
String get routeProfileMtb => 'Mountainbike';
@override
String get routeTapHint =>
'Tipp auf die Karte, um einen Wegpunkt hinzuzufügen; halt einen gedrückt und zieh ihn, um ihn zu verschieben.';
@override
String get routeUndo => 'Letzten Wegpunkt entfernen';
@override
String get routePlanning => 'Wird geplant …';
@override
String get routeRefusedInvalid =>
'Ein Wegpunkt zu viel, oder einer außerhalb der Karte.';
@override
String get routeRefusedTooOften =>
'Zu oft geplant: versuch es in einer Minute wieder.';
@override
String get routeRefusedNoRoute =>
'Dort wurde kein Weg gefunden: Ein Wegpunkt liegt vielleicht abseits der Wege oder außerhalb des Gebiets, in dem deine Instanz plant.';
@override
String get routeRefusedUnreachable =>
'Deine Instanz ist nicht erreichbar, um den Weg zu planen.';
@override
String get routeNotOffered => 'Deine Instanz plant keine Routen.';
@override
String routeAscent(int metres) {
return '$metres m bergauf';
}
@override
String routeDescent(int metres) {
return '$metres m bergab';
}
@override
String routeSummary(String distance, String ascent) {
return '$distance · $ascent';
}
@override
String get routeSurfaces => 'Untergrund';
@override
String get surfacePaved => 'Befestigt';
@override
String get surfaceCompacted => 'Verdichtet';
@override
String get surfaceGravel => 'Schotter';
@override
String get surfaceNatural => 'Naturboden';
@override
String get surfaceUnknown => 'Unbekannt';
@override
String get routeSave => 'Speichern';
@override
String get routeNotSaved => 'Die Route wurde nicht gespeichert.';
@override
String get routeDelete => 'Löschen';
@override
String get routeDeleteTitle => 'Diese Route löschen?';
@override
String get routeDeleteMessage => 'Sie wird auf all deinen Geräten gelöscht.';
@override
String get routeDeleted => 'Route gelöscht';
}

View file

@ -1360,6 +1360,137 @@ class AppLocalizationsEn extends AppLocalizations {
String daySkinDeviation(String value) {
return '$value °C from your usual at night';
}
@override
String get homeRoutes => 'Routes';
@override
String get routesNew => 'New route';
@override
String get routesEmptyTitle => 'No routes yet';
@override
String get routesEmptyHint =>
'Tap the map to set waypoints, and your instance plans the way between them on paths and roads, with its climb and surfaces.';
@override
String get routeLocked => 'A route that doesn\'t open here';
@override
String get routeTitle => 'Route';
@override
String get routeNewTitle => 'New route';
@override
String get routeName => 'Name';
@override
String get routeActivity => 'For';
@override
String get routeActivityAny => 'Any activity';
@override
String get routeProfile => 'Planned for';
@override
String get routeProfileRun => 'Running';
@override
String get routeProfileHike => 'Hiking';
@override
String get routeProfileRoadBike => 'Road bike';
@override
String get routeProfileTrekkingBike => 'Trekking bike';
@override
String get routeProfileGravel => 'Gravel bike';
@override
String get routeProfileMtb => 'Mountain bike';
@override
String get routeTapHint =>
'Tap the map to add a waypoint; hold one and drag it to move it.';
@override
String get routeUndo => 'Take the last waypoint off';
@override
String get routePlanning => 'Planning…';
@override
String get routeRefusedInvalid => 'A waypoint too many, or one off the map.';
@override
String get routeRefusedTooOften =>
'Planned too often: try again in a minute.';
@override
String get routeRefusedNoRoute =>
'No way found there: a waypoint may be off the paths, or outside the region your instance plans in.';
@override
String get routeRefusedUnreachable =>
'Your instance can\'t be reached to plan the way.';
@override
String get routeNotOffered => 'Your instance doesn\'t plan routes.';
@override
String routeAscent(int metres) {
return '$metres m up';
}
@override
String routeDescent(int metres) {
return '$metres m down';
}
@override
String routeSummary(String distance, String ascent) {
return '$distance · $ascent';
}
@override
String get routeSurfaces => 'Surfaces';
@override
String get surfacePaved => 'Paved';
@override
String get surfaceCompacted => 'Compacted';
@override
String get surfaceGravel => 'Gravel';
@override
String get surfaceNatural => 'Natural ground';
@override
String get surfaceUnknown => 'Not known';
@override
String get routeSave => 'Save';
@override
String get routeNotSaved => 'The route wasn\'t saved.';
@override
String get routeDelete => 'Delete';
@override
String get routeDeleteTitle => 'Delete this route?';
@override
String get routeDeleteMessage => 'It\'s deleted on all your devices.';
@override
String get routeDeleted => 'Route deleted';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -11,6 +11,7 @@ import 'package:halkyon_train_flutter/src/feature/plan/bloc/plans_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/routes_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/workout/bloc/workouts_cubit.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -100,6 +101,12 @@ class const TrainPersonApp({
return PlansCubit(train.plans, train.planDeletions)..open();
},
),
BlocProvider(
create: (context) {
final train = PersonScope.of<TrainPerson>(context);
return RoutesCubit(train.routes, train.routeDeletions)..open();
},
),
],
child: _Recording(child: child!),
),

View file

@ -26,6 +26,9 @@ import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gate
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';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/sync_route_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:package_info_plus/package_info_plus.dart';
@ -67,6 +70,12 @@ class TrainPerson(
/// Their interval plans (D42), sealed and synced.
required final PlanRepository plans,
/// Their routes (D44), sealed and synced.
required final RouteRepository routes,
/// The instance's planning of routes (D44).
required final RoutingGateway routing,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -93,11 +102,18 @@ class TrainPerson(
/// Plans deleted, to be undone for a moment, as workouts are.
late final planDeletions = DelayedDeletions(plans.delete, logName: 'Plans');
/// Routes deleted, to be undone for a moment, as workouts are.
late final routeDeletions = DelayedDeletions(
routes.delete,
logName: 'Routes',
);
/// Closes what was opened for the person.
@override
Future<void> dispose() async {
await deletions.dispose();
await planDeletions.dispose();
await routeDeletions.dispose();
await health?.dispose();
await loop?.dispose();
await _onDispose?.call();
@ -340,6 +356,7 @@ Future<TrainPerson> _openPersonData(
person: person.authUserId,
);
final plans = PlanRepository(SyncPlanGateway(db, sync), keys);
final routes = RouteRepository(SyncRouteGateway(db, sync), keys);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -367,6 +384,8 @@ Future<TrainPerson> _openPersonData(
profile: profile,
days: days,
plans: plans,
routes: routes,
routing: ServerpodRoutingGateway(client),
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: recorder != null
? (
@ -384,6 +403,7 @@ Future<TrainPerson> _openPersonData(
await profile.dispose();
await days.dispose();
await plans.dispose();
await routes.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -69,6 +69,13 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
Future<bool> isRecording() async =>
await _channel.invokeMethod<bool>('isRecording') ?? false;
@override
Future<({double lat, double lon})?> position() async =>
switch (await _channel.invokeMapMethod<String, double>('position')) {
{'lat': final lat, 'lon': final lon} => (lat: lat, lon: lon),
_ => null,
};
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();

View file

@ -124,6 +124,10 @@ abstract interface class RecorderPlatform() {
/// Whether a recording runs, as after the app was gone and came back.
Future<bool> isRecording();
/// Where the phone is, for a route's first waypoint (D44); null without
/// the location permission ([ask] first) or a fix within 15 s.
Future<({double lat, double lon})?> position();
/// Every line of the file written so far, then each new one as it is
/// written, while a recording runs.
Stream<String> lines();

View file

@ -0,0 +1,188 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// A route being planned or changed (ADR 0030, D44): waypoints tapped,
/// dragged and the last undone, a new route's first where the phone is
/// (`here`, null where the device can't tell); the instance plans the way
/// through them after each change, an answer to an earlier change dropped
/// once a later one is asked. Kept once named and planned; [id] null for a
/// new one.
class RouteEditorCubit(
final RouteRepository _routes,
final RoutingGateway _routing, {
required final RouteContent route,
final String? id,
final Future<LatLng?> Function()? _here,
}) extends Cubit<RouteEditorState> {
this : super(RouteEditorState(route: route, id: id));
/// Counts the plans asked, so that only the latest's answer counts.
var _asked = 0;
/// Asks whether the instance plans routes, and starts a new route where
/// the phone is.
Future<void> open() async {
final offered = await _routing.offered();
if (isClosed) {
return;
}
emit(state._with(route: state.route, offered: offered));
if (!offered || state.id != null || state.route.waypoints.isNotEmpty) {
return;
}
final here = await _here?.call();
if (here != null && !isClosed && state.route.waypoints.isEmpty) {
emit(state._with(route: _route(waypoints: [here])));
}
}
void rename(String name) => emit(state._with(route: _route(name: name)));
/// Sets what the route is for, and the profile that goes with it.
Future<void> setActivity(Activity? activity) => _replan(
RouteContent(
name: state.route.name,
activity: activity,
profile: RouteProfile.of(activity),
waypoints: state.route.waypoints,
),
);
Future<void> setProfile(RouteProfile profile) =>
_replan(_route(profile: profile));
/// Adds a waypoint at the end.
Future<void> add(LatLng point) =>
_replan(_route(waypoints: [...state.route.waypoints, point]));
/// Moves waypoint [index] to [point] (dragged).
Future<void> move(int index, LatLng point) =>
_replan(_route(waypoints: [...state.route.waypoints]..[index] = point));
/// Takes the last waypoint off.
Future<void> undo() async {
final waypoints = state.route.waypoints;
if (waypoints.isNotEmpty) {
await _replan(
_route(waypoints: waypoints.sublist(0, waypoints.length - 1)),
);
}
}
/// Keeps the route; false where it can't be (no name, no way planned, or
/// this device can't seal).
Future<bool> save() async {
if (!state.canSave) {
return false;
}
final named = _route(name: state.route.name.trim(), keepPlanned: true);
try {
if (state.id case final id?) {
await _routes.update(id, named);
} else {
final id = await _routes.save(named);
if (!isClosed) {
emit(RouteEditorState(route: named, id: id, offered: state.offered));
}
}
} on KeysMissing {
return false;
}
return true;
}
/// [route], its way planned anew where it has two waypoints at least.
Future<void> _replan(RouteContent route) async {
final asked = ++_asked;
if (route.waypoints.length < 2) {
emit(state._with(route: route, planning: false));
return;
}
emit(state._with(route: route, planning: true));
RouteGeometry? planned;
RoutingRefusal? refusal;
try {
planned = await _routing.plan(route.waypoints, route.profile);
} on RoutingRefused catch (refused) {
refusal = refused.reason;
}
if (asked != _asked || isClosed) {
return;
}
emit(
RouteEditorState(
route: RouteContent(
name: state.route.name,
activity: route.activity,
profile: route.profile,
waypoints: route.waypoints,
planned: planned,
),
id: state.id,
offered: state.offered,
refusal: refusal,
),
);
}
/// The route with what is given changed; its way planned dropped unless
/// [keepPlanned] or only the name changes, as it is planned again.
RouteContent _route({
String? name,
RouteProfile? profile,
List<LatLng>? waypoints,
bool keepPlanned = false,
}) {
final route = state.route;
return RouteContent(
name: name ?? route.name,
activity: route.activity,
profile: profile ?? route.profile,
waypoints: waypoints ?? route.waypoints,
planned: keepPlanned || (profile == null && waypoints == null)
? route.planned
: null,
);
}
}
final class const RouteEditorState({
required final RouteContent route,
final String? id,
/// Whether the instance plans routes; null until asked.
final bool? offered,
/// Whether the instance is planning the way after a change.
final bool planning = false,
/// Why the instance planned no way after the last change; null: it did,
/// or nothing was asked.
final RoutingRefusal? refusal,
}) with Equatable {
/// Whether it has what a route needs: a name and a way planned.
bool get canSave =>
route.name.trim().isNotEmpty && route.planned != null && !planning;
/// A refusal stays only until the next change is planned.
RouteEditorState _with({
required RouteContent route,
bool? planning,
bool? offered,
}) => RouteEditorState(
route: route,
id: id,
offered: offered ?? this.offered,
planning: planning ?? this.planning,
refusal: (planning ?? false) || route.waypoints.length < 2 ? null : refusal,
);
@override
List<Object?> get props => [route, id, offered, planning, refusal];
}

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