Compare commits

..
24 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
56 changed files with 12258 additions and 10630 deletions
+1 -5
View File
@@ -73,7 +73,7 @@ targets/wearos/ # WearOS app (Kotlin/Compose) — survives prebuil
## Database
- **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`
- CRUD: `src/db/operations.ts`
- 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)
- 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
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
+8
View File
@@ -1,5 +1,7 @@
import "./global.css";
import "./src/i18n";
import { useEffect } from "react";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { NavigationContainer } from "@react-navigation/native";
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 * as Sentry from "@sentry/react-native";
SplashScreen.preventAutoHideAsync();
Sentry.init({
dsn: "https://6197bcf085d63727d829aa89ede8e973@o4507430203293696.ingest.de.sentry.io/4511875297706064",
tracesSampleRate: 1.0,
@@ -27,6 +31,10 @@ function SystemLanguageListener() {
}
function App() {
useEffect(() => {
SplashScreen.hideAsync();
}, []);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<DatabaseProvider>
+50 -9
View File
@@ -21,7 +21,23 @@
],
"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": {
"adaptiveIcon": {
@@ -40,33 +56,58 @@
"plugins": [
"expo-sqlite",
"@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",
"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": {
"eas": {
"projectId": "7b88af8c-86a3-4c08-b186-09a90e2bdfcc",
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"bundleIdentifier": "nl.guido-it.swimbuddy.watch",
"targetName": "SwimBuddy",
"targetName": "SwimBuddyWatch",
"entitlements": {
"com.apple.security.application-groups": [
"group.nl.guido-it.swimbuddy"
]
],
"com.apple.developer.healthkit": true,
"com.apple.developer.healthkit.access": []
}
}
]
}
}
},
"projectId": "7b88af8c-86a3-4c08-b186-09a90e2bdfcc"
}
}
},
"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,
"tag": "0000_mushy_groot",
"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 {
journal,
migrations: {
m0000
m0000,
m0001,
m0002
}
}
+10 -4
View File
@@ -6,14 +6,20 @@
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
"distribution": "internal",
"environment": "development",
"channel": "development"
},
"preview": {
"distribution": "internal",
"autoIncrement": true
"distribution": "store",
"autoIncrement": true,
"environment": "preview",
"channel": "preview"
},
"production": {
"autoIncrement": true
"autoIncrement": true,
"environment": "production",
"channel": "production"
}
},
"submit": {
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 { withSentryConfig } = require("@sentry/react-native/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const config = getSentryExpoConfig(__dirname);
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
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
return self.session?.receivedApplicationContext as? [String: Any]
}
+648 -606
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -8,16 +8,18 @@
"@react-navigation/bottom-tabs": "^7.18.14",
"@react-navigation/native": "^7.3.14",
"@react-navigation/native-stack": "^7.18.6",
"@sentry/react-native": "^8.22.0",
"@sentry/react-native": "~7.11.0",
"@tailwindcss/postcss": "^4.3.3",
"drizzle-orm": "^0.45.2",
"expo": "~57.0.10",
"expo": "~57.0.16",
"expo-application": "~57.0.2",
"expo-font": "~57.0.1",
"expo-localization": "~57.0.1",
"expo-splash-screen": "~57.0.8",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "^57.0.12",
"expo-updates": "~57.0.17",
"i18next": "^26.3.6",
"nativewind": "^5.0.0-preview.4",
"react": "19.2.3",
@@ -28,7 +30,8 @@
"react-native-mmkv": "^4.3.2",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
"react-native-screens": "~4.26.0",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
"@types/react": "~19.2.2",
@@ -42,9 +45,11 @@
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"clean": "rm -rf .expo ios android node_modules && npm i",
"db:generate": "drizzle-kit generate",
"prebuild": "expo prebuild && npm run eas-build-post-install",
"prebuild:clean": "expo prebuild --clean && npm run eas-build-post-install",
"eas-build-pre-install": "npm install -g npm@11",
"eas-build-post-install": "node scripts/add-sentry-watch.js",
"build:wearos:debug": "./scripts/build-wearos.sh debug",
"build:wearos:release": "./scripts/build-wearos.sh release",
+5 -5
View File
@@ -43,7 +43,7 @@ if (content.includes(SENTRY_REPO_URL)) {
// ── Find watch target UUID ──
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) {
console.error("Watch target not found");
@@ -55,7 +55,7 @@ console.log(`Watch target: ${watchTargetUuid}`);
// ── Find watch target's Frameworks build phase UUID ──
const watchTargetBlock = content.match(
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) {
@@ -88,7 +88,7 @@ const pkgRefLine = `\t\t${pkgRefId} /* XCRemoteSwiftPackageReference "sentry-coc
if (!content.includes("/* Begin XCRemoteSwiftPackageReference section */")) {
content = content.replace(
/\/\* 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 {
content = content.replace(
@@ -103,7 +103,7 @@ const prodDepLine = `\t\t${prodDepId} /* ${SENTRY_PRODUCT} */ = {isa = XCSwiftPa
if (!content.includes("/* Begin XCSwiftPackageProductDependency section */")) {
content = content.replace(
/\/\* 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 {
content = content.replace(
@@ -143,7 +143,7 @@ if (!content.includes("packageReferences = (")) {
const prodDepComment = `${prodDepId} /* ${SENTRY_PRODUCT} */`;
// Find the watch target block and check if packageProductDependencies exists
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);
if (wtMatch && !content.includes("packageProductDependencies")) {
+15
View File
@@ -30,6 +30,21 @@ cd "$WEAR_DIR"
echo "Building WearOS ($PROFILE) v$VERSION ($VERSION_CODE)..."
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"
APK_PATH="app/build/outputs/apk/release/app-release.apk"
else
+3 -3
View File
@@ -1,6 +1,6 @@
import { useRef } from "react";
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 = {
children: React.ReactNode;
@@ -20,8 +20,8 @@ export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: Swi
translateX.setValue(clamped);
};
const onHandlerStateChange = (event: PanGestureHandlerGestureEvent) => {
if (event.oldState === State.ACTIVE) {
const onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => {
if (event.nativeEvent.oldState === State.ACTIVE) {
const { translationX } = event.nativeEvent;
const final = Math.max(MAX_LEFT, Math.min(0, offset.current + translationX));
+152 -77
View File
@@ -1,8 +1,9 @@
import { db } from "./client";
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 type { WorkoutSet, LapData } from "@types/workout";
import type { WorkoutSet, LapData } from "@/types/workout";
import WatchConnectivity from "../modules/watch-connectivity";
// ─── Templates ───────────────────────────────────────────────
@@ -30,7 +31,7 @@ export async function toggleTemplateFavorite(id: string, isFavorite: boolean) {
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
}
// ─── Workouts ────────────────────────────────────────────────
// ─── Workout Plans ───────────────────────────────────────────
export async function saveWorkout(
name: string | null,
@@ -39,16 +40,13 @@ export async function saveWorkout(
templateId?: string
) {
const workoutId = generateUUID();
const totalDistance = setsData.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
await db.insert(workouts).values({
id: workoutId,
name,
templateId: templateId ?? null,
poolLength,
startedAt: new Date().toISOString(),
totalDistance,
source: "manual",
createdAt: new Date().toISOString(),
});
for (let i = 0; i < setsData.length; i++) {
@@ -69,74 +67,50 @@ export async function saveWorkout(
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) {
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
if (!workout) return null;
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, laps: workoutLaps };
return { ...workout, sets: workoutSets };
}
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) {
await db.delete(laps).where(eq(laps.workoutId, id));
await db.delete(sets).where(eq(sets.workoutId, id));
await db.delete(workouts).where(eq(workouts.id, id));
}
// ─── Workout Sessions ────────────────────────────────────────
// ─── Laps ────────────────────────────────────────────────────
export async function finishWorkout(
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();
export async function saveLap(workoutId: string, lap: LapData) {
const swolf =
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: generateUUID(),
await db.insert(workoutSessions).values({
id: sessionId,
workoutId,
setPosition: lap.setPosition,
repNumber: lap.repNumber,
distance: lap.distance,
duration: lap.duration,
strokeCount: lap.strokeCount ?? null,
heartRate: lap.heartRate ?? null,
swolf,
name: workout?.name ?? null,
poolLength: workout?.poolLength ?? 25,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
totalDistance,
totalDuration: duration,
calories,
avgHeartRate,
source: "phone",
});
}
export async function saveLaps(workoutId: string, lapsData: LapData[]) {
for (const lap of lapsData) {
await saveLap(workoutId, lap);
}
}
export async function getWorkoutLaps(workoutId: string) {
return db.select().from(laps).where(eq(laps.workoutId, workoutId));
return sessionId;
}
export async function saveWatchCompletedWorkout(data: {
workoutId: string;
sessionId?: string;
duration: number;
totalDistance: number;
calories: number;
@@ -154,36 +128,48 @@ export async function saveWatchCompletedWorkout(data: {
? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length)
: 0;
const existing = await db
.select({ id: workouts.id })
// 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 (existing.length === 0) {
await db.insert(workouts).values({
id: data.workoutId,
name: "Watch Workout",
poolLength: 25,
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(),
totalDuration: data.duration,
totalDistance: data.totalDistance || 0,
totalDuration: data.duration,
calories: data.calories || 0,
avgHeartRate,
source: "watch",
});
} else {
await db
.update(workouts)
.update(workoutSessions)
.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));
.where(eq(workoutSessions.id, sessionId));
}
for (const lap of data.laps) {
@@ -192,7 +178,7 @@ export async function saveWatchCompletedWorkout(data: {
await db.insert(laps).values({
id: generateUUID(),
workoutId: data.workoutId,
sessionId,
setPosition: lap.setPosition,
repNumber: lap.repNumber,
distance: lap.distance,
@@ -204,6 +190,95 @@ export async function saveWatchCompletedWorkout(data: {
}
}
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 ────────────────────────────────────────────────────
export async function saveLap(sessionId: string, lap: LapData) {
const swolf =
lap.strokeCount != null ? lap.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,
strokeCount: lap.strokeCount ?? null,
heartRate: lap.heartRate ?? null,
swolf,
});
}
export async function saveLaps(sessionId: string, lapsData: LapData[]) {
for (const lap of lapsData) {
await saveLap(sessionId, lap);
}
}
export async function getSessionLaps(sessionId: string) {
return db.select().from(laps).where(eq(laps.sessionId, sessionId));
}
// ─── Delete ──────────────────────────────────────────────────
export async function deleteSession(sessionId: string) {
await db.delete(laps).where(eq(laps.sessionId, sessionId));
await db.delete(workoutSessions).where(eq(workoutSessions.id, sessionId));
}
export async function deleteSessionAndSync(sessionId: string) {
await deleteSession(sessionId);
WatchConnectivity.sendWorkoutDeleted(sessionId).catch((err: Error) =>
console.warn("[ops] sync failed:", err)
);
}
export async function deleteWorkout(id: string) {
// Verwijder alle sessies die aan dit plan gelinkt zijn
const sessions = await db
.select({ id: workoutSessions.id })
.from(workoutSessions)
.where(eq(workoutSessions.workoutId, id));
for (const session of sessions) {
await db.delete(laps).where(eq(laps.sessionId, session.id));
}
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));
}
export async function deleteWorkoutAndSync(id: string) {
await deleteWorkout(id);
WatchConnectivity.sendWorkoutDeleted(id).catch((err: Error) =>
console.warn("[ops] sync failed:", err)
);
}
// ─── Stats ───────────────────────────────────────────────────
export async function getWeeklyStats() {
@@ -213,12 +288,12 @@ export async function getWeeklyStats() {
return db
.select({
count: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
avgCalories: sql<number>`coalesce(avg(${workouts.calories}), 0)`,
totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
avgCalories: sql<number>`coalesce(avg(${workoutSessions.calories}), 0)`,
})
.from(workouts)
.where(sql`${workouts.startedAt} >= ${weekAgo.toISOString()}`)
.from(workoutSessions)
.where(sql`${workoutSessions.startedAt} >= ${weekAgo.toISOString()}`)
.get();
}
@@ -238,10 +313,10 @@ export async function getTotalStats() {
return db
.select({
totalWorkouts: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
})
.from(workouts)
.where(sql`${workouts.finishedAt} IS NOT NULL`)
.from(workoutSessions)
.where(sql`${workoutSessions.finishedAt} IS NOT NULL`)
.get();
}
+12 -4
View File
@@ -1,5 +1,5 @@
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 }) => ({
workouts: many(workouts),
@@ -11,6 +11,14 @@ export const workoutsRelations = relations(workouts, ({ one, many }) => ({
references: [templates.id],
}),
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),
}));
@@ -22,8 +30,8 @@ export const setsRelations = relations(sets, ({ one }) => ({
}));
export const lapsRelations = relations(laps, ({ one }) => ({
workout: one(workouts, {
fields: [laps.workoutId],
references: [workouts.id],
session: one(workoutSessions, {
fields: [laps.sessionId],
references: [workoutSessions.id],
}),
}));
+10 -2
View File
@@ -15,6 +15,14 @@ export const workouts = sqliteTable("workouts", {
name: text("name"),
templateId: text("template_id"),
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(),
finishedAt: text("finished_at"),
totalDistance: integer("total_distance"),
@@ -40,9 +48,9 @@ export const sets = sqliteTable("sets", {
export const laps = sqliteTable("laps", {
id: text("id").primaryKey(),
workoutId: text("workout_id")
sessionId: text("session_id")
.notNull()
.references(() => workouts.id),
.references(() => workoutSessions.id),
setPosition: integer("set_position").notNull(),
repNumber: integer("rep_number").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 { RouteProp } from "@react-navigation/native";
import type { RootStackParamList } from "@navigation/types";
import { getWorkoutWithSets, getWorkoutLaps } from "@db/operations";
import { getSessionWithDetails } from "@db/operations";
import { WorkoutSummary } from "../components/WorkoutSummary";
type Set = {
@@ -38,6 +38,8 @@ export function PostSwimScreen() {
sets: Set[];
laps: Lap[];
avgSwolf: number | null;
totalLengths: number | null;
avgPace: number | null;
} | null>(null);
useEffect(() => {
@@ -45,29 +47,27 @@ export function PostSwimScreen() {
}, []);
const loadSummary = async () => {
const workout = await getWorkoutWithSets(route.params.workoutId);
const laps = await getWorkoutLaps(route.params.workoutId);
const session = await getSessionWithDetails(route.params.sessionId);
if (!session) return;
if (!workout) return;
const totalDuration = workout.laps?.reduce((acc: number, l: any) => acc + l.duration, 0) ?? 0;
const totalDuration = session.totalDuration ?? 0;
const avgSwolf =
laps.length > 0
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
session.laps.length > 0
? Math.round(session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length)
: null;
const totalLengths =
workout.totalDistance && workout.poolLength
? Math.round(workout.totalDistance / workout.poolLength)
session.totalDistance && session.poolLength
? Math.round(session.totalDistance / session.poolLength)
: null;
const avgPace =
workout.totalDistance && totalDuration
? workout.totalDistance / totalDuration
session.totalDistance && totalDuration
? session.totalDistance / totalDuration
: null;
const setsWithPace = (workout.sets ?? []).map((set: any) => {
const setLaps = laps.filter((l: any) => l.setPosition === set.position);
const setsWithPace = (session.sets ?? []).map((set: any) => {
const setLaps = session.laps.filter((l: any) => l.setPosition === set.position);
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 poolPace = setDistance > 0 ? setDuration / setDistance : null;
@@ -75,14 +75,14 @@ export function PostSwimScreen() {
});
setData({
name: workout.name,
poolLength: workout.poolLength,
totalDistance: workout.totalDistance ?? 0,
name: session.name,
poolLength: session.poolLength,
totalDistance: session.totalDistance ?? 0,
totalDuration,
calories: workout.calories,
avgHeartRate: workout.avgHeartRate,
calories: session.calories,
avgHeartRate: session.avgHeartRate,
sets: setsWithPace,
laps,
laps: session.laps,
avgSwolf,
totalLengths,
avgPace,
@@ -10,7 +10,7 @@ import { useSyncWorkout } from "../../watch-connectivity/hooks/useSyncWorkout";
import { useWatchConnection } from "../../watch-connectivity/hooks/useWatchConnection";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
import type { WorkoutSet } from "@types/workout";
import type { WorkoutSet } from "@/types/workout";
type WorkoutWithSets = {
id: string;
+23 -23
View File
@@ -5,12 +5,12 @@ import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { HistoryStackParamList } from "@navigation/types";
import { getAllWorkouts, deleteWorkout } from "@db/operations";
import { getAllSessions, deleteSessionAndSync } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = {
type SessionRow = {
id: string;
name: string | null;
totalDistance: number | null;
@@ -24,23 +24,23 @@ export function HistoryScreen() {
const { t } = useTranslation("history");
const { t: tc } = useTranslation("common");
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
const [sessions, setSessions] = useState<SessionRow[]>([]);
const [refreshing, setRefreshing] = useState(false);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
};
const loadSessions = useCallback(async () => {
const data = await getAllSessions();
setSessions(data as SessionRow[]);
}, []);
useFocusEffect(
useCallback(() => {
loadWorkouts();
}, [])
loadSessions();
}, [loadSessions])
);
const onRefresh = async () => {
setRefreshing(true);
await loadWorkouts();
await loadSessions();
setRefreshing(false);
};
@@ -51,17 +51,17 @@ export function HistoryScreen() {
text: tc("delete"),
style: "destructive",
onPress: async () => {
await deleteWorkout(id);
await loadWorkouts();
await deleteSessionAndSync(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
},
},
]);
};
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
const date = new Date(w.startedAt).toLocaleDateString();
const grouped = sessions.reduce<Record<string, SessionRow[]>>((acc, s) => {
const date = new Date(s.startedAt).toLocaleDateString();
if (!acc[date]) acc[date] = [];
acc[date].push(w);
acc[date].push(s);
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">
{date}
</Text>
{rows.map((w) => (
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)} deleteLabel={tc("delete")}>
{rows.map((s) => (
<SwipeableRow key={s.id} onDelete={() => onDelete(s.id)} deleteLabel={tc("delete")}>
<Pressable
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
onPress={() => navigation.navigate("SessionDetail", { sessionId: s.id })}
className="p-4"
>
<View className="flex-row justify-between items-center">
<View>
<Text className="font-semibold text-slate-900">
{w.name || tc("swimSession")}
{s.name || tc("swimSession")}
</Text>
<Text className="text-slate-500 text-sm">
{formatDistance(w.totalDistance ?? 0)}
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
{w.calories ? ` · ${tc("kcal", { count: Math.round(w.calories) })}` : ""}
{formatDistance(s.totalDistance ?? 0)}
{s.totalDuration ? ` · ${formatTime(s.totalDuration)}` : ""}
{s.calories ? ` · ${tc("kcal", { count: Math.round(s.calories) })}` : ""}
</Text>
</View>
<Text className="text-slate-400 text-xs">
{new Date(w.startedAt).toLocaleTimeString([], {
{new Date(s.startedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
@@ -3,10 +3,10 @@ import { View, Text, Pressable, Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
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";
type WorkoutDetail = {
type SessionDetail = {
id: string;
name: string | null;
poolLength: number;
@@ -16,6 +16,7 @@ type WorkoutDetail = {
avgHeartRate: number | null;
startedAt: string;
finishedAt: string | null;
source: string;
sets: {
stroke: string;
distancePerRep: number;
@@ -35,15 +36,15 @@ type WorkoutDetail = {
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
const { t: tc } = useTranslation("common");
const navigation = useNavigation();
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
const [session, setSession] = useState<SessionDetail | null>(null);
useEffect(() => {
loadWorkout();
loadSession();
}, []);
const loadWorkout = async () => {
const data = await getWorkoutWithSets(route.params.sessionId);
setWorkout(data as WorkoutDetail | null);
const loadSession = async () => {
const data = await getSessionWithDetails(route.params.sessionId);
setSession(data as SessionDetail | null);
};
const handleDelete = () => {
@@ -53,7 +54,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
text: tc("delete"),
style: "destructive",
onPress: async () => {
await deleteWorkout(route.params.sessionId);
await deleteSessionAndSync(route.params.sessionId);
navigation.goBack();
},
},
@@ -61,7 +62,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
};
useEffect(() => {
if (workout) {
if (session) {
navigation.setOptions({
headerRight: () => (
<Pressable onPress={handleDelete} hitSlop={8}>
@@ -70,9 +71,9 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
),
});
}
}, [workout]);
}, [session]);
if (!workout) {
if (!session) {
return (
<View className="flex-1 bg-white items-center justify-center">
<Text className="text-slate-400">{tc("loading")}</Text>
@@ -81,24 +82,24 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
}
const avgSwolf =
workout.laps.length > 0
session.laps.length > 0
? 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;
const totalLengths =
workout.totalDistance && workout.poolLength
? Math.round(workout.totalDistance / workout.poolLength)
session.totalDistance && session.poolLength
? Math.round(session.totalDistance / session.poolLength)
: null;
const avgPace =
workout.totalDistance && workout.totalDuration
? workout.totalDistance / workout.totalDuration
session.totalDistance && session.totalDuration
? session.totalDistance / session.totalDuration
: null;
const setsWithPace = workout.sets.map((set) => {
const setLaps = workout.laps.filter((l) => l.setPosition === set.position);
const setsWithPace = session.sets.map((set) => {
const setLaps = session.laps.filter((l) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
@@ -107,17 +108,17 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
return (
<WorkoutSummary
name={workout.name}
date={new Date(workout.startedAt).toLocaleDateString()}
totalDistance={workout.totalDistance ?? 0}
totalDuration={workout.totalDuration ?? 0}
name={session.name}
date={new Date(session.startedAt).toLocaleDateString()}
totalDistance={session.totalDistance ?? 0}
totalDuration={session.totalDuration ?? 0}
totalLengths={totalLengths}
calories={workout.calories}
avgHeartRate={workout.avgHeartRate}
calories={session.calories}
avgHeartRate={session.avgHeartRate}
avgPace={avgPace}
poolLength={workout.poolLength}
poolLength={session.poolLength}
sets={setsWithPace}
laps={workout.laps}
laps={session.laps}
avgSwolf={avgSwolf}
/>
);
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from "react";
import { getTemplates, deleteTemplate, toggleTemplateFavorite } from "@db/operations";
import type { WorkoutSet } from "@types/workout";
import type { WorkoutSet } from "@/types/workout";
type Template = {
id: string;
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-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 };
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity";
import { saveWatchCompletedWorkout } from "@db/operations";
import { saveWatchCompletedWorkout, deleteSession } from "@db/operations";
export function useWatchWorkoutSync() {
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -22,11 +22,14 @@ export function useWatchWorkoutSync() {
if (event.type === "workoutCompleted") {
await saveWatchCompletedWorkout({
workoutId: event.workoutId,
sessionId: event.sessionId,
duration: event.duration ?? 0,
totalDistance: event.totalDistance ?? 0,
calories: event.calories ?? 0,
laps: event.laps ?? [],
});
} else if (event.type === "workoutDeleted") {
await deleteSession(event.workoutId);
}
}
} catch (error) {
@@ -1,5 +1,5 @@
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 i18n from "@i18n/index";
@@ -6,7 +6,7 @@ import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "@navigation/types";
import { useWorkoutBuilder } from "../hooks/useWorkoutBuilder";
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"];
@@ -5,18 +5,16 @@ import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { WorkoutStackParamList } from "@navigation/types";
import { getAllWorkouts, deleteWorkout } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { getAllWorkouts, getWorkoutWithSets, deleteWorkoutAndSync } from "@db/operations";
import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = {
id: string;
name: string | null;
totalDistance: number | null;
totalDuration: number | null;
startedAt: string;
finishedAt: string | null;
poolLength: number;
createdAt: string;
totalDistance?: number;
};
export function WorkoutListScreen() {
@@ -26,15 +24,30 @@ export function WorkoutListScreen() {
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
const [refreshing, setRefreshing] = useState(false);
const loadWorkouts = async () => {
const loadWorkouts = useCallback(async () => {
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(
useCallback(() => {
loadWorkouts();
}, [])
}, [loadWorkouts])
);
const onRefresh = async () => {
@@ -50,8 +63,8 @@ export function WorkoutListScreen() {
text: tc("delete"),
style: "destructive",
onPress: async () => {
await deleteWorkout(id);
await loadWorkouts();
await deleteWorkoutAndSync(id);
setWorkouts((prev) => prev.filter((w) => w.id !== id));
},
},
]);
@@ -92,18 +105,13 @@ export function WorkoutListScreen() {
{item.name || tc("untitledWorkout")}
</Text>
<Text className="text-slate-500 text-sm mt-1">
{formatDistance(item.totalDistance ?? 0)}
{item.totalDuration ? ` · ${formatTime(item.totalDuration)}` : ""}
{item.totalDistance ? formatDistance(item.totalDistance) : ""}
{item.poolLength ? ` · ${tc("pool", { length: item.poolLength })}` : ""}
</Text>
<Text className="text-slate-400 text-xs mt-1">
{new Date(item.startedAt).toLocaleDateString()}
{new Date(item.createdAt).toLocaleDateString()}
</Text>
</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>
</Pressable>
</SwipeableRow>
+1
View File
@@ -9,6 +9,7 @@ WatchConnectivity = requireOptionalNativeModule("WatchConnectivity") ?? {
isReachable: async () => false,
sendWorkoutPlan: 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 () => [],
getApplicationContext: async () => null,
updateApplicationContext: async () => {},
+1 -1
View File
@@ -4,7 +4,7 @@ export type RootStackParamList = {
Main: undefined;
WorkoutBuilder: { templateId?: string } | undefined;
PreSwim: { workoutId: string };
PostSwim: { workoutId: string };
PostSwim: { sessionId: string };
SessionDetail: { sessionId: string };
};
+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 {
return sets.reduce((acc, set) => acc + set.distancePerRep * set.repCount, 0);
+28 -28
View File
@@ -61,7 +61,7 @@
"value" : "%ds Pause"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "%ds hvile"
@@ -130,7 +130,7 @@
"value" : "Zurück"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tilbage"
@@ -189,7 +189,7 @@
"value" : "Kalorien"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kalorier"
@@ -248,7 +248,7 @@
"value" : "Bahn abschließen"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fuldfør bane"
@@ -307,7 +307,7 @@
"value" : "Erstelle ein Workout auf deinem iPhone um zu beginnen"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Opret en træning på din iPhone for at komme i gang"
@@ -366,7 +366,7 @@
"value" : "Distanz"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Distance"
@@ -425,7 +425,7 @@
"value" : "Beenden"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Slut"
@@ -484,7 +484,7 @@
"value" : "Workout beenden"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Afslut træning"
@@ -543,7 +543,7 @@
"value" : "%@kcal"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@kcal"
@@ -602,7 +602,7 @@
"value" : "Bahn erkannt!"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bane registreret!"
@@ -661,7 +661,7 @@
"value" : "Runden"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Baner"
@@ -720,7 +720,7 @@
"value" : "%@m Becken"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@m pool"
@@ -779,7 +779,7 @@
"value" : "%@m gesamt"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@m i alt"
@@ -848,7 +848,7 @@
"value" : "Noch keine Workouts"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingen træninger endnu"
@@ -907,7 +907,7 @@
"value" : "OK"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "OK"
@@ -966,7 +966,7 @@
"value" : "Pause"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pause"
@@ -1025,7 +1025,7 @@
"value" : "Zuletzt"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Seneste"
@@ -1094,7 +1094,7 @@
"value" : "Pause"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hvile"
@@ -1153,7 +1153,7 @@
"value" : "Pause %@"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hvile %@"
@@ -1212,7 +1212,7 @@
"value" : "Fortsetzen"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Forsæt"
@@ -1271,7 +1271,7 @@
"value" : "Gespeicherte Workouts"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gemte træninger"
@@ -1340,7 +1340,7 @@
"value" : "Sets"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sæt"
@@ -1399,7 +1399,7 @@
"value" : "Starten"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Start"
@@ -1458,7 +1458,7 @@
"value" : "Statistiken"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Statistik"
@@ -1520,7 +1520,7 @@
"value" : "Gesamtzeit"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Samlet tid"
@@ -1579,7 +1579,7 @@
"value" : "Wassersperre"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Vandlås"
@@ -1638,7 +1638,7 @@
"value" : "Workout"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Træning"
@@ -1697,7 +1697,7 @@
"value" : "Training abgeschlossen!"
}
},
"dk" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Træning afsluttet!"
+15 -6
View File
@@ -11,7 +11,7 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
@Published var selectedWorkout: StoredWorkout?
@Published var currentSetIndex = 0
@Published var currentRep = 0
@Published var currentLapDistance = 0
@Published var currentRepDistanceSwum: Int = 0
@Published var elapsedSeconds: TimeInterval = 0
@Published var heartRate: Double = 0
@Published var activeCalories: Double = 0
@@ -157,6 +157,7 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
completedLaps = []
healthKitLapCount = 0
currentStrokeCount = 0
currentRepDistanceSwum = 0
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
startHeartRateQuery()
@@ -235,6 +236,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
guard let workout = selectedWorkout else { return }
let currentSet = workout.sets[currentSetIndex]
currentRepDistanceSwum = 0
if currentRep + 1 < currentSet.repCount {
currentRep += 1
lapStartTime = Date()
@@ -296,10 +299,9 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
}
var currentSetDistanceSwum: Double {
guard let workout = selectedWorkout, currentSetIndex < workout.sets.count else { return 0 }
let setDistance = workout.sets[currentSetIndex].distancePerRep
let completedInSet = completedLaps.filter { $0.setPosition == currentSetIndex + 1 }.count
return Double(setDistance) * Double(completedInSet)
completedLaps
.filter { $0.setPosition == currentSetIndex + 1 }
.reduce(0) { $0 + Double($1.distance) }
}
var currentSetTotalDistance: Double {
@@ -490,11 +492,12 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
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: workout.sets[currentSetIndex].distancePerRep,
distance: lapDistance,
duration: duration,
heartRate: Int(heartRate),
strokeType: workout.sets[currentSetIndex].stroke,
@@ -503,6 +506,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
completedLaps.append(lap)
currentStrokeCount = 0
currentRepDistanceSwum += lapDistance
WKInterfaceDevice.current().play(.click)
DispatchQueue.main.async {
@@ -512,16 +517,20 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
self.lapDetected = false
}
if currentRepDistanceSwum >= workout.sets[currentSetIndex].distancePerRep {
advanceToNextRep()
}
lapStartTime = Date()
}
// MARK: - Sync to Phone
private func syncCompletedWorkout() {
let sessionId = UUID().uuidString
let workoutSummary: [String: Any] = [
"type": "workoutCompleted",
"workoutId": selectedWorkout?.id ?? "",
"sessionId": sessionId,
"name": selectedWorkout?.name ?? "Workout",
"poolLength": selectedWorkout?.poolLength ?? 0,
"laps": completedLaps.map { lap in
+1 -1
View File
@@ -1,6 +1,6 @@
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
module.exports = (config) => ({
name: config.name,
name: "SwimBuddyWatch",
type: "watch",
icon: "../../assets/icon.png",
deploymentTarget: "10.0",
+20 -1
View File
@@ -5,6 +5,8 @@ plugins {
id("io.sentry.android.gradle") version "6.17.0"
}
val credentialsFile = rootProject.file("../credentials.json")
android {
namespace = "nl.guidoit.swimbuddy"
compileSdk = 35
@@ -22,6 +24,20 @@ android {
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 {
release {
isMinifyEnabled = true
@@ -30,6 +46,9 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (credentialsFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
}
}
@@ -50,7 +69,7 @@ android {
dependencies {
// Wear OS Compose
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")
// Compose
@@ -34,6 +34,10 @@
android:name="io.sentry.attach-screenshot"
android:value="true" />
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="false" />
<activity
android:name=".SwimBuddyWearActivity"
android:exported="true"
@@ -20,6 +20,7 @@ class SwimBuddyWearActivity : ComponentActivity() {
val completedWorkouts by viewModel.completedWorkouts.collectAsState()
val selectedWorkout by viewModel.selectedWorkout.collectAsState()
val isWorkoutActive by viewModel.isWorkoutActive.collectAsState()
val isPaused by viewModel.isPaused.collectAsState()
val currentSetIndex by viewModel.currentSetIndex.collectAsState()
val currentRep by viewModel.currentRep.collectAsState()
val elapsedSeconds by viewModel.elapsedSeconds.collectAsState()
@@ -29,12 +30,14 @@ class SwimBuddyWearActivity : ComponentActivity() {
val isResting by viewModel.isResting.collectAsState()
val restTimeRemaining by viewModel.restTimeRemaining.collectAsState()
val workoutSummary by viewModel.workoutSummary.collectAsState()
val isWaterLockActive by viewModel.isWaterLockActive.collectAsState()
SwimBuddyApp(
savedWorkouts = savedWorkouts,
completedWorkouts = completedWorkouts,
selectedWorkout = selectedWorkout,
isWorkoutActive = isWorkoutActive,
isPaused = isPaused,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
elapsedSeconds = elapsedSeconds,
@@ -47,9 +50,13 @@ class SwimBuddyWearActivity : ComponentActivity() {
totalDistanceSwum = viewModel.totalDistanceSwum(),
currentSetDistanceSwum = viewModel.currentSetDistanceSwum(),
currentSetTotalDistance = viewModel.currentSetTotalDistance(),
isWaterLockActive = isWaterLockActive,
onSelectWorkout = { viewModel.selectWorkout(it) },
onStartWorkout = { viewModel.startWorkout() },
onCompleteLap = { viewModel.completeLap() },
onPauseResume = {
if (isPaused) viewModel.resumeWorkout() else viewModel.pauseWorkout()
},
onEndWorkout = { viewModel.endWorkout() },
onBack = { viewModel.selectWorkout(null) },
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 storage = WorkoutStorage(application)
private val waterLockHelper = WaterLockHelper()
private val gson = Gson()
private val appContext = application
@@ -67,8 +68,14 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
private val _workoutSummary = MutableStateFlow<WorkoutSummary?>(null)
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 lapStartTime = 0L
private var pausedElapsedSeconds = 0f
private var lapDetector: LapDetector? = null
private var currentStrokeCount = 0L
@@ -137,8 +144,11 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
healthManager.startSwimmingWorkout(workout.poolLength)
_isWorkoutActive.value = true
_isPaused.value = false
waterLockHelper.onWorkoutStarted()
startTime = System.currentTimeMillis()
lapStartTime = startTime
pausedElapsedSeconds = 0f
_currentSetIndex.value = 0
_currentRep.value = 0
_completedLaps.value = emptyList()
@@ -152,6 +162,27 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
}
}
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() {
lapDetector = LapDetector(appContext) {
viewModelScope.launch {
@@ -167,7 +198,9 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
private fun startElapsedTimeTracking() {
viewModelScope.launch {
while (_isWorkoutActive.value) {
if (!_isPaused.value) {
_elapsedSeconds.value = (System.currentTimeMillis() - startTime) / 1000f
}
kotlinx.coroutines.delay(1000)
}
}
@@ -259,6 +292,8 @@ class WorkoutViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch {
healthManager.endWorkout()
_isWorkoutActive.value = false
_isPaused.value = false
waterLockHelper.onWorkoutEnded()
_currentSetIndex.value = 0
_currentRep.value = 0
_elapsedSeconds.value = 0f
@@ -151,14 +151,17 @@ fun ActiveWorkoutScreen(
)
}
Button(
onClick = onEndWorkout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Red
)
) {
Text(
text = stringResource(R.string.end),
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = Color.Red,
style = MaterialTheme.typography.caption3,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
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
import androidx.compose.foundation.layout.fillMaxSize
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.WorkoutSummary
@@ -10,6 +15,7 @@ fun SwimBuddyApp(
completedWorkouts: List<StoredWorkout>,
selectedWorkout: StoredWorkout?,
isWorkoutActive: Boolean,
isPaused: Boolean,
currentSetIndex: Int,
currentRep: Int,
elapsedSeconds: Float,
@@ -22,9 +28,11 @@ fun SwimBuddyApp(
totalDistanceSwum: Int,
currentSetDistanceSwum: Int,
currentSetTotalDistance: Int,
isWaterLockActive: Boolean,
onSelectWorkout: (StoredWorkout) -> Unit,
onStartWorkout: () -> Unit,
onCompleteLap: () -> Unit,
onPauseResume: () -> Unit,
onEndWorkout: () -> Unit,
onBack: () -> Unit,
onDeleteSaved: (StoredWorkout) -> Unit,
@@ -38,16 +46,8 @@ fun SwimBuddyApp(
onDismiss = onDismissSummary
)
}
isWorkoutActive && isResting -> {
RestScreen(
restTimeRemaining = restTimeRemaining,
totalDistanceSwum = totalDistanceSwum,
calories = calories,
nextSet = selectedWorkout?.sets?.getOrNull(currentSetIndex)
)
}
isWorkoutActive -> {
ActiveWorkoutScreen(
ActiveWorkoutPager(
workout = selectedWorkout,
currentSetIndex = currentSetIndex,
currentRep = currentRep,
@@ -55,10 +55,15 @@ fun SwimBuddyApp(
heartRate = heartRate,
calories = calories,
lapDetected = lapDetected,
isResting = isResting,
restTimeRemaining = restTimeRemaining,
totalDistanceSwum = totalDistanceSwum,
currentSetDistanceSwum = currentSetDistanceSwum,
currentSetTotalDistance = currentSetTotalDistance,
isPaused = isPaused,
isWaterLockActive = isWaterLockActive,
onCompleteLap = onCompleteLap,
onPauseResume = onPauseResume,
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.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
@@ -55,6 +56,7 @@ fun WorkoutDetailScreen(
}
items(workout.sets.size) { index ->
val set = workout.sets[index]
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -69,6 +71,24 @@ fun WorkoutDetailScreen(
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 {
Spacer(modifier = Modifier.height(8.dp))
@@ -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="tap_to_dismiss">Tryk for at lukke</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Runden</string>
<string name="tap_to_dismiss">Tippen zum Schließen</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Vueltas</string>
<string name="tap_to_dismiss">Toca para cerrar</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Tours</string>
<string name="tap_to_dismiss">Appuyez pour fermer</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Giri</string>
<string name="tap_to_dismiss">Tocca per chiudere</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Rondes</string>
<string name="tap_to_dismiss">Tik om te sluiten</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Okrążenia</string>
<string name="tap_to_dismiss">Stuknij aby zamknąć</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Voltas</string>
<string name="tap_to_dismiss">Toque para fechar</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>
@@ -27,4 +27,10 @@
<string name="laps_label">Laps</string>
<string name="tap_to_dismiss">Tap to dismiss</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>