Compare commits
35
Commits
0979af288d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f07667e1f7 | ||
|
|
0f167108fc | ||
|
|
630416ee3c | ||
|
|
a94ebf3653 | ||
|
|
31b2a115ec | ||
|
|
1fcacb2eb6 | ||
|
|
6394954afe | ||
|
|
682d0c7eea | ||
|
|
9ded984543 | ||
|
|
5e797c32e7 | ||
|
|
7e3b95be8e | ||
|
|
122aa443aa | ||
|
|
dffee4a5f4 | ||
|
|
078cb84031 | ||
|
|
92dcc08d59 | ||
|
|
cad7f80607 | ||
|
|
8c8bb9125b | ||
|
|
43e2fac10f | ||
|
|
ca9259d5bd | ||
|
|
2096489fd1 | ||
|
|
2bb4225260 | ||
|
|
f14c2e5827 | ||
|
|
b648878414 | ||
|
|
debfcf3843 | ||
|
|
522337d332 | ||
|
|
070429627b | ||
|
|
7f2fe0c300 | ||
|
|
42e2f5fd47 | ||
|
|
098db881bb | ||
|
|
a17ae0dea5 | ||
|
|
383f641ac2 | ||
|
|
4a07556a5d | ||
|
|
8f96a12b18 | ||
|
|
1387d6bbdb | ||
|
|
8512bee27a |
@@ -73,7 +73,7 @@ targets/wearos/ # WearOS app (Kotlin/Compose) — survives prebuil
|
|||||||
## Database
|
## Database
|
||||||
|
|
||||||
- **expo-sqlite** + **Drizzle ORM** (SQLite dialect, expo driver)
|
- **expo-sqlite** + **Drizzle ORM** (SQLite dialect, expo driver)
|
||||||
- Schema: `src/db/schema.ts` — tables: templates, workouts, sets, laps
|
- Schema: `src/db/schema.ts` — tables: templates, workouts, workoutSessions, sets, laps (laps references workoutSessions, not workouts)
|
||||||
- Relations: `src/db/relations.ts`
|
- Relations: `src/db/relations.ts`
|
||||||
- CRUD: `src/db/operations.ts`
|
- CRUD: `src/db/operations.ts`
|
||||||
- Client: `src/db/client.ts` — exports `db` and `DATABASE_NAME`
|
- Client: `src/db/client.ts` — exports `db` and `DATABASE_NAME`
|
||||||
@@ -103,10 +103,6 @@ These are hard-won bugs that will waste hours if rediscovered:
|
|||||||
- Templates and Stats features removed from navigation (DB schema preserved but UI deleted)
|
- Templates and Stats features removed from navigation (DB schema preserved but UI deleted)
|
||||||
- Ionicons used for bottom tab icons (`@expo/vector-icons`)
|
- Ionicons used for bottom tab icons (`@expo/vector-icons`)
|
||||||
|
|
||||||
## NativeWind v5 TextInput Crash
|
|
||||||
|
|
||||||
`react-native-css` has a bug where the `textAlign` mapping on `TextInput` causes a crash (`path.split` on boolean `true`). The fix is in `src/utils/` — don't remove the workaround without testing TextInput rendering.
|
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
|
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import "./global.css";
|
import "./global.css";
|
||||||
import "./src/i18n";
|
import "./src/i18n";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { NavigationContainer } from "@react-navigation/native";
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
@@ -10,6 +12,8 @@ import { useWatchWorkoutSync } from "./src/features/watch-connectivity/hooks/use
|
|||||||
import { useSystemLanguageListener } from "./src/i18n";
|
import { useSystemLanguageListener } from "./src/i18n";
|
||||||
import * as Sentry from "@sentry/react-native";
|
import * as Sentry from "@sentry/react-native";
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: "https://6197bcf085d63727d829aa89ede8e973@o4507430203293696.ingest.de.sentry.io/4511875297706064",
|
dsn: "https://6197bcf085d63727d829aa89ede8e973@o4507430203293696.ingest.de.sentry.io/4511875297706064",
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
@@ -27,6 +31,10 @@ function SystemLanguageListener() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
SplashScreen.hideAsync();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<DatabaseProvider>
|
<DatabaseProvider>
|
||||||
|
|||||||
@@ -21,7 +21,23 @@
|
|||||||
],
|
],
|
||||||
"com.apple.developer.healthkit": true
|
"com.apple.developer.healthkit": true
|
||||||
},
|
},
|
||||||
"appleTeamId": "7TABQZ5458"
|
"appleTeamId": "7TABQZ5458",
|
||||||
|
"infoPlist": {
|
||||||
|
"CFBundleLocalizations": [
|
||||||
|
"en",
|
||||||
|
"nl",
|
||||||
|
"de",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"es",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"da"
|
||||||
|
],
|
||||||
|
"ITSAppUsesNonExemptEncryption": false,
|
||||||
|
"NSHealthShareUsageDescription": "SwimBuddy needs access to your health data to track your swim workouts.",
|
||||||
|
"NSHealthUpdateUsageDescription": "SwimBuddy needs to save your swim workout data to Health."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -40,33 +56,58 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
"@bacons/apple-targets",
|
"@bacons/apple-targets",
|
||||||
["expo-localization", {
|
[
|
||||||
"supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"]
|
"expo-localization",
|
||||||
}],
|
{
|
||||||
|
"supportedLocales": [
|
||||||
|
"en",
|
||||||
|
"nl",
|
||||||
|
"de",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"es",
|
||||||
|
"pl",
|
||||||
|
"pt",
|
||||||
|
"da"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"@sentry/react-native/expo",
|
"@sentry/react-native/expo",
|
||||||
"expo-updates",
|
"expo-updates",
|
||||||
"expo-system-ui"
|
"expo-system-ui",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#5BC0F8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@sentry/react-native",
|
||||||
|
"expo-font"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
|
"projectId": "7b88af8c-86a3-4c08-b186-09a90e2bdfcc",
|
||||||
"build": {
|
"build": {
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"ios": {
|
"ios": {
|
||||||
"appExtensions": [
|
"appExtensions": [
|
||||||
{
|
{
|
||||||
"bundleIdentifier": "nl.guido-it.swimbuddy.watch",
|
"bundleIdentifier": "nl.guido-it.swimbuddy.watch",
|
||||||
"targetName": "SwimBuddy",
|
"targetName": "SwimBuddyWatch",
|
||||||
"entitlements": {
|
"entitlements": {
|
||||||
"com.apple.security.application-groups": [
|
"com.apple.security.application-groups": [
|
||||||
"group.nl.guido-it.swimbuddy"
|
"group.nl.guido-it.swimbuddy"
|
||||||
]
|
],
|
||||||
|
"com.apple.developer.healthkit": true,
|
||||||
|
"com.apple.developer.healthkit.access": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"projectId": "7b88af8c-86a3-4c08-b186-09a90e2bdfcc"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "guido-it"
|
"owner": "guido-it"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
CREATE TABLE `workout_sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`workout_id` text,
|
||||||
|
`name` text,
|
||||||
|
`pool_length` real DEFAULT 25 NOT NULL,
|
||||||
|
`started_at` text NOT NULL,
|
||||||
|
`finished_at` text,
|
||||||
|
`total_distance` integer,
|
||||||
|
`total_duration` real,
|
||||||
|
`calories` real,
|
||||||
|
`avg_heart_rate` integer,
|
||||||
|
`source` text DEFAULT 'manual' NOT NULL,
|
||||||
|
FOREIGN KEY (`workout_id`) REFERENCES `workouts`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `workout_sessions` (`id`, `workout_id`, `name`, `pool_length`, `started_at`, `finished_at`, `total_distance`, `total_duration`, `calories`, `avg_heart_rate`, `source`)
|
||||||
|
SELECT `id`, `id`, `name`, `pool_length`, `started_at`, `finished_at`, `total_distance`, `total_duration`, `calories`, `avg_heart_rate`, `source`
|
||||||
|
FROM `workouts`
|
||||||
|
WHERE `finished_at` IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `laps` RENAME COLUMN `workout_id` TO `session_id`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `started_at`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `finished_at`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `total_distance`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `total_duration`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `calories`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `avg_heart_rate`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` DROP COLUMN `source`;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_laps` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`session_id` text NOT NULL,
|
||||||
|
`set_position` integer NOT NULL,
|
||||||
|
`rep_number` integer NOT NULL,
|
||||||
|
`distance` integer NOT NULL,
|
||||||
|
`duration` real NOT NULL,
|
||||||
|
`stroke_count` integer,
|
||||||
|
`heart_rate` integer,
|
||||||
|
`swolf` real,
|
||||||
|
FOREIGN KEY (`session_id`) REFERENCES `workout_sessions`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_laps`("id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf") SELECT "id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf" FROM `laps`;--> statement-breakpoint
|
||||||
|
DROP TABLE `laps`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_laps` RENAME TO `laps`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_workout_sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`workout_id` text,
|
||||||
|
`name` text,
|
||||||
|
`pool_length` real DEFAULT 25 NOT NULL,
|
||||||
|
`started_at` text NOT NULL,
|
||||||
|
`finished_at` text,
|
||||||
|
`total_distance` integer,
|
||||||
|
`total_duration` real,
|
||||||
|
`calories` real,
|
||||||
|
`avg_heart_rate` integer,
|
||||||
|
`source` text DEFAULT 'manual' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_workout_sessions`("id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source") SELECT "id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source" FROM `workout_sessions`;--> statement-breakpoint
|
||||||
|
DROP TABLE `workout_sessions`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_workout_sessions` RENAME TO `workout_sessions`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `workouts` ADD `created_at` text NOT NULL;
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "1a621e4a-ea8a-4479-a98c-0f02fc8e14e3",
|
||||||
|
"prevId": "a9688cc9-9314-4d59-9b68-b7e202f3e3ff",
|
||||||
|
"tables": {
|
||||||
|
"laps": {
|
||||||
|
"name": "laps",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"name": "workout_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"set_position": {
|
||||||
|
"name": "set_position",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rep_number": {
|
||||||
|
"name": "rep_number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"name": "distance",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "duration",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stroke_count": {
|
||||||
|
"name": "stroke_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"heart_rate": {
|
||||||
|
"name": "heart_rate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"swolf": {
|
||||||
|
"name": "swolf",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"laps_workout_id_workouts_id_fk": {
|
||||||
|
"name": "laps_session_id_workout_sessions_id_fk",
|
||||||
|
"tableFrom": "laps",
|
||||||
|
"tableTo": "workout_sessions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sets": {
|
||||||
|
"name": "sets",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"workout_id": {
|
||||||
|
"name": "workout_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"name": "position",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stroke": {
|
||||||
|
"name": "stroke",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"distance_per_rep": {
|
||||||
|
"name": "distance_per_rep",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rep_count": {
|
||||||
|
"name": "rep_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rest_type": {
|
||||||
|
"name": "rest_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'fixed_rest'"
|
||||||
|
},
|
||||||
|
"rest_duration": {
|
||||||
|
"name": "rest_duration",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"target_time": {
|
||||||
|
"name": "target_time",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sets_workout_id_workouts_id_fk": {
|
||||||
|
"name": "sets_workout_id_workouts_id_fk",
|
||||||
|
"tableFrom": "sets",
|
||||||
|
"tableTo": "workouts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"workout_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"name": "templates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pool_length": {
|
||||||
|
"name": "pool_length",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 25
|
||||||
|
},
|
||||||
|
"json_data": {
|
||||||
|
"name": "json_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_favorite": {
|
||||||
|
"name": "is_favorite",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"workouts": {
|
||||||
|
"name": "workouts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"template_id": {
|
||||||
|
"name": "template_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pool_length": {
|
||||||
|
"name": "pool_length",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"workout_sessions": {
|
||||||
|
"name": "workout_sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"workout_id": {
|
||||||
|
"name": "workout_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pool_length": {
|
||||||
|
"name": "pool_length",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 25
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"finished_at": {
|
||||||
|
"name": "finished_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"total_distance": {
|
||||||
|
"name": "total_distance",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"total_duration": {
|
||||||
|
"name": "total_duration",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"calories": {
|
||||||
|
"name": "calories",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avg_heart_rate": {
|
||||||
|
"name": "avg_heart_rate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'manual'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"workout_sessions_workout_id_workouts_id_fk": {
|
||||||
|
"name": "workout_sessions_workout_id_workouts_id_fk",
|
||||||
|
"tableFrom": "workout_sessions",
|
||||||
|
"tableTo": "workouts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"workout_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "3e3e9dde-1e8c-4a6a-94ac-b5a750829d2b",
|
||||||
|
"prevId": "1a621e4a-ea8a-4479-a98c-0f02fc8e14e3",
|
||||||
|
"tables": {
|
||||||
|
"laps": {
|
||||||
|
"name": "laps",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"name": "session_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"set_position": {
|
||||||
|
"name": "set_position",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rep_number": {
|
||||||
|
"name": "rep_number",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"name": "distance",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "duration",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stroke_count": {
|
||||||
|
"name": "stroke_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"heart_rate": {
|
||||||
|
"name": "heart_rate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"swolf": {
|
||||||
|
"name": "swolf",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"laps_session_id_workout_sessions_id_fk": {
|
||||||
|
"name": "laps_session_id_workout_sessions_id_fk",
|
||||||
|
"tableFrom": "laps",
|
||||||
|
"tableTo": "workout_sessions",
|
||||||
|
"columnsFrom": [
|
||||||
|
"session_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sets": {
|
||||||
|
"name": "sets",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"workout_id": {
|
||||||
|
"name": "workout_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"name": "position",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"stroke": {
|
||||||
|
"name": "stroke",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"distance_per_rep": {
|
||||||
|
"name": "distance_per_rep",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rep_count": {
|
||||||
|
"name": "rep_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rest_type": {
|
||||||
|
"name": "rest_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'fixed_rest'"
|
||||||
|
},
|
||||||
|
"rest_duration": {
|
||||||
|
"name": "rest_duration",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"target_time": {
|
||||||
|
"name": "target_time",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sets_workout_id_workouts_id_fk": {
|
||||||
|
"name": "sets_workout_id_workouts_id_fk",
|
||||||
|
"tableFrom": "sets",
|
||||||
|
"tableTo": "workouts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"workout_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"name": "templates",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pool_length": {
|
||||||
|
"name": "pool_length",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 25
|
||||||
|
},
|
||||||
|
"json_data": {
|
||||||
|
"name": "json_data",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_favorite": {
|
||||||
|
"name": "is_favorite",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"workout_sessions": {
|
||||||
|
"name": "workout_sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"workout_id": {
|
||||||
|
"name": "workout_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pool_length": {
|
||||||
|
"name": "pool_length",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 25
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"finished_at": {
|
||||||
|
"name": "finished_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"total_distance": {
|
||||||
|
"name": "total_distance",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"total_duration": {
|
||||||
|
"name": "total_duration",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"calories": {
|
||||||
|
"name": "calories",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avg_heart_rate": {
|
||||||
|
"name": "avg_heart_rate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'manual'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"workouts": {
|
||||||
|
"name": "workouts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"template_id": {
|
||||||
|
"name": "template_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pool_length": {
|
||||||
|
"name": "pool_length",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 25
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
"when": 1785854381700,
|
"when": 1785854381700,
|
||||||
"tag": "0000_mushy_groot",
|
"tag": "0000_mushy_groot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785940781700,
|
||||||
|
"tag": "0001_workout_sessions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1786525398056,
|
||||||
|
"tag": "0002_gifted_annihilus",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+80
-1
@@ -54,9 +54,88 @@ CREATE TABLE \`workouts\` (
|
|||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const m0001 = `
|
||||||
|
CREATE TABLE \`workout_sessions\` (
|
||||||
|
\`id\` text PRIMARY KEY NOT NULL,
|
||||||
|
\`workout_id\` text,
|
||||||
|
\`name\` text,
|
||||||
|
\`pool_length\` real DEFAULT 25 NOT NULL,
|
||||||
|
\`started_at\` text NOT NULL,
|
||||||
|
\`finished_at\` text,
|
||||||
|
\`total_distance\` integer,
|
||||||
|
\`total_duration\` real,
|
||||||
|
\`calories\` real,
|
||||||
|
\`avg_heart_rate\` integer,
|
||||||
|
\`source\` text DEFAULT 'manual' NOT NULL,
|
||||||
|
FOREIGN KEY (\`workout_id\`) REFERENCES \`workouts\`(\`id\`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO \`workout_sessions\` (\`id\`, \`workout_id\`, \`name\`, \`pool_length\`, \`started_at\`, \`finished_at\`, \`total_distance\`, \`total_duration\`, \`calories\`, \`avg_heart_rate\`, \`source\`)
|
||||||
|
SELECT \`id\`, \`id\`, \`name\`, \`pool_length\`, \`started_at\`, \`finished_at\`, \`total_distance\`, \`total_duration\`, \`calories\`, \`avg_heart_rate\`, \`source\`
|
||||||
|
FROM \`workouts\`
|
||||||
|
WHERE \`finished_at\` IS NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`laps\` RENAME COLUMN \`workout_id\` TO \`session_id\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`started_at\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`finished_at\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`total_distance\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`total_duration\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`calories\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`avg_heart_rate\`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` DROP COLUMN \`source\`;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const m0002 = `
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE \`__new_laps\` (
|
||||||
|
\`id\` text PRIMARY KEY NOT NULL,
|
||||||
|
\`session_id\` text NOT NULL,
|
||||||
|
\`set_position\` integer NOT NULL,
|
||||||
|
\`rep_number\` integer NOT NULL,
|
||||||
|
\`distance\` integer NOT NULL,
|
||||||
|
\`duration\` real NOT NULL,
|
||||||
|
\`stroke_count\` integer,
|
||||||
|
\`heart_rate\` integer,
|
||||||
|
\`swolf\` real,
|
||||||
|
FOREIGN KEY (\`session_id\`) REFERENCES \`workout_sessions\`(\`id\`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO \`__new_laps\`("id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf") SELECT "id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf" FROM \`laps\`;--> statement-breakpoint
|
||||||
|
DROP TABLE \`laps\`;--> statement-breakpoint
|
||||||
|
ALTER TABLE \`__new_laps\` RENAME TO \`laps\`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE TABLE \`__new_workout_sessions\` (
|
||||||
|
\`id\` text PRIMARY KEY NOT NULL,
|
||||||
|
\`workout_id\` text,
|
||||||
|
\`name\` text,
|
||||||
|
\`pool_length\` real DEFAULT 25 NOT NULL,
|
||||||
|
\`started_at\` text NOT NULL,
|
||||||
|
\`finished_at\` text,
|
||||||
|
\`total_distance\` integer,
|
||||||
|
\`total_duration\` real,
|
||||||
|
\`calories\` real,
|
||||||
|
\`avg_heart_rate\` integer,
|
||||||
|
\`source\` text DEFAULT 'manual' NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO \`__new_workout_sessions\`("id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source") SELECT "id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source" FROM \`workout_sessions\`;--> statement-breakpoint
|
||||||
|
DROP TABLE \`workout_sessions\`;--> statement-breakpoint
|
||||||
|
ALTER TABLE \`__new_workout_sessions\` RENAME TO \`workout_sessions\`;--> statement-breakpoint
|
||||||
|
ALTER TABLE \`workouts\` ADD \`created_at\` text NOT NULL;
|
||||||
|
`;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
journal,
|
journal,
|
||||||
migrations: {
|
migrations: {
|
||||||
m0000
|
m0000,
|
||||||
|
m0001,
|
||||||
|
m0002
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,20 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal",
|
||||||
|
"environment": "development",
|
||||||
|
"channel": "development"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
"distribution": "internal",
|
"distribution": "store",
|
||||||
"autoIncrement": true
|
"autoIncrement": true,
|
||||||
|
"environment": "preview",
|
||||||
|
"channel": "preview"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"autoIncrement": true
|
"autoIncrement": true,
|
||||||
|
"environment": "production",
|
||||||
|
"channel": "production"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"submit": {
|
"submit": {
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css" {}
|
||||||
+4
-5
@@ -1,9 +1,8 @@
|
|||||||
const { getDefaultConfig } = require("expo/metro-config");
|
const { getSentryExpoConfig } = require("@sentry/react-native/metro");
|
||||||
const { withNativewind } = require("nativewind/metro");
|
const { withNativewind } = require("nativewind/metro");
|
||||||
const { withSentryConfig } = require("@sentry/react-native/metro");
|
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getSentryExpoConfig(__dirname);
|
||||||
|
|
||||||
config.resolver.sourceExts.push("sql");
|
config.resolver.sourceExts.push("sql");
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ config.transformer.getTransformOptions = async () => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = withSentryConfig(withNativewind(config, {
|
module.exports = withNativewind(config, {
|
||||||
// Ensure .sql files are not processed as CSS
|
// Ensure .sql files are not processed as CSS
|
||||||
inlineRem: false,
|
inlineRem: false,
|
||||||
}));
|
});
|
||||||
|
|||||||
@@ -166,6 +166,31 @@ public class WatchConnectivityModule: Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AsyncFunction("sendWorkoutDeleted") { (id: String, promise: Promise) in
|
||||||
|
guard let session = self.session else {
|
||||||
|
promise.reject("NO_SESSION", "WCSession not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func doSend() {
|
||||||
|
let message: [String: Any] = ["type": "workoutDeleted", "id": id]
|
||||||
|
|
||||||
|
guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else {
|
||||||
|
promise.resolve(["status": "sent"])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.transferUserInfo(cleaned)
|
||||||
|
promise.resolve(["status": "sent"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.isActivated {
|
||||||
|
doSend()
|
||||||
|
} else {
|
||||||
|
self.pendingPromises.append(doSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AsyncFunction("getApplicationContext") { () -> [String: Any]? in
|
AsyncFunction("getApplicationContext") { () -> [String: Any]? in
|
||||||
return self.session?.receivedApplicationContext as? [String: Any]
|
return self.session?.receivedApplicationContext as? [String: Any]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+648
-606
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -8,16 +8,18 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.18.14",
|
"@react-navigation/bottom-tabs": "^7.18.14",
|
||||||
"@react-navigation/native": "^7.3.14",
|
"@react-navigation/native": "^7.3.14",
|
||||||
"@react-navigation/native-stack": "^7.18.6",
|
"@react-navigation/native-stack": "^7.18.6",
|
||||||
"@sentry/react-native": "^8.22.0",
|
"@sentry/react-native": "~7.11.0",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"expo": "~57.0.10",
|
"expo": "~57.0.16",
|
||||||
"expo-application": "~57.0.2",
|
"expo-application": "~57.0.2",
|
||||||
|
"expo-font": "~57.0.1",
|
||||||
"expo-localization": "~57.0.1",
|
"expo-localization": "~57.0.1",
|
||||||
|
"expo-splash-screen": "~57.0.8",
|
||||||
"expo-sqlite": "~57.0.1",
|
"expo-sqlite": "~57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-system-ui": "~57.0.2",
|
"expo-system-ui": "~57.0.2",
|
||||||
"expo-updates": "^57.0.12",
|
"expo-updates": "~57.0.17",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"nativewind": "^5.0.0-preview.4",
|
"nativewind": "^5.0.0-preview.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
"react-native-mmkv": "^4.3.2",
|
"react-native-mmkv": "^4.3.2",
|
||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
"react-native-screens": "~4.26.0"
|
"react-native-screens": "~4.26.0",
|
||||||
|
"react-native-worklets": "0.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
@@ -42,9 +45,11 @@
|
|||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
|
"clean": "rm -rf .expo ios android node_modules && npm i",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"prebuild": "expo prebuild && npm run eas-build-post-install",
|
"prebuild": "expo prebuild && npm run eas-build-post-install",
|
||||||
"prebuild:clean": "expo prebuild --clean && npm run eas-build-post-install",
|
"prebuild:clean": "expo prebuild --clean && npm run eas-build-post-install",
|
||||||
|
"eas-build-pre-install": "npm install -g npm@11",
|
||||||
"eas-build-post-install": "node scripts/add-sentry-watch.js",
|
"eas-build-post-install": "node scripts/add-sentry-watch.js",
|
||||||
"build:wearos:debug": "./scripts/build-wearos.sh debug",
|
"build:wearos:debug": "./scripts/build-wearos.sh debug",
|
||||||
"build:wearos:release": "./scripts/build-wearos.sh release",
|
"build:wearos:release": "./scripts/build-wearos.sh release",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ if (content.includes(SENTRY_REPO_URL)) {
|
|||||||
|
|
||||||
// ── Find watch target UUID ──
|
// ── Find watch target UUID ──
|
||||||
const watchTargetMatch = content.match(
|
const watchTargetMatch = content.match(
|
||||||
/(\w{24})\s*\/\* SwimBuddy \*\/\s*=\s*\{\s*\n\s*isa = PBXNativeTarget;\s*\n\s*buildConfigurationList = XX792EB48D81CD7C6C71DCXX/
|
/(\w{24})\s*\/\* SwimBuddyWatch \*\/\s*=\s*\{\s*\n\s*isa = PBXNativeTarget;\s*\n\s*buildConfigurationList = \w+ /
|
||||||
);
|
);
|
||||||
if (!watchTargetMatch) {
|
if (!watchTargetMatch) {
|
||||||
console.error("Watch target not found");
|
console.error("Watch target not found");
|
||||||
@@ -55,7 +55,7 @@ console.log(`Watch target: ${watchTargetUuid}`);
|
|||||||
// ── Find watch target's Frameworks build phase UUID ──
|
// ── Find watch target's Frameworks build phase UUID ──
|
||||||
const watchTargetBlock = content.match(
|
const watchTargetBlock = content.match(
|
||||||
new RegExp(
|
new RegExp(
|
||||||
`${watchTargetUuid}\\s*\\/\\* SwimBuddy \\*\\/\\s*=\\s*\\{[\\s\\S]*?buildPhases\\s*=\\s*\\(([\\s\\S]*?)\\);`
|
`${watchTargetUuid}\\s*\\/\\* SwimBuddyWatch \\*\\/\\s*=\\s*\\{[\\s\\S]*?buildPhases\\s*=\\s*\\(([\\s\\S]*?)\\);`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!watchTargetBlock) {
|
if (!watchTargetBlock) {
|
||||||
@@ -88,7 +88,7 @@ const pkgRefLine = `\t\t${pkgRefId} /* XCRemoteSwiftPackageReference "sentry-coc
|
|||||||
if (!content.includes("/* Begin XCRemoteSwiftPackageReference section */")) {
|
if (!content.includes("/* Begin XCRemoteSwiftPackageReference section */")) {
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/\/\* End PBXProject section \*\//,
|
/\/\* End PBXProject section \*\//,
|
||||||
`/* Begin XCRemoteSwiftPackageReference section */\n${pkgRefLine}\n/* End XCRemoteSwiftPackageReference section */\n/* End PBXProject section */`
|
`/* End PBXProject section */\n/* Begin XCRemoteSwiftPackageReference section */\n${pkgRefLine}\n/* End XCRemoteSwiftPackageReference section */`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
@@ -103,7 +103,7 @@ const prodDepLine = `\t\t${prodDepId} /* ${SENTRY_PRODUCT} */ = {isa = XCSwiftPa
|
|||||||
if (!content.includes("/* Begin XCSwiftPackageProductDependency section */")) {
|
if (!content.includes("/* Begin XCSwiftPackageProductDependency section */")) {
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
/\/\* End XCRemoteSwiftPackageReference section \*\//,
|
/\/\* End XCRemoteSwiftPackageReference section \*\//,
|
||||||
`/* Begin XCSwiftPackageProductDependency section */\n${prodDepLine}\n/* End XCSwiftPackageProductDependency section */\n/* End XCRemoteSwiftPackageReference section */`
|
`/* End XCRemoteSwiftPackageReference section */\n/* Begin XCSwiftPackageProductDependency section */\n${prodDepLine}\n/* End XCSwiftPackageProductDependency section */`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = content.replace(
|
content = content.replace(
|
||||||
@@ -143,7 +143,7 @@ if (!content.includes("packageReferences = (")) {
|
|||||||
const prodDepComment = `${prodDepId} /* ${SENTRY_PRODUCT} */`;
|
const prodDepComment = `${prodDepId} /* ${SENTRY_PRODUCT} */`;
|
||||||
// Find the watch target block and check if packageProductDependencies exists
|
// Find the watch target block and check if packageProductDependencies exists
|
||||||
const wtPattern = new RegExp(
|
const wtPattern = new RegExp(
|
||||||
`(${watchTargetUuid}\\s*\\/\\* SwimBuddy \\*\\/ = \\{[\\s\\S]*?fileSystemSynchronizedGroups = \\([\\s\\S]*?\\);[\\s\\S]*?)(name = )`
|
`(${watchTargetUuid}\\s*\\/\\* SwimBuddyWatch \\*\\/ = \\{[\\s\\S]*?fileSystemSynchronizedGroups = \\([\\s\\S]*?\\);[\\s\\S]*?)(name = )`
|
||||||
);
|
);
|
||||||
const wtMatch = content.match(wtPattern);
|
const wtMatch = content.match(wtPattern);
|
||||||
if (wtMatch && !content.includes("packageProductDependencies")) {
|
if (wtMatch && !content.includes("packageProductDependencies")) {
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ cd "$WEAR_DIR"
|
|||||||
echo "Building WearOS ($PROFILE) v$VERSION ($VERSION_CODE)..."
|
echo "Building WearOS ($PROFILE) v$VERSION ($VERSION_CODE)..."
|
||||||
|
|
||||||
if [ "$PROFILE" = "release" ]; then
|
if [ "$PROFILE" = "release" ]; then
|
||||||
|
if [ ! -f "$WEAR_DIR/../credentials.json" ]; then
|
||||||
|
echo "credentials.json not found. Downloading from EAS..."
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
eas credentials --platform android --non-interactive 2>/dev/null || {
|
||||||
|
echo "Auto-download failed. Run manually:"
|
||||||
|
echo " eas credentials --platform android"
|
||||||
|
echo " → Choose 'credentials.json: Upload/Download credentials'"
|
||||||
|
echo " → Choose 'Download credentials from EAS to credentials.json'"
|
||||||
|
echo " Then: cp credentials.json targets/wearos/credentials.json"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
cp credentials.json "$WEAR_DIR/credentials.json"
|
||||||
|
echo "credentials.json copied to targets/wearos/"
|
||||||
|
cd "$WEAR_DIR"
|
||||||
|
fi
|
||||||
./gradlew assembleRelease -PversionName="$VERSION" -PversionCode="$VERSION_CODE"
|
./gradlew assembleRelease -PversionName="$VERSION" -PversionCode="$VERSION_CODE"
|
||||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
||||||
import { PanGestureHandler, State, PanGestureHandlerGestureEvent } from "react-native-gesture-handler";
|
import { PanGestureHandler, State, PanGestureHandlerGestureEvent, PanGestureHandlerStateChangeEvent } from "react-native-gesture-handler";
|
||||||
|
|
||||||
type SwipeableRowProps = {
|
type SwipeableRowProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -20,8 +20,8 @@ export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: Swi
|
|||||||
translateX.setValue(clamped);
|
translateX.setValue(clamped);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onHandlerStateChange = (event: PanGestureHandlerGestureEvent) => {
|
const onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => {
|
||||||
if (event.oldState === State.ACTIVE) {
|
if (event.nativeEvent.oldState === State.ACTIVE) {
|
||||||
const { translationX } = event.nativeEvent;
|
const { translationX } = event.nativeEvent;
|
||||||
const final = Math.max(MAX_LEFT, Math.min(0, offset.current + translationX));
|
const final = Math.max(MAX_LEFT, Math.min(0, offset.current + translationX));
|
||||||
|
|
||||||
|
|||||||
+185
-106
@@ -1,8 +1,9 @@
|
|||||||
import { db } from "./client";
|
import { db } from "./client";
|
||||||
import { generateUUID } from "../utils/uuid";
|
import { generateUUID } from "../utils/uuid";
|
||||||
import { workouts, sets, laps, templates } from "./schema";
|
import { workouts, workoutSessions, sets, laps, templates } from "./schema";
|
||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
import type { WorkoutSet, LapData } from "@types/workout";
|
import type { WorkoutSet, LapData } from "@/types/workout";
|
||||||
|
import WatchConnectivity from "../modules/watch-connectivity";
|
||||||
|
|
||||||
// ─── Templates ───────────────────────────────────────────────
|
// ─── Templates ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export async function toggleTemplateFavorite(id: string, isFavorite: boolean) {
|
|||||||
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
|
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Workouts ────────────────────────────────────────────────
|
// ─── Workout Plans ───────────────────────────────────────────
|
||||||
|
|
||||||
export async function saveWorkout(
|
export async function saveWorkout(
|
||||||
name: string | null,
|
name: string | null,
|
||||||
@@ -39,16 +40,13 @@ export async function saveWorkout(
|
|||||||
templateId?: string
|
templateId?: string
|
||||||
) {
|
) {
|
||||||
const workoutId = generateUUID();
|
const workoutId = generateUUID();
|
||||||
const totalDistance = setsData.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
|
|
||||||
|
|
||||||
await db.insert(workouts).values({
|
await db.insert(workouts).values({
|
||||||
id: workoutId,
|
id: workoutId,
|
||||||
name,
|
name,
|
||||||
templateId: templateId ?? null,
|
templateId: templateId ?? null,
|
||||||
poolLength,
|
poolLength,
|
||||||
startedAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
totalDistance,
|
|
||||||
source: "manual",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i < setsData.length; i++) {
|
for (let i = 0; i < setsData.length; i++) {
|
||||||
@@ -69,52 +67,163 @@ export async function saveWorkout(
|
|||||||
return workoutId;
|
return workoutId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function finishWorkout(
|
|
||||||
workoutId: string,
|
|
||||||
duration: number,
|
|
||||||
calories: number,
|
|
||||||
avgHeartRate: number
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(workouts)
|
|
||||||
.set({
|
|
||||||
finishedAt: new Date().toISOString(),
|
|
||||||
totalDuration: duration,
|
|
||||||
calories,
|
|
||||||
avgHeartRate,
|
|
||||||
})
|
|
||||||
.where(eq(workouts.id, workoutId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWorkoutWithSets(workoutId: string) {
|
export async function getWorkoutWithSets(workoutId: string) {
|
||||||
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
|
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
|
||||||
if (!workout) return null;
|
if (!workout) return null;
|
||||||
|
|
||||||
const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId));
|
const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId));
|
||||||
const workoutLaps = await db.select().from(laps).where(eq(laps.workoutId, workoutId));
|
return { ...workout, sets: workoutSets };
|
||||||
|
|
||||||
return { ...workout, sets: workoutSets, laps: workoutLaps };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllWorkouts() {
|
export async function getAllWorkouts() {
|
||||||
return db.select().from(workouts).orderBy(desc(workouts.startedAt));
|
return db.select().from(workouts).orderBy(desc(workouts.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWorkout(id: string) {
|
// ─── Workout Sessions ────────────────────────────────────────
|
||||||
await db.delete(laps).where(eq(laps.workoutId, id));
|
|
||||||
await db.delete(sets).where(eq(sets.workoutId, id));
|
export async function finishWorkout(
|
||||||
await db.delete(workouts).where(eq(workouts.id, id));
|
workoutId: string,
|
||||||
|
duration: number,
|
||||||
|
calories: number,
|
||||||
|
avgHeartRate: number,
|
||||||
|
totalDistance: number
|
||||||
|
) {
|
||||||
|
const sessionId = generateUUID();
|
||||||
|
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
|
||||||
|
|
||||||
|
await db.insert(workoutSessions).values({
|
||||||
|
id: sessionId,
|
||||||
|
workoutId,
|
||||||
|
name: workout?.name ?? null,
|
||||||
|
poolLength: workout?.poolLength ?? 25,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
totalDistance,
|
||||||
|
totalDuration: duration,
|
||||||
|
calories,
|
||||||
|
avgHeartRate,
|
||||||
|
source: "phone",
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWatchCompletedWorkout(data: {
|
||||||
|
workoutId: string;
|
||||||
|
sessionId?: string;
|
||||||
|
duration: number;
|
||||||
|
totalDistance: number;
|
||||||
|
calories: number;
|
||||||
|
laps: Array<{
|
||||||
|
setPosition: number;
|
||||||
|
repNumber: number;
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
heartRate: number;
|
||||||
|
strokeCount?: number;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const avgHeartRate =
|
||||||
|
data.laps.length > 0
|
||||||
|
? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Haal plan op als het bestaat
|
||||||
|
const plan = await db
|
||||||
|
.select()
|
||||||
|
.from(workouts)
|
||||||
|
.where(eq(workouts.id, data.workoutId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Gebruik sessionId van de watch, of genereer een nieuwe
|
||||||
|
const sessionId = data.sessionId ?? generateUUID();
|
||||||
|
|
||||||
|
// Check of er al een sessie bestaat voor dit sessionId
|
||||||
|
const existingSession = await db
|
||||||
|
.select({ id: workoutSessions.id })
|
||||||
|
.from(workoutSessions)
|
||||||
|
.where(eq(workoutSessions.id, sessionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSession.length === 0) {
|
||||||
|
await db.insert(workoutSessions).values({
|
||||||
|
id: sessionId,
|
||||||
|
workoutId: data.workoutId,
|
||||||
|
name: plan?.name ?? "Watch Workout",
|
||||||
|
poolLength: plan?.poolLength ?? 25,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
totalDistance: data.totalDistance || 0,
|
||||||
|
totalDuration: data.duration,
|
||||||
|
calories: data.calories || 0,
|
||||||
|
avgHeartRate,
|
||||||
|
source: "watch",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(workoutSessions)
|
||||||
|
.set({
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
totalDuration: data.duration,
|
||||||
|
totalDistance: data.totalDistance || 0,
|
||||||
|
calories: data.calories || 0,
|
||||||
|
avgHeartRate,
|
||||||
|
})
|
||||||
|
.where(eq(workoutSessions.id, sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lap of data.laps) {
|
||||||
|
const strokeCount = lap.strokeCount ?? null;
|
||||||
|
const swolf = strokeCount != null ? strokeCount + Math.round(lap.duration) : null;
|
||||||
|
|
||||||
|
await db.insert(laps).values({
|
||||||
|
id: generateUUID(),
|
||||||
|
sessionId,
|
||||||
|
setPosition: lap.setPosition,
|
||||||
|
repNumber: lap.repNumber,
|
||||||
|
distance: lap.distance,
|
||||||
|
duration: lap.duration,
|
||||||
|
heartRate: lap.heartRate || null,
|
||||||
|
strokeCount,
|
||||||
|
swolf,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSessions() {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(workoutSessions)
|
||||||
|
.orderBy(desc(workoutSessions.startedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionWithDetails(sessionId: string) {
|
||||||
|
const session = await db
|
||||||
|
.select()
|
||||||
|
.from(workoutSessions)
|
||||||
|
.where(eq(workoutSessions.id, sessionId))
|
||||||
|
.get();
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const sessionLaps = await db.select().from(laps).where(eq(laps.sessionId, sessionId));
|
||||||
|
|
||||||
|
let sessionSets: any[] = [];
|
||||||
|
if (session.workoutId) {
|
||||||
|
sessionSets = await db.select().from(sets).where(eq(sets.workoutId, session.workoutId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...session, laps: sessionLaps, sets: sessionSets };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Laps ────────────────────────────────────────────────────
|
// ─── Laps ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function saveLap(workoutId: string, lap: LapData) {
|
export async function saveLap(sessionId: string, lap: LapData) {
|
||||||
const swolf =
|
const swolf =
|
||||||
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
|
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
|
||||||
|
|
||||||
await db.insert(laps).values({
|
await db.insert(laps).values({
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
workoutId,
|
sessionId,
|
||||||
setPosition: lap.setPosition,
|
setPosition: lap.setPosition,
|
||||||
repNumber: lap.repNumber,
|
repNumber: lap.repNumber,
|
||||||
distance: lap.distance,
|
distance: lap.distance,
|
||||||
@@ -125,79 +234,49 @@ export async function saveLap(workoutId: string, lap: LapData) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveLaps(workoutId: string, lapsData: LapData[]) {
|
export async function saveLaps(sessionId: string, lapsData: LapData[]) {
|
||||||
for (const lap of lapsData) {
|
for (const lap of lapsData) {
|
||||||
await saveLap(workoutId, lap);
|
await saveLap(sessionId, lap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkoutLaps(workoutId: string) {
|
export async function getSessionLaps(sessionId: string) {
|
||||||
return db.select().from(laps).where(eq(laps.workoutId, workoutId));
|
return db.select().from(laps).where(eq(laps.sessionId, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveWatchCompletedWorkout(data: {
|
// ─── Delete ──────────────────────────────────────────────────
|
||||||
workoutId: string;
|
|
||||||
duration: number;
|
|
||||||
totalDistance: number;
|
|
||||||
calories: number;
|
|
||||||
laps: Array<{
|
|
||||||
setPosition: number;
|
|
||||||
repNumber: number;
|
|
||||||
distance: number;
|
|
||||||
duration: number;
|
|
||||||
heartRate: number;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const avgHeartRate =
|
|
||||||
data.laps.length > 0
|
|
||||||
? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const existing = await db
|
export async function deleteSession(sessionId: string) {
|
||||||
.select({ id: workouts.id })
|
await db.delete(laps).where(eq(laps.sessionId, sessionId));
|
||||||
.from(workouts)
|
await db.delete(workoutSessions).where(eq(workoutSessions.id, sessionId));
|
||||||
.where(eq(workouts.id, data.workoutId))
|
}
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing.length === 0) {
|
export async function deleteSessionAndSync(sessionId: string) {
|
||||||
await db.insert(workouts).values({
|
await deleteSession(sessionId);
|
||||||
id: data.workoutId,
|
WatchConnectivity.sendWorkoutDeleted(sessionId).catch((err: Error) =>
|
||||||
name: "Watch Workout",
|
console.warn("[ops] sync failed:", err)
|
||||||
poolLength: 25,
|
);
|
||||||
finishedAt: new Date().toISOString(),
|
}
|
||||||
totalDuration: data.duration,
|
|
||||||
totalDistance: data.totalDistance || 0,
|
export async function deleteWorkout(id: string) {
|
||||||
calories: data.calories || 0,
|
// Verwijder alle sessies die aan dit plan gelinkt zijn
|
||||||
avgHeartRate,
|
const sessions = await db
|
||||||
source: "watch",
|
.select({ id: workoutSessions.id })
|
||||||
});
|
.from(workoutSessions)
|
||||||
} else {
|
.where(eq(workoutSessions.workoutId, id));
|
||||||
await db
|
for (const session of sessions) {
|
||||||
.update(workouts)
|
await db.delete(laps).where(eq(laps.sessionId, session.id));
|
||||||
.set({
|
|
||||||
finishedAt: new Date().toISOString(),
|
|
||||||
totalDuration: data.duration,
|
|
||||||
totalDistance: data.totalDistance || 0,
|
|
||||||
calories: data.calories || 0,
|
|
||||||
avgHeartRate,
|
|
||||||
source: "watch",
|
|
||||||
})
|
|
||||||
.where(eq(workouts.id, data.workoutId));
|
|
||||||
}
|
}
|
||||||
|
await db.delete(workoutSessions).where(eq(workoutSessions.workoutId, id));
|
||||||
|
await db.delete(sets).where(eq(sets.workoutId, id));
|
||||||
|
await db.delete(workouts).where(eq(workouts.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
for (const lap of data.laps) {
|
export async function deleteWorkoutAndSync(id: string) {
|
||||||
await db.insert(laps).values({
|
await deleteWorkout(id);
|
||||||
id: generateUUID(),
|
WatchConnectivity.sendWorkoutDeleted(id).catch((err: Error) =>
|
||||||
workoutId: data.workoutId,
|
console.warn("[ops] sync failed:", err)
|
||||||
setPosition: lap.setPosition,
|
);
|
||||||
repNumber: lap.repNumber,
|
|
||||||
distance: lap.distance,
|
|
||||||
duration: lap.duration,
|
|
||||||
heartRate: lap.heartRate || null,
|
|
||||||
strokeCount: null,
|
|
||||||
swolf: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stats ───────────────────────────────────────────────────
|
// ─── Stats ───────────────────────────────────────────────────
|
||||||
@@ -209,12 +288,12 @@ export async function getWeeklyStats() {
|
|||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
count: sql<number>`count(*)`,
|
count: sql<number>`count(*)`,
|
||||||
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
|
totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
|
||||||
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
|
totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
|
||||||
avgCalories: sql<number>`coalesce(avg(${workouts.calories}), 0)`,
|
avgCalories: sql<number>`coalesce(avg(${workoutSessions.calories}), 0)`,
|
||||||
})
|
})
|
||||||
.from(workouts)
|
.from(workoutSessions)
|
||||||
.where(sql`${workouts.startedAt} >= ${weekAgo.toISOString()}`)
|
.where(sql`${workoutSessions.startedAt} >= ${weekAgo.toISOString()}`)
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,10 +313,10 @@ export async function getTotalStats() {
|
|||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
totalWorkouts: sql<number>`count(*)`,
|
totalWorkouts: sql<number>`count(*)`,
|
||||||
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
|
totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
|
||||||
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
|
totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
|
||||||
})
|
})
|
||||||
.from(workouts)
|
.from(workoutSessions)
|
||||||
.where(sql`${workouts.finishedAt} IS NOT NULL`)
|
.where(sql`${workoutSessions.finishedAt} IS NOT NULL`)
|
||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -1,5 +1,5 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { workouts, sets, laps, templates } from "./schema";
|
import { templates, workouts, workoutSessions, sets, laps } from "./schema";
|
||||||
|
|
||||||
export const templatesRelations = relations(templates, ({ many }) => ({
|
export const templatesRelations = relations(templates, ({ many }) => ({
|
||||||
workouts: many(workouts),
|
workouts: many(workouts),
|
||||||
@@ -11,6 +11,14 @@ export const workoutsRelations = relations(workouts, ({ one, many }) => ({
|
|||||||
references: [templates.id],
|
references: [templates.id],
|
||||||
}),
|
}),
|
||||||
sets: many(sets),
|
sets: many(sets),
|
||||||
|
sessions: many(workoutSessions),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const workoutSessionsRelations = relations(workoutSessions, ({ one, many }) => ({
|
||||||
|
workout: one(workouts, {
|
||||||
|
fields: [workoutSessions.workoutId],
|
||||||
|
references: [workouts.id],
|
||||||
|
}),
|
||||||
laps: many(laps),
|
laps: many(laps),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -22,8 +30,8 @@ export const setsRelations = relations(sets, ({ one }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const lapsRelations = relations(laps, ({ one }) => ({
|
export const lapsRelations = relations(laps, ({ one }) => ({
|
||||||
workout: one(workouts, {
|
session: one(workoutSessions, {
|
||||||
fields: [laps.workoutId],
|
fields: [laps.sessionId],
|
||||||
references: [workouts.id],
|
references: [workoutSessions.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+10
-2
@@ -15,6 +15,14 @@ export const workouts = sqliteTable("workouts", {
|
|||||||
name: text("name"),
|
name: text("name"),
|
||||||
templateId: text("template_id"),
|
templateId: text("template_id"),
|
||||||
poolLength: real("pool_length").notNull().default(25),
|
poolLength: real("pool_length").notNull().default(25),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workoutSessions = sqliteTable("workout_sessions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
workoutId: text("workout_id"),
|
||||||
|
name: text("name"),
|
||||||
|
poolLength: real("pool_length").notNull().default(25),
|
||||||
startedAt: text("started_at").notNull(),
|
startedAt: text("started_at").notNull(),
|
||||||
finishedAt: text("finished_at"),
|
finishedAt: text("finished_at"),
|
||||||
totalDistance: integer("total_distance"),
|
totalDistance: integer("total_distance"),
|
||||||
@@ -40,9 +48,9 @@ export const sets = sqliteTable("sets", {
|
|||||||
|
|
||||||
export const laps = sqliteTable("laps", {
|
export const laps = sqliteTable("laps", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
workoutId: text("workout_id")
|
sessionId: text("session_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workouts.id),
|
.references(() => workoutSessions.id),
|
||||||
setPosition: integer("set_position").notNull(),
|
setPosition: integer("set_position").notNull(),
|
||||||
repNumber: integer("rep_number").notNull(),
|
repNumber: integer("rep_number").notNull(),
|
||||||
distance: integer("distance").notNull(),
|
distance: integer("distance").notNull(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useNavigation, useRoute } from "@react-navigation/native";
|
|||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import type { RouteProp } from "@react-navigation/native";
|
import type { RouteProp } from "@react-navigation/native";
|
||||||
import type { RootStackParamList } from "@navigation/types";
|
import type { RootStackParamList } from "@navigation/types";
|
||||||
import { getWorkoutWithSets, getWorkoutLaps } from "@db/operations";
|
import { getSessionWithDetails } from "@db/operations";
|
||||||
import { WorkoutSummary } from "../components/WorkoutSummary";
|
import { WorkoutSummary } from "../components/WorkoutSummary";
|
||||||
|
|
||||||
type Set = {
|
type Set = {
|
||||||
@@ -38,6 +38,8 @@ export function PostSwimScreen() {
|
|||||||
sets: Set[];
|
sets: Set[];
|
||||||
laps: Lap[];
|
laps: Lap[];
|
||||||
avgSwolf: number | null;
|
avgSwolf: number | null;
|
||||||
|
totalLengths: number | null;
|
||||||
|
avgPace: number | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,29 +47,27 @@ export function PostSwimScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
const workout = await getWorkoutWithSets(route.params.workoutId);
|
const session = await getSessionWithDetails(route.params.sessionId);
|
||||||
const laps = await getWorkoutLaps(route.params.workoutId);
|
if (!session) return;
|
||||||
|
|
||||||
if (!workout) return;
|
const totalDuration = session.totalDuration ?? 0;
|
||||||
|
|
||||||
const totalDuration = workout.laps?.reduce((acc: number, l: any) => acc + l.duration, 0) ?? 0;
|
|
||||||
const avgSwolf =
|
const avgSwolf =
|
||||||
laps.length > 0
|
session.laps.length > 0
|
||||||
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
|
? Math.round(session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const totalLengths =
|
const totalLengths =
|
||||||
workout.totalDistance && workout.poolLength
|
session.totalDistance && session.poolLength
|
||||||
? Math.round(workout.totalDistance / workout.poolLength)
|
? Math.round(session.totalDistance / session.poolLength)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const avgPace =
|
const avgPace =
|
||||||
workout.totalDistance && totalDuration
|
session.totalDistance && totalDuration
|
||||||
? workout.totalDistance / totalDuration
|
? session.totalDistance / totalDuration
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const setsWithPace = (workout.sets ?? []).map((set: any) => {
|
const setsWithPace = (session.sets ?? []).map((set: any) => {
|
||||||
const setLaps = laps.filter((l: any) => l.setPosition === set.position);
|
const setLaps = session.laps.filter((l: any) => l.setPosition === set.position);
|
||||||
const setDuration = setLaps.reduce((acc: number, l: any) => acc + l.duration, 0);
|
const setDuration = setLaps.reduce((acc: number, l: any) => acc + l.duration, 0);
|
||||||
const setDistance = setLaps.reduce((acc: number, l: any) => acc + l.distance, 0);
|
const setDistance = setLaps.reduce((acc: number, l: any) => acc + l.distance, 0);
|
||||||
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
|
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
|
||||||
@@ -75,14 +75,14 @@ export function PostSwimScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setData({
|
setData({
|
||||||
name: workout.name,
|
name: session.name,
|
||||||
poolLength: workout.poolLength,
|
poolLength: session.poolLength,
|
||||||
totalDistance: workout.totalDistance ?? 0,
|
totalDistance: session.totalDistance ?? 0,
|
||||||
totalDuration,
|
totalDuration,
|
||||||
calories: workout.calories,
|
calories: session.calories,
|
||||||
avgHeartRate: workout.avgHeartRate,
|
avgHeartRate: session.avgHeartRate,
|
||||||
sets: setsWithPace,
|
sets: setsWithPace,
|
||||||
laps,
|
laps: session.laps,
|
||||||
avgSwolf,
|
avgSwolf,
|
||||||
totalLengths,
|
totalLengths,
|
||||||
avgPace,
|
avgPace,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useSyncWorkout } from "../../watch-connectivity/hooks/useSyncWorkout";
|
|||||||
import { useWatchConnection } from "../../watch-connectivity/hooks/useWatchConnection";
|
import { useWatchConnection } from "../../watch-connectivity/hooks/useWatchConnection";
|
||||||
import { formatDistance } from "@utils/formatDistance";
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
import { STROKE_COLORS } from "@constants/strokes";
|
import { STROKE_COLORS } from "@constants/strokes";
|
||||||
import type { WorkoutSet } from "@types/workout";
|
import type { WorkoutSet } from "@/types/workout";
|
||||||
|
|
||||||
type WorkoutWithSets = {
|
type WorkoutWithSets = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { useNavigation } from "@react-navigation/native";
|
|||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import type { HistoryStackParamList } from "@navigation/types";
|
import type { HistoryStackParamList } from "@navigation/types";
|
||||||
import { getAllWorkouts, deleteWorkout } from "@db/operations";
|
import { getAllSessions, deleteSessionAndSync } from "@db/operations";
|
||||||
import { formatTime } from "@utils/formatTime";
|
import { formatTime } from "@utils/formatTime";
|
||||||
import { formatDistance } from "@utils/formatDistance";
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
import { SwipeableRow } from "@components/SwipeableRow";
|
import { SwipeableRow } from "@components/SwipeableRow";
|
||||||
|
|
||||||
type WorkoutRow = {
|
type SessionRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
totalDistance: number | null;
|
totalDistance: number | null;
|
||||||
@@ -24,23 +24,23 @@ export function HistoryScreen() {
|
|||||||
const { t } = useTranslation("history");
|
const { t } = useTranslation("history");
|
||||||
const { t: tc } = useTranslation("common");
|
const { t: tc } = useTranslation("common");
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
||||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
const [sessions, setSessions] = useState<SessionRow[]>([]);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const loadWorkouts = async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
const data = await getAllWorkouts();
|
const data = await getAllSessions();
|
||||||
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
|
setSessions(data as SessionRow[]);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
loadWorkouts();
|
loadSessions();
|
||||||
}, [])
|
}, [loadSessions])
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await loadWorkouts();
|
await loadSessions();
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,17 +51,17 @@ export function HistoryScreen() {
|
|||||||
text: tc("delete"),
|
text: tc("delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await deleteWorkout(id);
|
await deleteSessionAndSync(id);
|
||||||
await loadWorkouts();
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
|
const grouped = sessions.reduce<Record<string, SessionRow[]>>((acc, s) => {
|
||||||
const date = new Date(w.startedAt).toLocaleDateString();
|
const date = new Date(s.startedAt).toLocaleDateString();
|
||||||
if (!acc[date]) acc[date] = [];
|
if (!acc[date]) acc[date] = [];
|
||||||
acc[date].push(w);
|
acc[date].push(s);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
@@ -85,25 +85,25 @@ export function HistoryScreen() {
|
|||||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
|
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
|
||||||
{date}
|
{date}
|
||||||
</Text>
|
</Text>
|
||||||
{rows.map((w) => (
|
{rows.map((s) => (
|
||||||
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)} deleteLabel={tc("delete")}>
|
<SwipeableRow key={s.id} onDelete={() => onDelete(s.id)} deleteLabel={tc("delete")}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
|
onPress={() => navigation.navigate("SessionDetail", { sessionId: s.id })}
|
||||||
className="p-4"
|
className="p-4"
|
||||||
>
|
>
|
||||||
<View className="flex-row justify-between items-center">
|
<View className="flex-row justify-between items-center">
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-semibold text-slate-900">
|
<Text className="font-semibold text-slate-900">
|
||||||
{w.name || tc("swimSession")}
|
{s.name || tc("swimSession")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-slate-500 text-sm">
|
<Text className="text-slate-500 text-sm">
|
||||||
{formatDistance(w.totalDistance ?? 0)}
|
{formatDistance(s.totalDistance ?? 0)}
|
||||||
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
|
{s.totalDuration ? ` · ${formatTime(s.totalDuration)}` : ""}
|
||||||
{w.calories ? ` · ${tc("kcal", { count: Math.round(w.calories) })}` : ""}
|
{s.calories ? ` · ${tc("kcal", { count: Math.round(s.calories) })}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="text-slate-400 text-xs">
|
<Text className="text-slate-400 text-xs">
|
||||||
{new Date(w.startedAt).toLocaleTimeString([], {
|
{new Date(s.startedAt).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { View, Text, Pressable, Alert } from "react-native";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import type { HistoryStackProps } from "@navigation/types";
|
import type { HistoryStackProps } from "@navigation/types";
|
||||||
import { getWorkoutWithSets, deleteWorkout } from "@db/operations";
|
import { getSessionWithDetails, deleteSessionAndSync } from "@db/operations";
|
||||||
import { WorkoutSummary } from "../../active-workout/components/WorkoutSummary";
|
import { WorkoutSummary } from "../../active-workout/components/WorkoutSummary";
|
||||||
|
|
||||||
type WorkoutDetail = {
|
type SessionDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
poolLength: number;
|
poolLength: number;
|
||||||
@@ -16,6 +16,7 @@ type WorkoutDetail = {
|
|||||||
avgHeartRate: number | null;
|
avgHeartRate: number | null;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
finishedAt: string | null;
|
finishedAt: string | null;
|
||||||
|
source: string;
|
||||||
sets: {
|
sets: {
|
||||||
stroke: string;
|
stroke: string;
|
||||||
distancePerRep: number;
|
distancePerRep: number;
|
||||||
@@ -35,15 +36,15 @@ type WorkoutDetail = {
|
|||||||
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
|
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
|
||||||
const { t: tc } = useTranslation("common");
|
const { t: tc } = useTranslation("common");
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
|
const [session, setSession] = useState<SessionDetail | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWorkout();
|
loadSession();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadWorkout = async () => {
|
const loadSession = async () => {
|
||||||
const data = await getWorkoutWithSets(route.params.sessionId);
|
const data = await getSessionWithDetails(route.params.sessionId);
|
||||||
setWorkout(data as WorkoutDetail | null);
|
setSession(data as SessionDetail | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
@@ -53,7 +54,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
text: tc("delete"),
|
text: tc("delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await deleteWorkout(route.params.sessionId);
|
await deleteSessionAndSync(route.params.sessionId);
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -61,7 +62,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workout) {
|
if (session) {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Pressable onPress={handleDelete} hitSlop={8}>
|
<Pressable onPress={handleDelete} hitSlop={8}>
|
||||||
@@ -70,9 +71,9 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [workout]);
|
}, [session]);
|
||||||
|
|
||||||
if (!workout) {
|
if (!session) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white items-center justify-center">
|
<View className="flex-1 bg-white items-center justify-center">
|
||||||
<Text className="text-slate-400">{tc("loading")}</Text>
|
<Text className="text-slate-400">{tc("loading")}</Text>
|
||||||
@@ -81,24 +82,24 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avgSwolf =
|
const avgSwolf =
|
||||||
workout.laps.length > 0
|
session.laps.length > 0
|
||||||
? Math.round(
|
? Math.round(
|
||||||
workout.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / workout.laps.length
|
session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const totalLengths =
|
const totalLengths =
|
||||||
workout.totalDistance && workout.poolLength
|
session.totalDistance && session.poolLength
|
||||||
? Math.round(workout.totalDistance / workout.poolLength)
|
? Math.round(session.totalDistance / session.poolLength)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const avgPace =
|
const avgPace =
|
||||||
workout.totalDistance && workout.totalDuration
|
session.totalDistance && session.totalDuration
|
||||||
? workout.totalDistance / workout.totalDuration
|
? session.totalDistance / session.totalDuration
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const setsWithPace = workout.sets.map((set) => {
|
const setsWithPace = session.sets.map((set) => {
|
||||||
const setLaps = workout.laps.filter((l) => l.setPosition === set.position);
|
const setLaps = session.laps.filter((l) => l.setPosition === set.position);
|
||||||
const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0);
|
const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0);
|
||||||
const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0);
|
const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0);
|
||||||
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
|
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
|
||||||
@@ -107,17 +108,17 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkoutSummary
|
<WorkoutSummary
|
||||||
name={workout.name}
|
name={session.name}
|
||||||
date={new Date(workout.startedAt).toLocaleDateString()}
|
date={new Date(session.startedAt).toLocaleDateString()}
|
||||||
totalDistance={workout.totalDistance ?? 0}
|
totalDistance={session.totalDistance ?? 0}
|
||||||
totalDuration={workout.totalDuration ?? 0}
|
totalDuration={session.totalDuration ?? 0}
|
||||||
totalLengths={totalLengths}
|
totalLengths={totalLengths}
|
||||||
calories={workout.calories}
|
calories={session.calories}
|
||||||
avgHeartRate={workout.avgHeartRate}
|
avgHeartRate={session.avgHeartRate}
|
||||||
avgPace={avgPace}
|
avgPace={avgPace}
|
||||||
poolLength={workout.poolLength}
|
poolLength={session.poolLength}
|
||||||
sets={setsWithPace}
|
sets={setsWithPace}
|
||||||
laps={workout.laps}
|
laps={session.laps}
|
||||||
avgSwolf={avgSwolf}
|
avgSwolf={avgSwolf}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { getTemplates, deleteTemplate, toggleTemplateFavorite } from "@db/operations";
|
import { getTemplates, deleteTemplate, toggleTemplateFavorite } from "@db/operations";
|
||||||
import type { WorkoutSet } from "@types/workout";
|
import type { WorkoutSet } from "@/types/workout";
|
||||||
|
|
||||||
type Template = {
|
type Template = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import WatchConnectivity from "../../../modules/watch-connectivity";
|
import WatchConnectivity from "../../../modules/watch-connectivity";
|
||||||
import WearConnectivity from "../../../modules/wear-connectivity";
|
import WearConnectivity from "../../../modules/wear-connectivity";
|
||||||
import type { WorkoutSet } from "@types/workout";
|
import type { WorkoutSet } from "@/types/workout";
|
||||||
|
|
||||||
type SyncResult = { status: string } | { error: string };
|
type SyncResult = { status: string } | { error: string };
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import WatchConnectivity from "../../../modules/watch-connectivity";
|
import WatchConnectivity from "../../../modules/watch-connectivity";
|
||||||
import WearConnectivity from "../../../modules/wear-connectivity";
|
import WearConnectivity from "../../../modules/wear-connectivity";
|
||||||
import { saveWatchCompletedWorkout } from "@db/operations";
|
import { saveWatchCompletedWorkout, deleteSession } from "@db/operations";
|
||||||
|
|
||||||
export function useWatchWorkoutSync() {
|
export function useWatchWorkoutSync() {
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -22,11 +22,14 @@ export function useWatchWorkoutSync() {
|
|||||||
if (event.type === "workoutCompleted") {
|
if (event.type === "workoutCompleted") {
|
||||||
await saveWatchCompletedWorkout({
|
await saveWatchCompletedWorkout({
|
||||||
workoutId: event.workoutId,
|
workoutId: event.workoutId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
duration: event.duration ?? 0,
|
duration: event.duration ?? 0,
|
||||||
totalDistance: event.totalDistance ?? 0,
|
totalDistance: event.totalDistance ?? 0,
|
||||||
calories: event.calories ?? 0,
|
calories: event.calories ?? 0,
|
||||||
laps: event.laps ?? [],
|
laps: event.laps ?? [],
|
||||||
});
|
});
|
||||||
|
} else if (event.type === "workoutDeleted") {
|
||||||
|
await deleteSession(event.workoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import type { WorkoutSet, StrokeType, RestType } from "@types/workout";
|
import type { WorkoutSet, StrokeType, RestType } from "@/types/workout";
|
||||||
import { saveWorkout, saveTemplate } from "@db/operations";
|
import { saveWorkout, saveTemplate } from "@db/operations";
|
||||||
import i18n from "@i18n/index";
|
import i18n from "@i18n/index";
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
|||||||
import type { RootStackParamList } from "@navigation/types";
|
import type { RootStackParamList } from "@navigation/types";
|
||||||
import { useWorkoutBuilder } from "../hooks/useWorkoutBuilder";
|
import { useWorkoutBuilder } from "../hooks/useWorkoutBuilder";
|
||||||
import { useSettings } from "../../../providers/WatchProvider";
|
import { useSettings } from "../../../providers/WatchProvider";
|
||||||
import type { StrokeType } from "@types/workout";
|
import type { StrokeType } from "@/types/workout";
|
||||||
|
|
||||||
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
|
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
|
||||||
|
|
||||||
export function BuilderScreen() {
|
export function BuilderScreen() {
|
||||||
const { t } = useTranslation("workout");
|
const { t } = useTranslation("workout");
|
||||||
const { t: tc } = useTranslation("common");
|
const { t: tc } = useTranslation("common");
|
||||||
|
const { t: tb } = useTranslation("navigation");
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const { poolLength: defaultPoolLength } = useSettings();
|
const { poolLength: defaultPoolLength } = useSettings();
|
||||||
const [poolLength, setPoolLength] = useState(defaultPoolLength);
|
const [poolLength, setPoolLength] = useState(defaultPoolLength);
|
||||||
|
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||||
const {
|
const {
|
||||||
sets,
|
sets,
|
||||||
workoutName,
|
workoutName,
|
||||||
@@ -46,7 +48,7 @@ export function BuilderScreen() {
|
|||||||
>
|
>
|
||||||
<ScrollView className="flex-1 bg-white" contentContainerStyle={{ paddingBottom: 40 }}>
|
<ScrollView className="flex-1 bg-white" contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
<View className="p-4">
|
<View className="p-4">
|
||||||
<Text className="text-2xl font-bold text-slate-900 mb-1">{t("buildWorkout")}</Text>
|
<Text className="text-2xl font-bold text-slate-900 mb-1">{tb("buildWorkout")}</Text>
|
||||||
<Text className="text-slate-500 mb-4">
|
<Text className="text-slate-500 mb-4">
|
||||||
{totalDistance}m total · {tc("pool", { length: poolLength })}
|
{totalDistance}m total · {tc("pool", { length: poolLength })}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -124,8 +126,19 @@ export function BuilderScreen() {
|
|||||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("distanceM")}</Text>
|
<Text className="text-xs font-medium text-slate-500 mb-1">{t("distanceM")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={String(set.distancePerRep)}
|
value={fieldValues[`${index}-distance`] ?? String(set.distancePerRep)}
|
||||||
onChangeText={(val) => updateSet(index, { distancePerRep: Number(val) || 0 })}
|
onChangeText={(val) => setFieldValues((prev) => ({ ...prev, [`${index}-distance`]: val }))}
|
||||||
|
onBlur={() => {
|
||||||
|
const num = Number(fieldValues[`${index}-distance`] ?? set.distancePerRep);
|
||||||
|
updateSet(index, { distancePerRep: isNaN(num) || num < poolLength ? poolLength : num });
|
||||||
|
setFieldValues((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${index}-distance`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder={String(poolLength)}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -133,8 +146,19 @@ export function BuilderScreen() {
|
|||||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("reps")}</Text>
|
<Text className="text-xs font-medium text-slate-500 mb-1">{t("reps")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={String(set.repCount)}
|
value={fieldValues[`${index}-rep`] ?? String(set.repCount)}
|
||||||
onChangeText={(val) => updateSet(index, { repCount: Number(val) || 1 })}
|
onChangeText={(val) => setFieldValues((prev) => ({ ...prev, [`${index}-rep`]: val }))}
|
||||||
|
onBlur={() => {
|
||||||
|
const num = Number(fieldValues[`${index}-rep`] ?? set.repCount);
|
||||||
|
updateSet(index, { repCount: isNaN(num) || num < 1 ? 1 : num });
|
||||||
|
setFieldValues((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${index}-rep`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="1"
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -142,8 +166,19 @@ export function BuilderScreen() {
|
|||||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("restS")}</Text>
|
<Text className="text-xs font-medium text-slate-500 mb-1">{t("restS")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
keyboardType="numeric"
|
keyboardType="numeric"
|
||||||
value={String(set.restDuration)}
|
value={fieldValues[`${index}-rest`] ?? String(set.restDuration)}
|
||||||
onChangeText={(val) => updateSet(index, { restDuration: Number(val) || 0 })}
|
onChangeText={(val) => setFieldValues((prev) => ({ ...prev, [`${index}-rest`]: val }))}
|
||||||
|
onBlur={() => {
|
||||||
|
const num = Number(fieldValues[`${index}-rest`] ?? set.restDuration);
|
||||||
|
updateSet(index, { restDuration: isNaN(num) || num < 0 ? 0 : num });
|
||||||
|
setFieldValues((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[`${index}-rest`];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="0"
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import { useNavigation } from "@react-navigation/native";
|
|||||||
import { useFocusEffect } from "@react-navigation/native";
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import type { WorkoutStackParamList } from "@navigation/types";
|
import type { WorkoutStackParamList } from "@navigation/types";
|
||||||
import { getAllWorkouts, deleteWorkout } from "@db/operations";
|
import { getAllWorkouts, getWorkoutWithSets, deleteWorkoutAndSync } from "@db/operations";
|
||||||
import { formatTime } from "@utils/formatTime";
|
|
||||||
import { formatDistance } from "@utils/formatDistance";
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
import { SwipeableRow } from "@components/SwipeableRow";
|
import { SwipeableRow } from "@components/SwipeableRow";
|
||||||
|
|
||||||
type WorkoutRow = {
|
type WorkoutRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
totalDistance: number | null;
|
poolLength: number;
|
||||||
totalDuration: number | null;
|
createdAt: string;
|
||||||
startedAt: string;
|
totalDistance?: number;
|
||||||
finishedAt: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkoutListScreen() {
|
export function WorkoutListScreen() {
|
||||||
@@ -26,15 +24,30 @@ export function WorkoutListScreen() {
|
|||||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const loadWorkouts = async () => {
|
const loadWorkouts = useCallback(async () => {
|
||||||
const data = await getAllWorkouts();
|
const data = await getAllWorkouts();
|
||||||
setWorkouts(data as WorkoutRow[]);
|
const rows = data as WorkoutRow[];
|
||||||
};
|
|
||||||
|
// Bereken totalDistance per workout op basis van sets
|
||||||
|
const withDistance = await Promise.all(
|
||||||
|
rows.map(async (w) => {
|
||||||
|
const detail = await getWorkoutWithSets(w.id);
|
||||||
|
if (!detail) return w;
|
||||||
|
const totalDistance = (detail.sets ?? []).reduce(
|
||||||
|
(acc: number, s: any) => acc + s.distancePerRep * s.repCount,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return { ...w, totalDistance };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setWorkouts(withDistance);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
loadWorkouts();
|
loadWorkouts();
|
||||||
}, [])
|
}, [loadWorkouts])
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
@@ -50,8 +63,8 @@ export function WorkoutListScreen() {
|
|||||||
text: tc("delete"),
|
text: tc("delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await deleteWorkout(id);
|
await deleteWorkoutAndSync(id);
|
||||||
await loadWorkouts();
|
setWorkouts((prev) => prev.filter((w) => w.id !== id));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -92,18 +105,13 @@ export function WorkoutListScreen() {
|
|||||||
{item.name || tc("untitledWorkout")}
|
{item.name || tc("untitledWorkout")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-slate-500 text-sm mt-1">
|
<Text className="text-slate-500 text-sm mt-1">
|
||||||
{formatDistance(item.totalDistance ?? 0)}
|
{item.totalDistance ? formatDistance(item.totalDistance) : ""}
|
||||||
{item.totalDuration ? ` · ${formatTime(item.totalDuration)}` : ""}
|
{item.poolLength ? ` · ${tc("pool", { length: item.poolLength })}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-slate-400 text-xs mt-1">
|
<Text className="text-slate-400 text-xs mt-1">
|
||||||
{new Date(item.startedAt).toLocaleDateString()}
|
{new Date(item.createdAt).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{item.finishedAt && (
|
|
||||||
<View className="bg-green-100 rounded-full px-2 py-0.5">
|
|
||||||
<Text className="text-green-700 text-xs font-medium">{tc("done")}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</SwipeableRow>
|
</SwipeableRow>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ WatchConnectivity = requireOptionalNativeModule("WatchConnectivity") ?? {
|
|||||||
isReachable: async () => false,
|
isReachable: async () => false,
|
||||||
sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }),
|
sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }),
|
||||||
sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }),
|
sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }),
|
||||||
|
sendWorkoutDeleted: async () => ({ error: "WatchConnectivity native module not available" }),
|
||||||
pollEvents: async () => [],
|
pollEvents: async () => [],
|
||||||
getApplicationContext: async () => null,
|
getApplicationContext: async () => null,
|
||||||
updateApplicationContext: async () => {},
|
updateApplicationContext: async () => {},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Ionicons } from "@expo/vector-icons";
|
|||||||
import { MainTabParamList } from "./types";
|
import { MainTabParamList } from "./types";
|
||||||
import { WorkoutNavigator } from "./WorkoutNavigator";
|
import { WorkoutNavigator } from "./WorkoutNavigator";
|
||||||
import { HistoryNavigator } from "./HistoryNavigator";
|
import { HistoryNavigator } from "./HistoryNavigator";
|
||||||
import { SettingsScreen } from "../features/settings/screens/SettingsScreen";
|
import { SettingsNavigator } from "./SettingsNavigator";
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||||
|
|
||||||
@@ -45,8 +45,9 @@ export function MainTabNavigator() {
|
|||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Settings"
|
name="Settings"
|
||||||
component={SettingsScreen}
|
component={SettingsNavigator}
|
||||||
options={{
|
options={{
|
||||||
|
headerShown: false,
|
||||||
tabBarLabel: t("settings"),
|
tabBarLabel: t("settings"),
|
||||||
tabBarIcon: ({ color, size }) => (
|
tabBarIcon: ({ color, size }) => (
|
||||||
<Ionicons name="settings-outline" size={size} color={color} />
|
<Ionicons name="settings-outline" size={size} color={color} />
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import { SettingsStackParamList } from "./types";
|
||||||
|
import { SettingsScreen } from "../features/settings/screens/SettingsScreen";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<SettingsStackParamList>();
|
||||||
|
|
||||||
|
export function SettingsNavigator() {
|
||||||
|
const { t } = useTranslation("navigation");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: "#0ea5e9" },
|
||||||
|
headerTintColor: "#fff",
|
||||||
|
headerTitleStyle: { fontWeight: "bold" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="SettingsMain"
|
||||||
|
component={SettingsScreen}
|
||||||
|
options={{ title: t("settings") }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export type RootStackParamList = {
|
|||||||
Main: undefined;
|
Main: undefined;
|
||||||
WorkoutBuilder: { templateId?: string } | undefined;
|
WorkoutBuilder: { templateId?: string } | undefined;
|
||||||
PreSwim: { workoutId: string };
|
PreSwim: { workoutId: string };
|
||||||
PostSwim: { workoutId: string };
|
PostSwim: { sessionId: string };
|
||||||
SessionDetail: { sessionId: string };
|
SessionDetail: { sessionId: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@ export type HistoryStackParamList = {
|
|||||||
SessionDetail: { sessionId: string };
|
SessionDetail: { sessionId: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SettingsStackParamList = {
|
||||||
|
SettingsMain: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type RootStackProps<T extends keyof RootStackParamList> =
|
export type RootStackProps<T extends keyof RootStackParamList> =
|
||||||
NativeStackScreenProps<RootStackParamList, T>;
|
NativeStackScreenProps<RootStackParamList, T>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type WorkoutSet } from "@types/workout";
|
import { type WorkoutSet } from "@/types/workout";
|
||||||
|
|
||||||
export function calculateTotalDistance(sets: WorkoutSet[]): number {
|
export function calculateTotalDistance(sets: WorkoutSet[]): number {
|
||||||
return sets.reduce((acc, set) => acc + set.distancePerRep * set.repCount, 0);
|
return sets.reduce((acc, set) => acc + set.distancePerRep * set.repCount, 0);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ struct WorkoutListView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(workout.name ?? String(localized: "Workout"))
|
Text(workout.name ?? "Workout")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
Text("\(workout.sets.reduce(0) { $0 + $1.distancePerRep * $1.repCount })m · \(workout.sets.count) sets")
|
Text("\(workout.sets.reduce(0) { $0 + $1.distancePerRep * $1.repCount })m · \(workout.sets.count) sets")
|
||||||
@@ -75,7 +75,7 @@ struct WorkoutListView: View {
|
|||||||
ForEach(workoutManager.completedWorkouts.prefix(3)) { workout in
|
ForEach(workoutManager.completedWorkouts.prefix(3)) { workout in
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(workout.name ?? String(localized: "Workout"))
|
Text(workout.name ?? "Workout")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
Text(workout.completedAt ?? "")
|
Text(workout.completedAt ?? "")
|
||||||
|
|||||||
@@ -51,6 +51,65 @@
|
|||||||
},
|
},
|
||||||
"%lldm total" : {
|
"%lldm total" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%ds rest" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds Pause"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds hvile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds rest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds descanso"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds repos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds recupero"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds rust"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds odpoczynku"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%ds descanso"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"%lldx%lldm" : {
|
"%lldx%lldm" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -71,7 +130,7 @@
|
|||||||
"value" : "Zurück"
|
"value" : "Zurück"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Tilbage"
|
"value" : "Tilbage"
|
||||||
@@ -130,7 +189,7 @@
|
|||||||
"value" : "Kalorien"
|
"value" : "Kalorien"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Kalorier"
|
"value" : "Kalorier"
|
||||||
@@ -189,7 +248,7 @@
|
|||||||
"value" : "Bahn abschließen"
|
"value" : "Bahn abschließen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Fuldfør bane"
|
"value" : "Fuldfør bane"
|
||||||
@@ -248,7 +307,7 @@
|
|||||||
"value" : "Erstelle ein Workout auf deinem iPhone um zu beginnen"
|
"value" : "Erstelle ein Workout auf deinem iPhone um zu beginnen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Opret en træning på din iPhone for at komme i gang"
|
"value" : "Opret en træning på din iPhone for at komme i gang"
|
||||||
@@ -307,7 +366,7 @@
|
|||||||
"value" : "Distanz"
|
"value" : "Distanz"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Distance"
|
"value" : "Distance"
|
||||||
@@ -366,7 +425,7 @@
|
|||||||
"value" : "Beenden"
|
"value" : "Beenden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Slut"
|
"value" : "Slut"
|
||||||
@@ -417,7 +476,63 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"End Workout" : {
|
"End Workout" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Workout beenden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Afslut træning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "End Workout"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Finalizar entrenamiento"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Terminer l'entraînement"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Termina allenamento"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Workout beëindigen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Zakończ trening"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Terminar treino"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"kcal" : {
|
"kcal" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -428,7 +543,7 @@
|
|||||||
"value" : "%@kcal"
|
"value" : "%@kcal"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "%@kcal"
|
"value" : "%@kcal"
|
||||||
@@ -487,7 +602,7 @@
|
|||||||
"value" : "Bahn erkannt!"
|
"value" : "Bahn erkannt!"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Bane registreret!"
|
"value" : "Bane registreret!"
|
||||||
@@ -546,7 +661,7 @@
|
|||||||
"value" : "Runden"
|
"value" : "Runden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Baner"
|
"value" : "Baner"
|
||||||
@@ -605,7 +720,7 @@
|
|||||||
"value" : "%@m Becken"
|
"value" : "%@m Becken"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "%@m pool"
|
"value" : "%@m pool"
|
||||||
@@ -664,7 +779,7 @@
|
|||||||
"value" : "%@m gesamt"
|
"value" : "%@m gesamt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "%@m i alt"
|
"value" : "%@m i alt"
|
||||||
@@ -733,7 +848,7 @@
|
|||||||
"value" : "Noch keine Workouts"
|
"value" : "Noch keine Workouts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Ingen træninger endnu"
|
"value" : "Ingen træninger endnu"
|
||||||
@@ -792,7 +907,7 @@
|
|||||||
"value" : "OK"
|
"value" : "OK"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "OK"
|
"value" : "OK"
|
||||||
@@ -843,7 +958,63 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Pause" : {
|
"Pause" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pause"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pause"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pause"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pausa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pause"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pausa"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pauze"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pauza"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pausa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Recent" : {
|
"Recent" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -854,7 +1025,7 @@
|
|||||||
"value" : "Zuletzt"
|
"value" : "Zuletzt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Seneste"
|
"value" : "Seneste"
|
||||||
@@ -923,7 +1094,7 @@
|
|||||||
"value" : "Pause"
|
"value" : "Pause"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Hvile"
|
"value" : "Hvile"
|
||||||
@@ -974,10 +1145,122 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Rest %@" : {
|
"Rest %@" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Pause %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Hvile %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rest %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Descanso %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Repos %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Recupero %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rust %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Odpoczynek %@"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Descanso %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Resume" : {
|
"Resume" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Fortsetzen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Forsæt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Resume"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reanudar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reprendre"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Riprendi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Hervatten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Wznów"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Retomar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Saved Workouts" : {
|
"Saved Workouts" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -988,7 +1271,7 @@
|
|||||||
"value" : "Gespeicherte Workouts"
|
"value" : "Gespeicherte Workouts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Gemte træninger"
|
"value" : "Gemte træninger"
|
||||||
@@ -1057,7 +1340,7 @@
|
|||||||
"value" : "Sets"
|
"value" : "Sets"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Sæt"
|
"value" : "Sæt"
|
||||||
@@ -1116,7 +1399,7 @@
|
|||||||
"value" : "Starten"
|
"value" : "Starten"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Start"
|
"value" : "Start"
|
||||||
@@ -1175,7 +1458,7 @@
|
|||||||
"value" : "Statistiken"
|
"value" : "Statistiken"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Statistik"
|
"value" : "Statistik"
|
||||||
@@ -1237,7 +1520,7 @@
|
|||||||
"value" : "Gesamtzeit"
|
"value" : "Gesamtzeit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Samlet tid"
|
"value" : "Samlet tid"
|
||||||
@@ -1287,6 +1570,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Water Lock" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Wassersperre"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"da" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Vandlås"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Water Lock"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Bloqueo de agua"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Verrouillage eau"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Blocco acqua"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Watermodus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Blokada wody"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Bloqueio de água"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Workout" : {
|
"Workout" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1296,7 +1638,7 @@
|
|||||||
"value" : "Workout"
|
"value" : "Workout"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Træning"
|
"value" : "Træning"
|
||||||
@@ -1355,7 +1697,7 @@
|
|||||||
"value" : "Training abgeschlossen!"
|
"value" : "Training abgeschlossen!"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dk" : {
|
"da" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Træning afsluttet!"
|
"value" : "Træning afsluttet!"
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ struct WorkoutOverviewPage: View {
|
|||||||
|
|
||||||
if let workout = workoutManager.selectedWorkout {
|
if let workout = workoutManager.selectedWorkout {
|
||||||
ForEach(Array(workout.sets.enumerated()), id: \.offset) { i, set in
|
ForEach(Array(workout.sets.enumerated()), id: \.offset) { i, set in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack {
|
HStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(i < workoutManager.currentSetIndex ? .green :
|
.fill(i < workoutManager.currentSetIndex ? .green :
|
||||||
@@ -178,6 +179,18 @@ struct WorkoutOverviewPage: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
if set.restType != "none", set.restDuration > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Spacer().frame(width: 14)
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text(String(format: "%ds rest", Int(set.restDuration)))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +212,17 @@ struct ControlsPage: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
if workoutManager.isWaterLockEnabled {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "drop.fill")
|
||||||
|
.foregroundColor(.cyan)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Water Lock")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.cyan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ struct WorkoutDetailView: View {
|
|||||||
if let workout = workoutManager.selectedWorkout {
|
if let workout = workoutManager.selectedWorkout {
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
Text(workout.name ?? String(localized: "Workout"))
|
Text(workout.name ?? "Workout")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
Text("\(Int(workout.poolLength))m pool")
|
Text("\(Int(workout.poolLength))m pool")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Text("\(workout.sets.reduce(0) { $0 + $1.distancePerRep * $1.repCount })m total")
|
let totalM = workout.sets.reduce(0) { $0 + $1.distancePerRep * $1.repCount }
|
||||||
|
Text("\(totalM)m total")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ struct WorkoutDetailView: View {
|
|||||||
|
|
||||||
Section("Sets") {
|
Section("Sets") {
|
||||||
ForEach(Array(workout.sets.enumerated()), id: \.offset) { i, set in
|
ForEach(Array(workout.sets.enumerated()), id: \.offset) { i, set in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(i + 1). \(set.stroke)")
|
Text("\(i + 1). \(set.stroke)")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
@@ -33,6 +35,18 @@ struct WorkoutDetailView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
if set.restType != "none", set.restDuration > 0 {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Spacer().frame(width: 14)
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text(String(format: "%ds rest", Int(set.restDuration)))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
import HealthKit
|
import HealthKit
|
||||||
import WatchKit
|
import WatchKit
|
||||||
import WatchConnectivity
|
import WatchConnectivity
|
||||||
import CoreMotion
|
|
||||||
|
|
||||||
class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
||||||
@Published var isWorkoutActive = false
|
@Published var isWorkoutActive = false
|
||||||
@@ -12,7 +11,7 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
@Published var selectedWorkout: StoredWorkout?
|
@Published var selectedWorkout: StoredWorkout?
|
||||||
@Published var currentSetIndex = 0
|
@Published var currentSetIndex = 0
|
||||||
@Published var currentRep = 0
|
@Published var currentRep = 0
|
||||||
@Published var currentLapDistance = 0
|
@Published var currentRepDistanceSwum: Int = 0
|
||||||
@Published var elapsedSeconds: TimeInterval = 0
|
@Published var elapsedSeconds: TimeInterval = 0
|
||||||
@Published var heartRate: Double = 0
|
@Published var heartRate: Double = 0
|
||||||
@Published var activeCalories: Double = 0
|
@Published var activeCalories: Double = 0
|
||||||
@@ -33,14 +32,16 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
private var calorieQuery: HKObserverQuery?
|
private var calorieQuery: HKObserverQuery?
|
||||||
|
|
||||||
// Lap detection
|
// Lap detection
|
||||||
private let motionManager = CMMotionManager()
|
|
||||||
private var lapDetectionTimer: Timer?
|
|
||||||
private var lastLapDetectionTime: Date = .distantPast
|
|
||||||
private var wasStationary = false
|
|
||||||
private var stationarySince: Date?
|
|
||||||
@Published var lapDetected = false
|
@Published var lapDetected = false
|
||||||
@Published var healthKitAuthorized = false
|
@Published var healthKitAuthorized = false
|
||||||
@Published var isPaused = false
|
@Published var isPaused = false
|
||||||
|
@Published var isWaterLockEnabled = false
|
||||||
|
|
||||||
|
// HealthKit lap tracking
|
||||||
|
private var currentStrokeCount: Double = 0
|
||||||
|
private var healthKitLapCount: Int = 0
|
||||||
|
|
||||||
|
private var waterLockCheckTimer: Timer?
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -53,6 +54,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
let typesToRead: Set<HKObjectType> = [
|
let typesToRead: Set<HKObjectType> = [
|
||||||
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
|
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
|
||||||
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
|
HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned)!,
|
||||||
|
HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount)!,
|
||||||
|
HKQuantityType.quantityType(forIdentifier: .distanceSwimming)!,
|
||||||
HKObjectType.workoutType()
|
HKObjectType.workoutType()
|
||||||
]
|
]
|
||||||
let typesToWrite: Set<HKSampleType> = [
|
let typesToWrite: Set<HKSampleType> = [
|
||||||
@@ -152,23 +155,23 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
activeCalories = 0
|
activeCalories = 0
|
||||||
heartRate = 0
|
heartRate = 0
|
||||||
completedLaps = []
|
completedLaps = []
|
||||||
wasStationary = false
|
healthKitLapCount = 0
|
||||||
stationarySince = nil
|
currentStrokeCount = 0
|
||||||
|
currentRepDistanceSwum = 0
|
||||||
|
|
||||||
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
|
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
|
||||||
startHeartRateQuery()
|
startHeartRateQuery()
|
||||||
startCalorieQuery()
|
startCalorieQuery()
|
||||||
startTimer()
|
startTimer()
|
||||||
startLapDetection()
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
WKInterfaceDevice.current().enableWaterLock()
|
WKInterfaceDevice.current().enableWaterLock()
|
||||||
}
|
}
|
||||||
|
startWaterLockCheck()
|
||||||
WKInterfaceDevice.current().play(.start)
|
WKInterfaceDevice.current().play(.start)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pauseWorkout() {
|
func pauseWorkout() {
|
||||||
workoutTimer?.invalidate()
|
workoutTimer?.invalidate()
|
||||||
stopLapDetection()
|
|
||||||
stopHeartRateQuery()
|
stopHeartRateQuery()
|
||||||
stopCalorieQuery()
|
stopCalorieQuery()
|
||||||
isPaused = true
|
isPaused = true
|
||||||
@@ -177,7 +180,6 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
|
|
||||||
func resumeWorkout() {
|
func resumeWorkout() {
|
||||||
startTimer()
|
startTimer()
|
||||||
startLapDetection()
|
|
||||||
startHeartRateQuery()
|
startHeartRateQuery()
|
||||||
startCalorieQuery()
|
startCalorieQuery()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
@@ -189,7 +191,7 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
restTimer?.invalidate()
|
restTimer?.invalidate()
|
||||||
isWorkoutActive = false
|
isWorkoutActive = false
|
||||||
|
|
||||||
stopLapDetection()
|
stopWaterLockCheck()
|
||||||
stopHeartRateQuery()
|
stopHeartRateQuery()
|
||||||
stopCalorieQuery()
|
stopCalorieQuery()
|
||||||
endHealthKitWorkout()
|
endHealthKitWorkout()
|
||||||
@@ -199,6 +201,23 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
WKInterfaceDevice.current().play(.success)
|
WKInterfaceDevice.current().play(.success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Water Lock Check
|
||||||
|
|
||||||
|
private func startWaterLockCheck() {
|
||||||
|
isWaterLockEnabled = WKInterfaceDevice.current().isWaterLockEnabled
|
||||||
|
waterLockCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.isWaterLockEnabled = WKInterfaceDevice.current().isWaterLockEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopWaterLockCheck() {
|
||||||
|
waterLockCheckTimer?.invalidate()
|
||||||
|
waterLockCheckTimer = nil
|
||||||
|
isWaterLockEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Workout Summary
|
// MARK: - Workout Summary
|
||||||
|
|
||||||
private func createWorkoutSummary() {
|
private func createWorkoutSummary() {
|
||||||
@@ -213,30 +232,12 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
|
|
||||||
// MARK: - Lap Tracking
|
// MARK: - Lap Tracking
|
||||||
|
|
||||||
func completeLap() {
|
|
||||||
guard let workout = selectedWorkout else { return }
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
|
|
||||||
|
|
||||||
let lap = CompletedLap(
|
|
||||||
setPosition: currentSetIndex + 1,
|
|
||||||
repNumber: currentRep + 1,
|
|
||||||
distance: workout.sets[currentSetIndex].distancePerRep,
|
|
||||||
duration: duration,
|
|
||||||
heartRate: Int(heartRate)
|
|
||||||
)
|
|
||||||
completedLaps.append(lap)
|
|
||||||
|
|
||||||
WKInterfaceDevice.current().play(.click)
|
|
||||||
|
|
||||||
advanceToNextRep()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func advanceToNextRep() {
|
private func advanceToNextRep() {
|
||||||
guard let workout = selectedWorkout else { return }
|
guard let workout = selectedWorkout else { return }
|
||||||
let currentSet = workout.sets[currentSetIndex]
|
let currentSet = workout.sets[currentSetIndex]
|
||||||
|
|
||||||
|
currentRepDistanceSwum = 0
|
||||||
|
|
||||||
if currentRep + 1 < currentSet.repCount {
|
if currentRep + 1 < currentSet.repCount {
|
||||||
currentRep += 1
|
currentRep += 1
|
||||||
lapStartTime = Date()
|
lapStartTime = Date()
|
||||||
@@ -298,10 +299,9 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentSetDistanceSwum: Double {
|
var currentSetDistanceSwum: Double {
|
||||||
guard let workout = selectedWorkout, currentSetIndex < workout.sets.count else { return 0 }
|
completedLaps
|
||||||
let setDistance = workout.sets[currentSetIndex].distancePerRep
|
.filter { $0.setPosition == currentSetIndex + 1 }
|
||||||
let completedInSet = completedLaps.filter { $0.setPosition == currentSetIndex + 1 }.count
|
.reduce(0) { $0 + Double($1.distance) }
|
||||||
return Double(setDistance) * Double(completedInSet)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentSetTotalDistance: Double {
|
var currentSetTotalDistance: Double {
|
||||||
@@ -393,62 +393,11 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
healthStore.execute(query)
|
healthStore.execute(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Lap Detection (Accelerometer)
|
// MARK: - Lap Detection (HealthKit-based)
|
||||||
|
|
||||||
private func startLapDetection() {
|
// Lap detection is now handled by HKLiveWorkoutBuilderDelegate
|
||||||
guard motionManager.isAccelerometerAvailable else { return }
|
// which processes HKWorkoutEvent.lap events from HealthKit's
|
||||||
motionManager.accelerometerUpdateInterval = 0.1 // 10 Hz
|
// built-in swimming lap detection algorithm.
|
||||||
motionManager.startAccelerometerUpdates()
|
|
||||||
|
|
||||||
lapDetectionTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { [weak self] _ in
|
|
||||||
self?.processAccelerometerData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopLapDetection() {
|
|
||||||
lapDetectionTimer?.invalidate()
|
|
||||||
lapDetectionTimer = nil
|
|
||||||
motionManager.stopAccelerometerUpdates()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func processAccelerometerData() {
|
|
||||||
guard let data = motionManager.accelerometerData else { return }
|
|
||||||
|
|
||||||
let magnitude = sqrt(data.acceleration.x * data.acceleration.x +
|
|
||||||
data.acceleration.y * data.acceleration.y +
|
|
||||||
data.acceleration.z * data.acceleration.z)
|
|
||||||
|
|
||||||
let isStationary = magnitude < 1.1 // Near rest (~1g from gravity)
|
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
if isStationary {
|
|
||||||
if !wasStationary {
|
|
||||||
wasStationary = true
|
|
||||||
stationarySince = now
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if wasStationary, let since = stationarySince {
|
|
||||||
let pauseDuration = now.timeIntervalSince(since)
|
|
||||||
let timeSinceLastLap = now.timeIntervalSince(lastLapDetectionTime)
|
|
||||||
|
|
||||||
// Wall touch: was stationary for 0.3-3s, then pushed off
|
|
||||||
// Min 8s between laps to avoid false positives
|
|
||||||
if pauseDuration >= 0.3 && pauseDuration <= 3.0 && timeSinceLastLap >= 8.0 {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.completeLap()
|
|
||||||
self.lapDetected = true
|
|
||||||
self.lastLapDetectionTime = now
|
|
||||||
}
|
|
||||||
// Reset after detection
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
||||||
self.lapDetected = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wasStationary = false
|
|
||||||
stationarySince = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Timer
|
// MARK: - Timer
|
||||||
|
|
||||||
@@ -510,6 +459,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
|
self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
|
||||||
case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned):
|
case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned):
|
||||||
self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
|
self.activeCalories = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
|
||||||
|
case HKQuantityType.quantityType(forIdentifier: .swimmingStrokeCount):
|
||||||
|
self.currentStrokeCount = statistics.sumQuantity()?.doubleValue(for: .count()) ?? 0
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -519,15 +470,67 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
|
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
|
||||||
// Handle collected events if needed
|
guard let events = workoutBuilder.workoutEvents as? [HKWorkoutEvent] else { return }
|
||||||
|
|
||||||
|
let lapEvents = events.filter { $0.type == .lap }
|
||||||
|
let newLapCount = lapEvents.count
|
||||||
|
|
||||||
|
if newLapCount > healthKitLapCount {
|
||||||
|
let lapsDelta = newLapCount - healthKitLapCount
|
||||||
|
healthKitLapCount = newLapCount
|
||||||
|
|
||||||
|
for _ in 0..<lapsDelta {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.completeLapFromHealthKit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeLapFromHealthKit() {
|
||||||
|
guard let workout = selectedWorkout else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
|
||||||
|
let lapDistance = Int(workout.poolLength)
|
||||||
|
|
||||||
|
let lap = CompletedLap(
|
||||||
|
setPosition: currentSetIndex + 1,
|
||||||
|
repNumber: currentRep + 1,
|
||||||
|
distance: lapDistance,
|
||||||
|
duration: duration,
|
||||||
|
heartRate: Int(heartRate),
|
||||||
|
strokeType: workout.sets[currentSetIndex].stroke,
|
||||||
|
strokeCount: Int(currentStrokeCount)
|
||||||
|
)
|
||||||
|
completedLaps.append(lap)
|
||||||
|
currentStrokeCount = 0
|
||||||
|
|
||||||
|
currentRepDistanceSwum += lapDistance
|
||||||
|
|
||||||
|
WKInterfaceDevice.current().play(.click)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.lapDetected = true
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
self.lapDetected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentRepDistanceSwum >= workout.sets[currentSetIndex].distancePerRep {
|
||||||
|
advanceToNextRep()
|
||||||
|
}
|
||||||
|
lapStartTime = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sync to Phone
|
// MARK: - Sync to Phone
|
||||||
|
|
||||||
private func syncCompletedWorkout() {
|
private func syncCompletedWorkout() {
|
||||||
|
let sessionId = UUID().uuidString
|
||||||
let workoutSummary: [String: Any] = [
|
let workoutSummary: [String: Any] = [
|
||||||
"type": "workoutCompleted",
|
"type": "workoutCompleted",
|
||||||
"workoutId": selectedWorkout?.id ?? "",
|
"workoutId": selectedWorkout?.id ?? "",
|
||||||
|
"sessionId": sessionId,
|
||||||
"name": selectedWorkout?.name ?? "Workout",
|
"name": selectedWorkout?.name ?? "Workout",
|
||||||
"poolLength": selectedWorkout?.poolLength ?? 0,
|
"poolLength": selectedWorkout?.poolLength ?? 0,
|
||||||
"laps": completedLaps.map { lap in
|
"laps": completedLaps.map { lap in
|
||||||
@@ -537,6 +540,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
|
|||||||
"distance": lap.distance,
|
"distance": lap.distance,
|
||||||
"duration": lap.duration,
|
"duration": lap.duration,
|
||||||
"heartRate": lap.heartRate ?? 0,
|
"heartRate": lap.heartRate ?? 0,
|
||||||
|
"strokeType": lap.strokeType ?? "",
|
||||||
|
"strokeCount": lap.strokeCount ?? 0,
|
||||||
] as [String: Any]
|
] as [String: Any]
|
||||||
},
|
},
|
||||||
"duration": elapsedSeconds,
|
"duration": elapsedSeconds,
|
||||||
@@ -584,6 +589,8 @@ struct CompletedLap {
|
|||||||
let distance: Int
|
let distance: Int
|
||||||
let duration: TimeInterval
|
let duration: TimeInterval
|
||||||
let heartRate: Int?
|
let heartRate: Int?
|
||||||
|
let strokeType: String?
|
||||||
|
let strokeCount: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkoutSummary {
|
struct WorkoutSummary {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
||||||
module.exports = (config) => ({
|
module.exports = (config) => ({
|
||||||
name: config.name,
|
name: "SwimBuddyWatch",
|
||||||
type: "watch",
|
type: "watch",
|
||||||
icon: "../../assets/icon.png",
|
icon: "../../assets/icon.png",
|
||||||
deploymentTarget: "10.0",
|
deploymentTarget: "10.0",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ plugins {
|
|||||||
id("io.sentry.android.gradle") version "6.17.0"
|
id("io.sentry.android.gradle") version "6.17.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val credentialsFile = rootProject.file("../credentials.json")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "nl.guidoit.swimbuddy"
|
namespace = "nl.guidoit.swimbuddy"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -22,6 +24,20 @@ android {
|
|||||||
manifestPlaceholders["sentryDsn"] = sentryDsn
|
manifestPlaceholders["sentryDsn"] = sentryDsn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (credentialsFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
val credentials = groovy.json.JsonSlurper().parse(credentialsFile) as Map<*, *>
|
||||||
|
val keystoreData = credentials["android"]!! as Map<*, *>
|
||||||
|
val keyData = keystoreData["keystore"]!! as Map<*, *>
|
||||||
|
storeFile = rootProject.file(keyData["keystorePath"] as String)
|
||||||
|
storePassword = keyData["keystorePassword"] as String
|
||||||
|
keyAlias = keyData["keyAlias"] as String
|
||||||
|
keyPassword = keyData["keyPassword"] as? String ?: keyData["keystorePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -30,6 +46,9 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
|
if (credentialsFile.exists()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +69,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
// Wear OS Compose
|
// Wear OS Compose
|
||||||
implementation("androidx.wear.compose:compose-material:1.3.1")
|
implementation("androidx.wear.compose:compose-material:1.3.1")
|
||||||
implementation("androidx.wear.compose:compose-foundation:1.3.1")
|
implementation("androidx.wear.compose:compose-foundation:1.5.0")
|
||||||
implementation("androidx.wear.compose:compose-navigation:1.3.1")
|
implementation("androidx.wear.compose:compose-navigation:1.3.1")
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
android:name="io.sentry.attach-screenshot"
|
android:name="io.sentry.attach-screenshot"
|
||||||
android:value="true" />
|
android:value="true" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.wearable.standalone"
|
||||||
|
android:value="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".SwimBuddyWearActivity"
|
android:name=".SwimBuddyWearActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ class HealthServicesManager(context: Context) {
|
|||||||
|
|
||||||
private val hrChannel = Channel<Double>(Channel.CONFLATED)
|
private val hrChannel = Channel<Double>(Channel.CONFLATED)
|
||||||
private val calChannel = Channel<Double>(Channel.CONFLATED)
|
private val calChannel = Channel<Double>(Channel.CONFLATED)
|
||||||
|
private val strokeCountChannel = Channel<Long>(Channel.CONFLATED)
|
||||||
private var callbackRegistered = false
|
private var callbackRegistered = false
|
||||||
|
|
||||||
private val combinedCallback = object : androidx.health.services.client.ExerciseUpdateCallback {
|
private val combinedCallback = object : androidx.health.services.client.ExerciseUpdateCallback {
|
||||||
override fun onNewUpdate(update: ExerciseUpdate) {
|
override fun onNewUpdate(update: ExerciseUpdate) {
|
||||||
update.getData(DataType.HEART_RATE_BPM)?.toDouble()?.let { hrChannel.trySend(it) }
|
update.getData(DataType.HEART_RATE_BPM)?.toDouble()?.let { hrChannel.trySend(it) }
|
||||||
update.getData(DataType.CALORIES)?.toDouble()?.let { calChannel.trySend(it) }
|
update.getData(DataType.CALORIES)?.toDouble()?.let { calChannel.trySend(it) }
|
||||||
|
update.getData(DataType.SWIMMING_STROKES)?.let { strokeCountChannel.trySend(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAvailabilityChanged(dataType: DataType, availability: DataPointAvailability) {}
|
override fun onAvailabilityChanged(dataType: DataType, availability: DataPointAvailability) {}
|
||||||
@@ -46,6 +48,7 @@ class HealthServicesManager(context: Context) {
|
|||||||
|
|
||||||
val config = ExerciseConfig.builder(ExerciseType.SWIMMING_POOL)
|
val config = ExerciseConfig.builder(ExerciseType.SWIMMING_POOL)
|
||||||
.setDataTypes(poolCapabilities.supportedDataTypes)
|
.setDataTypes(poolCapabilities.supportedDataTypes)
|
||||||
|
.setSwimmingPoolLengthMeters(poolLengthMeters)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
exerciseClient.startExerciseAsync(config).await()
|
exerciseClient.startExerciseAsync(config).await()
|
||||||
@@ -73,6 +76,11 @@ class HealthServicesManager(context: Context) {
|
|||||||
return calChannel.receiveAsFlow()
|
return calChannel.receiveAsFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun strokeCountFlow(): Flow<Long> {
|
||||||
|
ensureCallbackRegistered()
|
||||||
|
return strokeCountChannel.receiveAsFlow()
|
||||||
|
}
|
||||||
|
|
||||||
private fun ensureCallbackRegistered() {
|
private fun ensureCallbackRegistered() {
|
||||||
if (!callbackRegistered) {
|
if (!callbackRegistered) {
|
||||||
exerciseClient.setUpdateCallback(combinedCallback)
|
exerciseClient.setUpdateCallback(combinedCallback)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class SwimBuddyWearActivity : ComponentActivity() {
|
|||||||
val completedWorkouts by viewModel.completedWorkouts.collectAsState()
|
val completedWorkouts by viewModel.completedWorkouts.collectAsState()
|
||||||
val selectedWorkout by viewModel.selectedWorkout.collectAsState()
|
val selectedWorkout by viewModel.selectedWorkout.collectAsState()
|
||||||
val isWorkoutActive by viewModel.isWorkoutActive.collectAsState()
|
val isWorkoutActive by viewModel.isWorkoutActive.collectAsState()
|
||||||
|
val isPaused by viewModel.isPaused.collectAsState()
|
||||||
val currentSetIndex by viewModel.currentSetIndex.collectAsState()
|
val currentSetIndex by viewModel.currentSetIndex.collectAsState()
|
||||||
val currentRep by viewModel.currentRep.collectAsState()
|
val currentRep by viewModel.currentRep.collectAsState()
|
||||||
val elapsedSeconds by viewModel.elapsedSeconds.collectAsState()
|
val elapsedSeconds by viewModel.elapsedSeconds.collectAsState()
|
||||||
@@ -29,12 +30,14 @@ class SwimBuddyWearActivity : ComponentActivity() {
|
|||||||
val isResting by viewModel.isResting.collectAsState()
|
val isResting by viewModel.isResting.collectAsState()
|
||||||
val restTimeRemaining by viewModel.restTimeRemaining.collectAsState()
|
val restTimeRemaining by viewModel.restTimeRemaining.collectAsState()
|
||||||
val workoutSummary by viewModel.workoutSummary.collectAsState()
|
val workoutSummary by viewModel.workoutSummary.collectAsState()
|
||||||
|
val isWaterLockActive by viewModel.isWaterLockActive.collectAsState()
|
||||||
|
|
||||||
SwimBuddyApp(
|
SwimBuddyApp(
|
||||||
savedWorkouts = savedWorkouts,
|
savedWorkouts = savedWorkouts,
|
||||||
completedWorkouts = completedWorkouts,
|
completedWorkouts = completedWorkouts,
|
||||||
selectedWorkout = selectedWorkout,
|
selectedWorkout = selectedWorkout,
|
||||||
isWorkoutActive = isWorkoutActive,
|
isWorkoutActive = isWorkoutActive,
|
||||||
|
isPaused = isPaused,
|
||||||
currentSetIndex = currentSetIndex,
|
currentSetIndex = currentSetIndex,
|
||||||
currentRep = currentRep,
|
currentRep = currentRep,
|
||||||
elapsedSeconds = elapsedSeconds,
|
elapsedSeconds = elapsedSeconds,
|
||||||
@@ -47,9 +50,13 @@ class SwimBuddyWearActivity : ComponentActivity() {
|
|||||||
totalDistanceSwum = viewModel.totalDistanceSwum(),
|
totalDistanceSwum = viewModel.totalDistanceSwum(),
|
||||||
currentSetDistanceSwum = viewModel.currentSetDistanceSwum(),
|
currentSetDistanceSwum = viewModel.currentSetDistanceSwum(),
|
||||||
currentSetTotalDistance = viewModel.currentSetTotalDistance(),
|
currentSetTotalDistance = viewModel.currentSetTotalDistance(),
|
||||||
|
isWaterLockActive = isWaterLockActive,
|
||||||
onSelectWorkout = { viewModel.selectWorkout(it) },
|
onSelectWorkout = { viewModel.selectWorkout(it) },
|
||||||
onStartWorkout = { viewModel.startWorkout() },
|
onStartWorkout = { viewModel.startWorkout() },
|
||||||
onCompleteLap = { viewModel.completeLap() },
|
onCompleteLap = { viewModel.completeLap() },
|
||||||
|
onPauseResume = {
|
||||||
|
if (isPaused) viewModel.resumeWorkout() else viewModel.pauseWorkout()
|
||||||
|
},
|
||||||
onEndWorkout = { viewModel.endWorkout() },
|
onEndWorkout = { viewModel.endWorkout() },
|
||||||
onBack = { viewModel.selectWorkout(null) },
|
onBack = { viewModel.selectWorkout(null) },
|
||||||
onDeleteSaved = { viewModel.deleteSavedWorkout(it) },
|
onDeleteSaved = { viewModel.deleteSavedWorkout(it) },
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package nl.guidoit.swimbuddy
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
class WaterLockHelper {
|
||||||
|
|
||||||
|
private val _isActive = MutableStateFlow(false)
|
||||||
|
val isActive: StateFlow<Boolean> = _isActive.asStateFlow()
|
||||||
|
|
||||||
|
fun onWorkoutStarted() {
|
||||||
|
_isActive.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onWorkoutEnded() {
|
||||||
|
_isActive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
private val healthManager = HealthServicesManager(application)
|
private val healthManager = HealthServicesManager(application)
|
||||||
private val storage = WorkoutStorage(application)
|
private val storage = WorkoutStorage(application)
|
||||||
|
private val waterLockHelper = WaterLockHelper()
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
private val appContext = application
|
private val appContext = application
|
||||||
|
|
||||||
@@ -67,9 +68,16 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private val _workoutSummary = MutableStateFlow<WorkoutSummary?>(null)
|
private val _workoutSummary = MutableStateFlow<WorkoutSummary?>(null)
|
||||||
val workoutSummary: StateFlow<WorkoutSummary?> = _workoutSummary.asStateFlow()
|
val workoutSummary: StateFlow<WorkoutSummary?> = _workoutSummary.asStateFlow()
|
||||||
|
|
||||||
|
private val _isPaused = MutableStateFlow(false)
|
||||||
|
val isPaused: StateFlow<Boolean> = _isPaused.asStateFlow()
|
||||||
|
|
||||||
|
val isWaterLockActive: StateFlow<Boolean> = waterLockHelper.isActive
|
||||||
|
|
||||||
private var startTime = 0L
|
private var startTime = 0L
|
||||||
private var lapStartTime = 0L
|
private var lapStartTime = 0L
|
||||||
|
private var pausedElapsedSeconds = 0f
|
||||||
private var lapDetector: LapDetector? = null
|
private var lapDetector: LapDetector? = null
|
||||||
|
private var currentStrokeCount = 0L
|
||||||
|
|
||||||
private val workoutReceiver = object : BroadcastReceiver() {
|
private val workoutReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
@@ -136,19 +144,45 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
healthManager.startSwimmingWorkout(workout.poolLength)
|
healthManager.startSwimmingWorkout(workout.poolLength)
|
||||||
_isWorkoutActive.value = true
|
_isWorkoutActive.value = true
|
||||||
|
_isPaused.value = false
|
||||||
|
waterLockHelper.onWorkoutStarted()
|
||||||
startTime = System.currentTimeMillis()
|
startTime = System.currentTimeMillis()
|
||||||
lapStartTime = startTime
|
lapStartTime = startTime
|
||||||
|
pausedElapsedSeconds = 0f
|
||||||
_currentSetIndex.value = 0
|
_currentSetIndex.value = 0
|
||||||
_currentRep.value = 0
|
_currentRep.value = 0
|
||||||
_completedLaps.value = emptyList()
|
_completedLaps.value = emptyList()
|
||||||
|
currentStrokeCount = 0L
|
||||||
|
|
||||||
startElapsedTimeTracking()
|
startElapsedTimeTracking()
|
||||||
startHeartRateTracking()
|
startHeartRateTracking()
|
||||||
startCaloriesTracking()
|
startCaloriesTracking()
|
||||||
|
startStrokeCountTracking()
|
||||||
startLapDetection()
|
startLapDetection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pauseWorkout() {
|
||||||
|
if (!_isWorkoutActive.value || _isPaused.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
healthManager.pauseWorkout()
|
||||||
|
_isPaused.value = true
|
||||||
|
pausedElapsedSeconds = _elapsedSeconds.value
|
||||||
|
HapticFeedback.play(appContext, HapticFeedback.Type.NOTIFICATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeWorkout() {
|
||||||
|
if (!_isWorkoutActive.value || !_isPaused.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
healthManager.resumeWorkout()
|
||||||
|
_isPaused.value = false
|
||||||
|
startTime = System.currentTimeMillis() - (pausedElapsedSeconds * 1000).toLong()
|
||||||
|
lapStartTime = System.currentTimeMillis()
|
||||||
|
HapticFeedback.play(appContext, HapticFeedback.Type.START)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startLapDetection() {
|
private fun startLapDetection() {
|
||||||
lapDetector = LapDetector(appContext) {
|
lapDetector = LapDetector(appContext) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -164,7 +198,9 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
private fun startElapsedTimeTracking() {
|
private fun startElapsedTimeTracking() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
while (_isWorkoutActive.value) {
|
while (_isWorkoutActive.value) {
|
||||||
|
if (!_isPaused.value) {
|
||||||
_elapsedSeconds.value = (System.currentTimeMillis() - startTime) / 1000f
|
_elapsedSeconds.value = (System.currentTimeMillis() - startTime) / 1000f
|
||||||
|
}
|
||||||
kotlinx.coroutines.delay(1000)
|
kotlinx.coroutines.delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +222,14 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startStrokeCountTracking() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
healthManager.strokeCountFlow().collect { delta ->
|
||||||
|
currentStrokeCount += delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun completeLap() {
|
fun completeLap() {
|
||||||
if (!_isWorkoutActive.value || _isResting.value) return
|
if (!_isWorkoutActive.value || _isResting.value) return
|
||||||
val workout = _selectedWorkout.value ?: return
|
val workout = _selectedWorkout.value ?: return
|
||||||
@@ -202,10 +246,12 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
repNumber = _currentRep.value + 1,
|
repNumber = _currentRep.value + 1,
|
||||||
distance = currentSet.distancePerRep,
|
distance = currentSet.distancePerRep,
|
||||||
duration = lapDuration,
|
duration = lapDuration,
|
||||||
heartRate = _heartRate.value
|
heartRate = _heartRate.value,
|
||||||
|
strokeCount = currentStrokeCount.toInt()
|
||||||
)
|
)
|
||||||
|
|
||||||
_completedLaps.value = _completedLaps.value + lap
|
_completedLaps.value = _completedLaps.value + lap
|
||||||
|
currentStrokeCount = 0L
|
||||||
|
|
||||||
if (_currentRep.value < currentSet.repCount - 1) {
|
if (_currentRep.value < currentSet.repCount - 1) {
|
||||||
_currentRep.value++
|
_currentRep.value++
|
||||||
@@ -246,6 +292,8 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
healthManager.endWorkout()
|
healthManager.endWorkout()
|
||||||
_isWorkoutActive.value = false
|
_isWorkoutActive.value = false
|
||||||
|
_isPaused.value = false
|
||||||
|
waterLockHelper.onWorkoutEnded()
|
||||||
_currentSetIndex.value = 0
|
_currentSetIndex.value = 0
|
||||||
_currentRep.value = 0
|
_currentRep.value = 0
|
||||||
_elapsedSeconds.value = 0f
|
_elapsedSeconds.value = 0f
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ data class CompletedLap(
|
|||||||
val repNumber: Int,
|
val repNumber: Int,
|
||||||
val distance: Int,
|
val distance: Int,
|
||||||
val duration: Float,
|
val duration: Float,
|
||||||
val heartRate: Int?
|
val heartRate: Int?,
|
||||||
|
val strokeCount: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class WorkoutSummary(
|
data class WorkoutSummary(
|
||||||
|
|||||||
+10
-7
@@ -151,14 +151,17 @@ fun ActiveWorkoutScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onEndWorkout,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = Color.Red
|
||||||
|
)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.end),
|
text = stringResource(R.string.end_workout),
|
||||||
modifier = Modifier
|
color = Color.White
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 4.dp),
|
|
||||||
color = Color.Red,
|
|
||||||
style = MaterialTheme.typography.caption3,
|
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package nl.guidoit.swimbuddy.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.wear.compose.material.Button
|
||||||
|
import androidx.wear.compose.material.ButtonDefaults
|
||||||
|
import androidx.wear.compose.material.MaterialTheme
|
||||||
|
import androidx.wear.compose.material.Text
|
||||||
|
import nl.guidoit.swimbuddy.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ControlsScreen(
|
||||||
|
isPaused: Boolean,
|
||||||
|
isWaterLockActive: Boolean,
|
||||||
|
onPauseResume: () -> Unit,
|
||||||
|
onEndWorkout: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
if (isWaterLockActive) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "\uD83D\uDCA7",
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.water_lock),
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
color = Color.Cyan
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onPauseResume,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isPaused) stringResource(R.string.resume) else stringResource(R.string.pause),
|
||||||
|
color = Color.Black
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onEndWorkout,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
backgroundColor = Color.Red
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.end_workout),
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package nl.guidoit.swimbuddy.presentation
|
package nl.guidoit.swimbuddy.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.wear.compose.foundation.ExperimentalWearFoundationApi
|
||||||
|
import androidx.wear.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.wear.compose.foundation.pager.rememberPagerState
|
||||||
import nl.guidoit.swimbuddy.data.StoredWorkout
|
import nl.guidoit.swimbuddy.data.StoredWorkout
|
||||||
import nl.guidoit.swimbuddy.data.WorkoutSummary
|
import nl.guidoit.swimbuddy.data.WorkoutSummary
|
||||||
|
|
||||||
@@ -10,6 +15,7 @@ fun SwimBuddyApp(
|
|||||||
completedWorkouts: List<StoredWorkout>,
|
completedWorkouts: List<StoredWorkout>,
|
||||||
selectedWorkout: StoredWorkout?,
|
selectedWorkout: StoredWorkout?,
|
||||||
isWorkoutActive: Boolean,
|
isWorkoutActive: Boolean,
|
||||||
|
isPaused: Boolean,
|
||||||
currentSetIndex: Int,
|
currentSetIndex: Int,
|
||||||
currentRep: Int,
|
currentRep: Int,
|
||||||
elapsedSeconds: Float,
|
elapsedSeconds: Float,
|
||||||
@@ -22,9 +28,11 @@ fun SwimBuddyApp(
|
|||||||
totalDistanceSwum: Int,
|
totalDistanceSwum: Int,
|
||||||
currentSetDistanceSwum: Int,
|
currentSetDistanceSwum: Int,
|
||||||
currentSetTotalDistance: Int,
|
currentSetTotalDistance: Int,
|
||||||
|
isWaterLockActive: Boolean,
|
||||||
onSelectWorkout: (StoredWorkout) -> Unit,
|
onSelectWorkout: (StoredWorkout) -> Unit,
|
||||||
onStartWorkout: () -> Unit,
|
onStartWorkout: () -> Unit,
|
||||||
onCompleteLap: () -> Unit,
|
onCompleteLap: () -> Unit,
|
||||||
|
onPauseResume: () -> Unit,
|
||||||
onEndWorkout: () -> Unit,
|
onEndWorkout: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onDeleteSaved: (StoredWorkout) -> Unit,
|
onDeleteSaved: (StoredWorkout) -> Unit,
|
||||||
@@ -38,16 +46,8 @@ fun SwimBuddyApp(
|
|||||||
onDismiss = onDismissSummary
|
onDismiss = onDismissSummary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
isWorkoutActive && isResting -> {
|
|
||||||
RestScreen(
|
|
||||||
restTimeRemaining = restTimeRemaining,
|
|
||||||
totalDistanceSwum = totalDistanceSwum,
|
|
||||||
calories = calories,
|
|
||||||
nextSet = selectedWorkout?.sets?.getOrNull(currentSetIndex)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isWorkoutActive -> {
|
isWorkoutActive -> {
|
||||||
ActiveWorkoutScreen(
|
ActiveWorkoutPager(
|
||||||
workout = selectedWorkout,
|
workout = selectedWorkout,
|
||||||
currentSetIndex = currentSetIndex,
|
currentSetIndex = currentSetIndex,
|
||||||
currentRep = currentRep,
|
currentRep = currentRep,
|
||||||
@@ -55,10 +55,15 @@ fun SwimBuddyApp(
|
|||||||
heartRate = heartRate,
|
heartRate = heartRate,
|
||||||
calories = calories,
|
calories = calories,
|
||||||
lapDetected = lapDetected,
|
lapDetected = lapDetected,
|
||||||
|
isResting = isResting,
|
||||||
|
restTimeRemaining = restTimeRemaining,
|
||||||
totalDistanceSwum = totalDistanceSwum,
|
totalDistanceSwum = totalDistanceSwum,
|
||||||
currentSetDistanceSwum = currentSetDistanceSwum,
|
currentSetDistanceSwum = currentSetDistanceSwum,
|
||||||
currentSetTotalDistance = currentSetTotalDistance,
|
currentSetTotalDistance = currentSetTotalDistance,
|
||||||
|
isPaused = isPaused,
|
||||||
|
isWaterLockActive = isWaterLockActive,
|
||||||
onCompleteLap = onCompleteLap,
|
onCompleteLap = onCompleteLap,
|
||||||
|
onPauseResume = onPauseResume,
|
||||||
onEndWorkout = onEndWorkout
|
onEndWorkout = onEndWorkout
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -80,3 +85,76 @@ fun SwimBuddyApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWearFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun ActiveWorkoutPager(
|
||||||
|
workout: StoredWorkout?,
|
||||||
|
currentSetIndex: Int,
|
||||||
|
currentRep: Int,
|
||||||
|
elapsedSeconds: Float,
|
||||||
|
heartRate: Int,
|
||||||
|
calories: Float,
|
||||||
|
lapDetected: Boolean,
|
||||||
|
isResting: Boolean,
|
||||||
|
restTimeRemaining: Float,
|
||||||
|
totalDistanceSwum: Int,
|
||||||
|
currentSetDistanceSwum: Int,
|
||||||
|
currentSetTotalDistance: Int,
|
||||||
|
isPaused: Boolean,
|
||||||
|
isWaterLockActive: Boolean,
|
||||||
|
onCompleteLap: () -> Unit,
|
||||||
|
onPauseResume: () -> Unit,
|
||||||
|
onEndWorkout: () -> Unit
|
||||||
|
) {
|
||||||
|
val pagerState = rememberPagerState(pageCount = { 3 })
|
||||||
|
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) { page ->
|
||||||
|
when (page) {
|
||||||
|
0 -> {
|
||||||
|
if (isResting) {
|
||||||
|
RestScreen(
|
||||||
|
restTimeRemaining = restTimeRemaining,
|
||||||
|
totalDistanceSwum = totalDistanceSwum,
|
||||||
|
calories = calories,
|
||||||
|
nextSet = workout?.sets?.getOrNull(currentSetIndex)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ActiveWorkoutScreen(
|
||||||
|
workout = workout,
|
||||||
|
currentSetIndex = currentSetIndex,
|
||||||
|
currentRep = currentRep,
|
||||||
|
elapsedSeconds = elapsedSeconds,
|
||||||
|
heartRate = heartRate,
|
||||||
|
calories = calories,
|
||||||
|
lapDetected = lapDetected,
|
||||||
|
totalDistanceSwum = totalDistanceSwum,
|
||||||
|
currentSetDistanceSwum = currentSetDistanceSwum,
|
||||||
|
currentSetTotalDistance = currentSetTotalDistance,
|
||||||
|
onCompleteLap = onCompleteLap,
|
||||||
|
onEndWorkout = onEndWorkout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 -> {
|
||||||
|
WorkoutOverviewScreen(
|
||||||
|
workout = workout,
|
||||||
|
currentSetIndex = currentSetIndex,
|
||||||
|
isResting = isResting,
|
||||||
|
restTimeRemaining = restTimeRemaining
|
||||||
|
)
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
ControlsScreen(
|
||||||
|
isPaused = isPaused,
|
||||||
|
isWaterLockActive = isWaterLockActive,
|
||||||
|
onPauseResume = onPauseResume,
|
||||||
|
onEndWorkout = onEndWorkout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
@@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.wear.compose.material.Button
|
import androidx.wear.compose.material.Button
|
||||||
import androidx.wear.compose.material.ButtonDefaults
|
import androidx.wear.compose.material.ButtonDefaults
|
||||||
import androidx.wear.compose.material.MaterialTheme
|
import androidx.wear.compose.material.MaterialTheme
|
||||||
@@ -55,6 +56,7 @@ fun WorkoutDetailScreen(
|
|||||||
}
|
}
|
||||||
items(workout.sets.size) { index ->
|
items(workout.sets.size) { index ->
|
||||||
val set = workout.sets[index]
|
val set = workout.sets[index]
|
||||||
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
@@ -69,6 +71,24 @@ fun WorkoutDetailScreen(
|
|||||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (set.restType != "none" && set.restDuration > 0) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "\u23F0",
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rest_duration, set.restDuration.toInt()),
|
||||||
|
style = MaterialTheme.typography.caption2,
|
||||||
|
color = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package nl.guidoit.swimbuddy.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.wear.compose.material.ListColumn
|
||||||
|
import androidx.wear.compose.material.MaterialTheme
|
||||||
|
import androidx.wear.compose.material.Text
|
||||||
|
import nl.guidoit.swimbuddy.R
|
||||||
|
import nl.guidoit.swimbuddy.data.StoredWorkout
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WorkoutOverviewScreen(
|
||||||
|
workout: StoredWorkout?,
|
||||||
|
currentSetIndex: Int,
|
||||||
|
isResting: Boolean,
|
||||||
|
restTimeRemaining: Float
|
||||||
|
) {
|
||||||
|
if (workout == null) return
|
||||||
|
|
||||||
|
val restMins = (restTimeRemaining / 60).toInt()
|
||||||
|
val restSecs = (restTimeRemaining % 60).toInt()
|
||||||
|
|
||||||
|
ListColumn(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
if (isResting) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "\u23F8",
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rest) + " " + String.format("%d:%02d", restMins, restSecs),
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
color = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(workout.sets.size) { index ->
|
||||||
|
val set = workout.sets[index]
|
||||||
|
val statusColor = when {
|
||||||
|
index < currentSetIndex -> Color.Green
|
||||||
|
index == currentSetIndex -> Color.Cyan
|
||||||
|
else -> Color.Gray.copy(alpha = 0.4f)
|
||||||
|
}
|
||||||
|
val textColor = if (index <= currentSetIndex) {
|
||||||
|
MaterialTheme.colors.onPrimary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.size(8.dp)) {
|
||||||
|
drawCircle(color = statusColor)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}. ${set.stroke}",
|
||||||
|
style = MaterialTheme.typography.caption,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${set.repCount}x${set.distancePerRep}m",
|
||||||
|
style = MaterialTheme.typography.caption2,
|
||||||
|
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (set.restType != "none" && set.restDuration > 0) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(start = 22.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "\u23F0",
|
||||||
|
fontSize = 10.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rest_duration, set.restDuration.toInt()),
|
||||||
|
style = MaterialTheme.typography.caption2,
|
||||||
|
color = Color(0xFFFF9800)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Omgange</string>
|
<string name="laps_label">Omgange</string>
|
||||||
<string name="tap_to_dismiss">Tryk for at lukke</string>
|
<string name="tap_to_dismiss">Tryk for at lukke</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pause</string>
|
||||||
|
<string name="resume">Genoptag</string>
|
||||||
|
<string name="end_workout">Afslut træning</string>
|
||||||
|
<string name="workout_overview">Oversigt</string>
|
||||||
|
<string name="rest_duration">%ds pause</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Runden</string>
|
<string name="laps_label">Runden</string>
|
||||||
<string name="tap_to_dismiss">Tippen zum Schließen</string>
|
<string name="tap_to_dismiss">Tippen zum Schließen</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pause</string>
|
||||||
|
<string name="resume">Fortsetzen</string>
|
||||||
|
<string name="end_workout">Training beenden</string>
|
||||||
|
<string name="workout_overview">Übersicht</string>
|
||||||
|
<string name="rest_duration">%ds Pause</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Vueltas</string>
|
<string name="laps_label">Vueltas</string>
|
||||||
<string name="tap_to_dismiss">Toca para cerrar</string>
|
<string name="tap_to_dismiss">Toca para cerrar</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pausa</string>
|
||||||
|
<string name="resume">Reanudar</string>
|
||||||
|
<string name="end_workout">Finalizar entrenamiento</string>
|
||||||
|
<string name="workout_overview">Resumen</string>
|
||||||
|
<string name="rest_duration">%ds descanso</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Tours</string>
|
<string name="laps_label">Tours</string>
|
||||||
<string name="tap_to_dismiss">Appuyez pour fermer</string>
|
<string name="tap_to_dismiss">Appuyez pour fermer</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pause</string>
|
||||||
|
<string name="resume">Reprendre</string>
|
||||||
|
<string name="end_workout">Terminer l\'entraînement</string>
|
||||||
|
<string name="workout_overview">Aperçu</string>
|
||||||
|
<string name="rest_duration">%ds repos</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Giri</string>
|
<string name="laps_label">Giri</string>
|
||||||
<string name="tap_to_dismiss">Tocca per chiudere</string>
|
<string name="tap_to_dismiss">Tocca per chiudere</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pausa</string>
|
||||||
|
<string name="resume">Riprendi</string>
|
||||||
|
<string name="end_workout">Termina allenamento</string>
|
||||||
|
<string name="workout_overview">Panoramica</string>
|
||||||
|
<string name="rest_duration">%ds pausa</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Rondes</string>
|
<string name="laps_label">Rondes</string>
|
||||||
<string name="tap_to_dismiss">Tik om te sluiten</string>
|
<string name="tap_to_dismiss">Tik om te sluiten</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pauze</string>
|
||||||
|
<string name="resume">Hervat</string>
|
||||||
|
<string name="end_workout">Training beëindigen</string>
|
||||||
|
<string name="workout_overview">Overzicht</string>
|
||||||
|
<string name="rest_duration">%ds rust</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Okrążenia</string>
|
<string name="laps_label">Okrążenia</string>
|
||||||
<string name="tap_to_dismiss">Stuknij aby zamknąć</string>
|
<string name="tap_to_dismiss">Stuknij aby zamknąć</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pauza</string>
|
||||||
|
<string name="resume">Wznów</string>
|
||||||
|
<string name="end_workout">Zakończ trening</string>
|
||||||
|
<string name="workout_overview">Przegląd</string>
|
||||||
|
<string name="rest_duration">%ds przerwy</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Voltas</string>
|
<string name="laps_label">Voltas</string>
|
||||||
<string name="tap_to_dismiss">Toque para fechar</string>
|
<string name="tap_to_dismiss">Toque para fechar</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pausar</string>
|
||||||
|
<string name="resume">Retomar</string>
|
||||||
|
<string name="end_workout">Terminar treino</string>
|
||||||
|
<string name="workout_overview">Resumo</string>
|
||||||
|
<string name="rest_duration">%ds descanso</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -27,4 +27,10 @@
|
|||||||
<string name="laps_label">Laps</string>
|
<string name="laps_label">Laps</string>
|
||||||
<string name="tap_to_dismiss">Tap to dismiss</string>
|
<string name="tap_to_dismiss">Tap to dismiss</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="water_lock">Water Lock</string>
|
||||||
|
<string name="pause">Pause</string>
|
||||||
|
<string name="resume">Resume</string>
|
||||||
|
<string name="end_workout">End Workout</string>
|
||||||
|
<string name="workout_overview">Overview</string>
|
||||||
|
<string name="rest_duration">%ds rest</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user