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 ## 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
} }
] ]
} }
+81 -2
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]
} }
+10308 -10266
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));
+152 -77
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,74 +67,50 @@ 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));
await db.delete(workouts).where(eq(workouts.id, id));
}
// ─── 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) { await db.insert(workoutSessions).values({
const swolf = id: sessionId,
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: generateUUID(),
workoutId, workoutId,
setPosition: lap.setPosition, name: workout?.name ?? null,
repNumber: lap.repNumber, poolLength: workout?.poolLength ?? 25,
distance: lap.distance, startedAt: new Date().toISOString(),
duration: lap.duration, finishedAt: new Date().toISOString(),
strokeCount: lap.strokeCount ?? null, totalDistance,
heartRate: lap.heartRate ?? null, totalDuration: duration,
swolf, calories,
avgHeartRate,
source: "phone",
}); });
}
export async function saveLaps(workoutId: string, lapsData: LapData[]) { return sessionId;
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));
} }
export async function saveWatchCompletedWorkout(data: { export async function saveWatchCompletedWorkout(data: {
workoutId: string; workoutId: string;
sessionId?: string;
duration: number; duration: number;
totalDistance: number; totalDistance: number;
calories: 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) ? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length)
: 0; : 0;
const existing = await db // Haal plan op als het bestaat
.select({ id: workouts.id }) const plan = await db
.select()
.from(workouts) .from(workouts)
.where(eq(workouts.id, data.workoutId)) .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); .limit(1);
if (existing.length === 0) { if (existingSession.length === 0) {
await db.insert(workouts).values({ await db.insert(workoutSessions).values({
id: data.workoutId, id: sessionId,
name: "Watch Workout", workoutId: data.workoutId,
poolLength: 25, name: plan?.name ?? "Watch Workout",
poolLength: plan?.poolLength ?? 25,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
totalDuration: data.duration,
totalDistance: data.totalDistance || 0, totalDistance: data.totalDistance || 0,
totalDuration: data.duration,
calories: data.calories || 0, calories: data.calories || 0,
avgHeartRate, avgHeartRate,
source: "watch", source: "watch",
}); });
} else { } else {
await db await db
.update(workouts) .update(workoutSessions)
.set({ .set({
finishedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
totalDuration: data.duration, totalDuration: data.duration,
totalDistance: data.totalDistance || 0, totalDistance: data.totalDistance || 0,
calories: data.calories || 0, calories: data.calories || 0,
avgHeartRate, avgHeartRate,
source: "watch",
}) })
.where(eq(workouts.id, data.workoutId)); .where(eq(workoutSessions.id, sessionId));
} }
for (const lap of data.laps) { for (const lap of data.laps) {
@@ -192,7 +178,7 @@ export async function saveWatchCompletedWorkout(data: {
await db.insert(laps).values({ await db.insert(laps).values({
id: generateUUID(), id: generateUUID(),
workoutId: data.workoutId, sessionId,
setPosition: lap.setPosition, setPosition: lap.setPosition,
repNumber: lap.repNumber, repNumber: lap.repNumber,
distance: lap.distance, 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 ─────────────────────────────────────────────────── // ─── Stats ───────────────────────────────────────────────────
export async function getWeeklyStats() { export async function getWeeklyStats() {
@@ -213,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();
} }
@@ -238,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,7 +6,7 @@ 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"];
@@ -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 () => {},
+1 -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 };
}; };
+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);
+28 -28
View File
@@ -61,7 +61,7 @@
"value" : "%ds Pause" "value" : "%ds Pause"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "%ds hvile" "value" : "%ds hvile"
@@ -130,7 +130,7 @@
"value" : "Zurück" "value" : "Zurück"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Tilbage" "value" : "Tilbage"
@@ -189,7 +189,7 @@
"value" : "Kalorien" "value" : "Kalorien"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Kalorier" "value" : "Kalorier"
@@ -248,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"
@@ -307,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"
@@ -366,7 +366,7 @@
"value" : "Distanz" "value" : "Distanz"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Distance" "value" : "Distance"
@@ -425,7 +425,7 @@
"value" : "Beenden" "value" : "Beenden"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Slut" "value" : "Slut"
@@ -484,7 +484,7 @@
"value" : "Workout beenden" "value" : "Workout beenden"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Afslut træning" "value" : "Afslut træning"
@@ -543,7 +543,7 @@
"value" : "%@kcal" "value" : "%@kcal"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "%@kcal" "value" : "%@kcal"
@@ -602,7 +602,7 @@
"value" : "Bahn erkannt!" "value" : "Bahn erkannt!"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Bane registreret!" "value" : "Bane registreret!"
@@ -661,7 +661,7 @@
"value" : "Runden" "value" : "Runden"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Baner" "value" : "Baner"
@@ -720,7 +720,7 @@
"value" : "%@m Becken" "value" : "%@m Becken"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "%@m pool" "value" : "%@m pool"
@@ -779,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"
@@ -848,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"
@@ -907,7 +907,7 @@
"value" : "OK" "value" : "OK"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "OK" "value" : "OK"
@@ -966,7 +966,7 @@
"value" : "Pause" "value" : "Pause"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Pause" "value" : "Pause"
@@ -1025,7 +1025,7 @@
"value" : "Zuletzt" "value" : "Zuletzt"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Seneste" "value" : "Seneste"
@@ -1094,7 +1094,7 @@
"value" : "Pause" "value" : "Pause"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Hvile" "value" : "Hvile"
@@ -1153,7 +1153,7 @@
"value" : "Pause %@" "value" : "Pause %@"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Hvile %@" "value" : "Hvile %@"
@@ -1212,7 +1212,7 @@
"value" : "Fortsetzen" "value" : "Fortsetzen"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Forsæt" "value" : "Forsæt"
@@ -1271,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"
@@ -1340,7 +1340,7 @@
"value" : "Sets" "value" : "Sets"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Sæt" "value" : "Sæt"
@@ -1399,7 +1399,7 @@
"value" : "Starten" "value" : "Starten"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Start" "value" : "Start"
@@ -1458,7 +1458,7 @@
"value" : "Statistiken" "value" : "Statistiken"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Statistik" "value" : "Statistik"
@@ -1520,7 +1520,7 @@
"value" : "Gesamtzeit" "value" : "Gesamtzeit"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Samlet tid" "value" : "Samlet tid"
@@ -1579,7 +1579,7 @@
"value" : "Wassersperre" "value" : "Wassersperre"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Vandlås" "value" : "Vandlås"
@@ -1638,7 +1638,7 @@
"value" : "Workout" "value" : "Workout"
} }
}, },
"dk" : { "da" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "state" : "translated",
"value" : "Træning" "value" : "Træning"
@@ -1697,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!"
+16 -7
View File
@@ -11,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
@@ -157,6 +157,7 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
completedLaps = [] completedLaps = []
healthKitLapCount = 0 healthKitLapCount = 0
currentStrokeCount = 0 currentStrokeCount = 0
currentRepDistanceSwum = 0
startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25) startHealthKitWorkout(poolLength: selectedWorkout?.poolLength ?? 25)
startHeartRateQuery() startHeartRateQuery()
@@ -235,6 +236,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
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()
@@ -296,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 {
@@ -490,11 +492,12 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
let now = Date() let now = Date()
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0 let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
let lapDistance = Int(workout.poolLength)
let lap = CompletedLap( let lap = CompletedLap(
setPosition: currentSetIndex + 1, setPosition: currentSetIndex + 1,
repNumber: currentRep + 1, repNumber: currentRep + 1,
distance: workout.sets[currentSetIndex].distancePerRep, distance: lapDistance,
duration: duration, duration: duration,
heartRate: Int(heartRate), heartRate: Int(heartRate),
strokeType: workout.sets[currentSetIndex].stroke, strokeType: workout.sets[currentSetIndex].stroke,
@@ -503,6 +506,8 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
completedLaps.append(lap) completedLaps.append(lap)
currentStrokeCount = 0 currentStrokeCount = 0
currentRepDistanceSwum += lapDistance
WKInterfaceDevice.current().play(.click) WKInterfaceDevice.current().play(.click)
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -512,16 +517,20 @@ class WorkoutManager: NSObject, ObservableObject, HKLiveWorkoutBuilderDelegate {
self.lapDetected = false self.lapDetected = false
} }
advanceToNextRep() if currentRepDistanceSwum >= workout.sets[currentSetIndex].distancePerRep {
advanceToNextRep()
}
lapStartTime = Date() 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
+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,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,8 +68,14 @@ 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 var currentStrokeCount = 0L
@@ -137,8 +144,11 @@ 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()
@@ -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() { private fun startLapDetection() {
lapDetector = LapDetector(appContext) { lapDetector = LapDetector(appContext) {
viewModelScope.launch { viewModelScope.launch {
@@ -167,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)
} }
} }
@@ -259,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
@@ -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>