Compare commits

..
35 Commits
Author SHA1 Message Date
JustZoe101 f07667e1f7 fix: correct path aliases and type imports across codebase 2026-08-24 14:58:27 +02:00
JustZoe101 0f167108fc chore(deps): update expo and expo-related dependencies 2026-08-24 14:49:15 +02:00
JustZoe101 630416ee3c feat(wearos): add release signing via EAS credentials.json 2026-08-24 10:50:12 +02:00
JustZoe101 a94ebf3653 feat(wearos): add pause/resume workout, water lock, and swipeable workout pages 2026-08-24 10:24:24 +02:00
JustZoe101 31b2a115ec fix(localization): change Danish locale code from dk to da and add HealthKit usage descriptions 2026-08-18 14:23:23 +02:00
JustZoe101 1fcacb2eb6 chore(eas): add build channel definitions per profile 2026-08-18 12:45:49 +02:00
JustZoe101 6394954afe fix(metro): simplify Sentry config using getSentryExpoConfig 2026-08-18 12:42:28 +02:00
JustZoe101 682d0c7eea chore(deps): downgrade sentry, update expo, add expo-font and worklets 2026-08-18 11:54:47 +02:00
JustZoe101 9ded984543 fix(scripts): update watch target regex to match SwimBuddyWatch without space 2026-08-18 11:46:19 +02:00
JustZoe101 5e797c32e7 fix(watch): remove space from watch target name 2026-08-18 11:43:18 +02:00
JustZoe101 7e3b95be8e fix: correct watch target name and Sentry script section ordering 2026-08-18 11:32:37 +02:00
JustZoe101 122aa443aa chore(app): remove space from watch target name 2026-08-18 11:05:11 +02:00
JustZoe101 dffee4a5f4 build(app): add EAS build config for Apple Watch extension 2026-08-18 11:02:23 +02:00
JustZoe101 078cb84031 chore(app): remove EAS build experimental iOS app extensions config 2026-08-18 10:51:45 +02:00
JustZoe101 92dcc08d59 fix(watch): rename watch target to SwimBuddy Watch for Xcode compatibility 2026-08-18 10:34:00 +02:00
JustZoe101 cad7f80607 chore(build): add eas-build-pre-install script to upgrade npm 2026-08-18 10:18:20 +02:00
JustZoe101 8c8bb9125b chore(eas): add environment fields to build profiles 2026-08-18 09:52:08 +02:00
JustZoe101 43e2fac10f chore(app): add iOS InfoPlist config and watch HealthKit entitlements 2026-08-18 09:47:03 +02:00
JustZoe101 ca9259d5bd feat(watch): add workout deletion sync and optimize delete operations 2026-08-12 13:52:23 +02:00
JustZoe101 2096489fd1 fix(history,workout-builder): memoize async load functions with useCallback 2026-08-12 12:59:27 +02:00
JustZoe101 2bb4225260 refactor(db): separate workout plans from completed sessions 2026-08-12 11:35:16 +02:00
JustZoe101 f14c2e5827 fix(app): auto-hide splash screen on mount 2026-08-12 10:11:06 +02:00
JustZoe101 b648878414 fix(watch): advance rep based on distance swum instead of lap count 2026-08-12 09:19:54 +02:00
JustZoe101 debfcf3843 feat: add expo-splash-screen configuration 2026-08-11 17:00:41 +02:00
JustZoe101 522337d332 fix(workout-builder): use navigation namespace for screen title translation 2026-08-11 16:49:35 +02:00
JustZoe101 070429627b fix(workout-builder): defer TextInput updates to onBlur using local state 2026-08-11 16:48:01 +02:00
JustZoe101 7f2fe0c300 feat(wearos): track swimming stroke count during workouts 2026-08-11 16:35:02 +02:00
JustZoe101 42e2f5fd47 feat(watch): use HealthKit lap detection and track stroke count/swolf 2026-08-11 16:03:08 +02:00
JustZoe101 098db881bb feat(watch): add rest duration display in workout views 2026-08-09 18:42:21 +02:00
JustZoe101 a17ae0dea5 feat(watch): display water lock status indicator during workout 2026-08-09 18:33:23 +02:00
JustZoe101 383f641ac2 feat(watch): improve lap detection with CMDeviceMotion and low-pass filtering 2026-08-09 18:24:55 +02:00
JustZoe101 4a07556a5d refactor(navigation): wrap settings screen in stack navigator 2026-08-09 18:24:39 +02:00
JustZoe101 8f96a12b18 refactor(watch): remove .listRowBackground(.clear) modifiers 2026-08-09 18:19:38 +02:00
JustZoe101 1387d6bbdb feat(watch): add localizations for workout UI strings 2026-08-09 18:03:36 +02:00
JustZoe101 8512bee27a fix(watch): remove default list row backgrounds for cleaner UI 2026-08-09 17:57:58 +02:00
63 changed files with 3215 additions and 1117 deletions
+1 -5
View File
@@ -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
+8
View File
@@ -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>
+50 -9
View File
@@ -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"
+35
View File
@@ -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`;
+36
View File
@@ -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;
+397
View File
@@ -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": {}
}
}
+390
View File
@@ -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": {}
}
}
+14
View File
@@ -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
View File
@@ -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
} }
} }
+32 -26
View File
@@ -1,28 +1,34 @@
{ {
"cli": { "cli": {
"version": ">= 20.0.0", "version": ">= 20.0.0",
"appVersionSource": "remote" "appVersionSource": "remote"
}, },
"build": { "build": {
"development": { "development": {
"developmentClient": true, "developmentClient": true,
"distribution": "internal" "distribution": "internal",
}, "environment": "development",
"preview": { "channel": "development"
"distribution": "internal", },
"autoIncrement": true "preview": {
}, "distribution": "store",
"production": { "autoIncrement": true,
"autoIncrement": true "environment": "preview",
} "channel": "preview"
}, },
"submit": { "production": {
"preview": { "autoIncrement": true,
"android": { "environment": "production",
"serviceAccountKeyPath": "./google-services.json", "channel": "production"
"track": "internal" }
} },
}, "submit": {
"production": {} "preview": {
} "android": {
"serviceAccountKeyPath": "./google-services.json",
"track": "internal"
}
},
"production": {}
}
} }
Vendored
+1
View File
@@ -0,0 +1 @@
declare module "*.css" {}
+4 -5
View File
@@ -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,
})); });
+25
View File
@@ -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]
} }
+648 -606
View File
File diff suppressed because it is too large Load Diff
+64 -59
View File
@@ -1,61 +1,66 @@
{ {
"name": "swimbuddy", "name": "swimbuddy",
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^5.0.0", "@bacons/apple-targets": "^5.0.0",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@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-localization": "~57.0.1", "expo-font": "~57.0.1",
"expo-sqlite": "~57.0.1", "expo-localization": "~57.0.1",
"expo-status-bar": "~57.0.1", "expo-splash-screen": "~57.0.8",
"expo-system-ui": "~57.0.2", "expo-sqlite": "~57.0.1",
"expo-updates": "^57.0.12", "expo-status-bar": "~57.0.1",
"i18next": "^26.3.6", "expo-system-ui": "~57.0.2",
"nativewind": "^5.0.0-preview.4", "expo-updates": "~57.0.17",
"react": "19.2.3", "i18next": "^26.3.6",
"react-i18next": "^17.0.11", "nativewind": "^5.0.0-preview.4",
"react-native": "0.86.2", "react": "19.2.3",
"react-native-css": "^3.0.7", "react-i18next": "^17.0.11",
"react-native-gesture-handler": "~2.32.0", "react-native": "0.86.2",
"react-native-mmkv": "^4.3.2", "react-native-css": "^3.0.7",
"react-native-reanimated": "4.5.1", "react-native-gesture-handler": "~2.32.0",
"react-native-safe-area-context": "~5.7.0", "react-native-mmkv": "^4.3.2",
"react-native-screens": "~4.26.0" "react-native-reanimated": "4.5.1",
}, "react-native-safe-area-context": "~5.7.0",
"devDependencies": { "react-native-screens": "~4.26.0",
"@types/react": "~19.2.2", "react-native-worklets": "0.10.1"
"babel-plugin-inline-import": "^3.0.0", },
"drizzle-kit": "^0.31.10", "devDependencies": {
"tailwindcss": "^4", "@types/react": "~19.2.2",
"typescript": "~6.0.3" "babel-plugin-inline-import": "^3.0.0",
}, "drizzle-kit": "^0.31.10",
"scripts": { "tailwindcss": "^4",
"start": "expo start", "typescript": "~6.0.3"
"android": "expo run:android", },
"ios": "expo run:ios", "scripts": {
"web": "expo start --web", "start": "expo start",
"db:generate": "drizzle-kit generate", "android": "expo run:android",
"prebuild": "expo prebuild && npm run eas-build-post-install", "ios": "expo run:ios",
"prebuild:clean": "expo prebuild --clean && npm run eas-build-post-install", "web": "expo start --web",
"eas-build-post-install": "node scripts/add-sentry-watch.js", "clean": "rm -rf .expo ios android node_modules && npm i",
"build:wearos:debug": "./scripts/build-wearos.sh debug", "db:generate": "drizzle-kit generate",
"build:wearos:release": "./scripts/build-wearos.sh release", "prebuild": "expo prebuild && npm run eas-build-post-install",
"deploy:ios": "eas build --platform ios --profile ${1:-preview} && eas submit --platform ios --profile ${1:-preview}", "prebuild:clean": "expo prebuild --clean && npm run eas-build-post-install",
"deploy:android": "eas build --platform android --profile ${1:-preview} && eas submit --platform android --profile ${1:-preview}", "eas-build-pre-install": "npm install -g npm@11",
"deploy:wearos": "./scripts/build-wearos.sh release ${2:-}", "eas-build-post-install": "node scripts/add-sentry-watch.js",
"update": "eas update --branch ${1:-preview}", "build:wearos:debug": "./scripts/build-wearos.sh debug",
"update:production": "eas update --branch production" "build:wearos:release": "./scripts/build-wearos.sh release",
}, "deploy:ios": "eas build --platform ios --profile ${1:-preview} && eas submit --platform ios --profile ${1:-preview}",
"private": true, "deploy:android": "eas build --platform android --profile ${1:-preview} && eas submit --platform android --profile ${1:-preview}",
"overrides": { "deploy:wearos": "./scripts/build-wearos.sh release ${2:-}",
"lightningcss": "1.30.1" "update": "eas update --branch ${1:-preview}",
} "update:production": "eas update --branch production"
},
"private": true,
"overrides": {
"lightningcss": "1.30.1"
}
} }
+5 -5
View File
@@ -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")) {
+15
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+23 -23
View File
@@ -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 -1
View File
@@ -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>
+1
View File
@@ -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 () => {},
+3 -2
View File
@@ -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} />
+26
View File
@@ -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>
);
}
+5 -1
View File
@@ -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 -1
View File
@@ -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);
+2 -2
View File
@@ -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 ?? "")
+368 -26
View File
@@ -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!"
+36 -12
View File
@@ -165,18 +165,31 @@ 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
HStack { VStack(alignment: .leading, spacing: 2) {
Circle() HStack {
.fill(i < workoutManager.currentSetIndex ? .green : Circle()
i == workoutManager.currentSetIndex ? .cyan : .gray.opacity(0.4)) .fill(i < workoutManager.currentSetIndex ? .green :
.frame(width: 8, height: 8) i == workoutManager.currentSetIndex ? .cyan : .gray.opacity(0.4))
Text("\(i + 1). \(set.stroke)") .frame(width: 8, height: 8)
.font(.caption) Text("\(i + 1). \(set.stroke)")
.foregroundColor(i <= workoutManager.currentSetIndex ? .primary : .secondary) .font(.caption)
Spacer() .foregroundColor(i <= workoutManager.currentSetIndex ? .primary : .secondary)
Text("\(set.repCount)x\(set.distancePerRep)m") Spacer()
.font(.caption2) Text("\(set.repCount)x\(set.distancePerRep)m")
.foregroundColor(.secondary) .font(.caption2)
.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 {
+23 -9
View File
@@ -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,13 +26,26 @@ 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
HStack { VStack(alignment: .leading, spacing: 2) {
Text("\(i + 1). \(set.stroke)") HStack {
.font(.body) Text("\(i + 1). \(set.stroke)")
Spacer() .font(.body)
Text("\(set.repCount)x\(set.distancePerRep)m") Spacer()
.font(.caption) Text("\(set.repCount)x\(set.distancePerRep)m")
.foregroundColor(.secondary) .font(.caption)
.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)
}
}
} }
} }
} }
+100 -93
View File
@@ -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 -1
View File
@@ -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",
+20 -1
View File
@@ -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) {
_elapsedSeconds.value = (System.currentTimeMillis() - startTime) / 1000f if (!_isPaused.value) {
_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(
@@ -151,14 +151,17 @@ fun ActiveWorkoutScreen(
) )
} }
Text( Button(
text = stringResource(R.string.end), onClick = onEndWorkout,
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() colors = ButtonDefaults.buttonColors(
.padding(top = 4.dp), backgroundColor = Color.Red
color = Color.Red, )
style = MaterialTheme.typography.caption3, ) {
textAlign = androidx.compose.ui.text.style.TextAlign.Center Text(
) text = stringResource(R.string.end_workout),
color = Color.White
)
}
} }
} }
@@ -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
)
}
}
}
}
@@ -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,19 +56,38 @@ fun WorkoutDetailScreen(
} }
items(workout.sets.size) { index -> items(workout.sets.size) { index ->
val set = workout.sets[index] val set = workout.sets[index]
Row( Column {
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.SpaceBetween modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.SpaceBetween
Text( ) {
text = "${index + 1}. ${set.stroke}", Text(
style = MaterialTheme.typography.body1 text = "${index + 1}. ${set.stroke}",
) style = MaterialTheme.typography.body1
Text( )
text = "${set.repCount}x${set.distancePerRep}m", Text(
style = MaterialTheme.typography.caption3, text = "${set.repCount}x${set.distancePerRep}m",
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f) style = MaterialTheme.typography.caption3,
) 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 {
@@ -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>