feat: initial project scaffold with Watch companion app
This commit is contained in:
@@ -1,3 +1,72 @@
|
|||||||
# Expo HAS CHANGED
|
# SwimBuddy
|
||||||
|
|
||||||
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
Swimming workout tracker with Apple Watch companion app. Expo 57 + React Native 0.86 + NativeWind v5 + Drizzle ORM + Swift Watch target.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start # Dev server
|
||||||
|
npx expo run:ios # Build & run on iOS
|
||||||
|
npx expo prebuild -p ios # Regenerate ios/ dir (safe — targets/ persists)
|
||||||
|
npx drizzle-kit generate # Regenerate SQL migrations after schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
No `npm test` or lint scripts exist yet. No `babel.config.js` — NativeWind v5 uses CSS-first config.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
features/<name>/screens/ # Screen components (registered in navigators)
|
||||||
|
features/<name>/components/# Feature-specific UI
|
||||||
|
features/<name>/hooks/ # Feature-specific hooks
|
||||||
|
components/ # Shared UI primitives (flat)
|
||||||
|
hooks/ # Shared hooks
|
||||||
|
navigation/ # React Navigation (RootNav, TabNav, feature stacks)
|
||||||
|
db/ # Drizzle schema, client, relations, operations
|
||||||
|
providers/ # DatabaseProvider, SettingsProvider (MMKV)
|
||||||
|
utils/ # formatTime, calculateSwolf, etc.
|
||||||
|
constants/ # strokes, poolLengths, colors
|
||||||
|
types/ # workout.ts, common.ts
|
||||||
|
modules/watch-connectivity/# Expo native module bridging WCSession
|
||||||
|
targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives prebuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- **Path aliases**: `@/`, `@features/`, `@components/`, `@db/`, etc. (tsconfig.json)
|
||||||
|
- **Feature-based** folder structure with **type-based** shared layer
|
||||||
|
- **NativeWind v5**: `className` prop, no babel config, CSS-first. Use `className` not `StyleSheet`
|
||||||
|
- **`className` types**: `nativewind-env.d.ts` at root — don't delete
|
||||||
|
- **lightningcss pinned** to 1.30.1 in package.json overrides — required by NativeWind v5
|
||||||
|
|
||||||
|
## Watch Target
|
||||||
|
|
||||||
|
- Lives in `targets/watch/`, NOT in `ios/` — survives `expo prebuild --clean`
|
||||||
|
- Config: `targets/watch/expo-target.config.js`
|
||||||
|
- Build: open `ios/SwimBuddy.xcworkspace` in Xcode, select Watch scheme
|
||||||
|
- Uses App Groups (`group.nl.guido-it.swimbuddy`) for data sharing via UserDefaults
|
||||||
|
- WCSession for real-time messaging, `transferUserInfo` for reliable offline sync
|
||||||
|
- HealthKit for swim workout tracking (indoor swimming)
|
||||||
|
- Swift types `StoredWorkout`, `StoredSet`, `CompletedLap` in `WorkoutManager.swift`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- **expo-sqlite** + **Drizzle ORM** (SQLite dialect, expo driver)
|
||||||
|
- Schema: `src/db/schema.ts` — 4 tables: templates, workouts, sets, laps
|
||||||
|
- Relations: `src/db/relations.ts`
|
||||||
|
- CRUD: `src/db/operations.ts`
|
||||||
|
- Client: `src/db/client.ts` — exports `db` and `DATABASE_NAME`
|
||||||
|
- Migrations in `drizzle/` — generated by `npx drizzle-kit generate`
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
|
||||||
|
- `expo prebuild` clears and regenerates `ios/` by default. Use `--no-clean` to preserve
|
||||||
|
- Watch target compiles as separate Xcode target — not a RN module
|
||||||
|
- `react-native-css` is a NativeWind v5 peer dependency — don't remove
|
||||||
|
- `@bacons/apple-targets` plugin must be in app.json plugins array
|
||||||
|
|
||||||
|
## Expo Docs
|
||||||
|
|
||||||
|
Expo SDK 57: https://docs.expo.dev/versions/v57.0.0/
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import "./global.css";
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
|
import { RootNavigator } from "./src/navigation/RootNavigator";
|
||||||
|
import { DatabaseProvider } from "./src/providers/DatabaseProvider";
|
||||||
|
import { SettingsProvider } from "./src/providers/WatchProvider";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<DatabaseProvider>
|
||||||
<Text>Open up App.tsx to start working on your app!</Text>
|
<SettingsProvider>
|
||||||
<StatusBar style="auto" />
|
<NavigationContainer>
|
||||||
</View>
|
<RootNavigator />
|
||||||
|
<StatusBar style="light" />
|
||||||
|
</NavigationContainer>
|
||||||
|
</SettingsProvider>
|
||||||
|
</DatabaseProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,25 +1,36 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "SwimBuddy",
|
"name": "SwimBuddy",
|
||||||
"slug": "SwimBuddy",
|
"slug": "swimbuddy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
},
|
"bundleIdentifier": "nl.guido-it.swimbuddy",
|
||||||
"android": {
|
"entitlements": {
|
||||||
"adaptiveIcon": {
|
"com.apple.security.application-groups": [
|
||||||
"backgroundColor": "#E6F4FE",
|
"group.nl.guido-it.swimbuddy"
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
],
|
||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"com.apple.developer.healthkit": true
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
},
|
||||||
},
|
"appleTeamId": "7TABQZ5458"
|
||||||
"predictiveBackGestureEnabled": false
|
},
|
||||||
},
|
"android": {
|
||||||
"web": {
|
"adaptiveIcon": {
|
||||||
"favicon": "./assets/favicon.png"
|
"backgroundColor": "#E6F4FE",
|
||||||
}
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
}
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "nl.guidoit.swimbuddy"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"favicon": "./assets/favicon.png",
|
||||||
|
"bundler": "metro"
|
||||||
|
},
|
||||||
|
"plugins": ["expo-sqlite", "@bacons/apple-targets"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "sqlite",
|
||||||
|
driver: "expo",
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
CREATE TABLE `laps` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`workout_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 (`workout_id`) REFERENCES `workouts`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `sets` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`workout_id` text NOT NULL,
|
||||||
|
`position` integer NOT NULL,
|
||||||
|
`stroke` text NOT NULL,
|
||||||
|
`distance_per_rep` integer NOT NULL,
|
||||||
|
`rep_count` integer NOT NULL,
|
||||||
|
`rest_type` text DEFAULT 'fixed_rest' NOT NULL,
|
||||||
|
`rest_duration` real DEFAULT 0 NOT NULL,
|
||||||
|
`target_time` real,
|
||||||
|
FOREIGN KEY (`workout_id`) REFERENCES `workouts`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `templates` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`pool_length` real DEFAULT 25 NOT NULL,
|
||||||
|
`json_data` text NOT NULL,
|
||||||
|
`is_favorite` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `workouts` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text,
|
||||||
|
`template_id` 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
|
||||||
|
);
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "a9688cc9-9314-4d59-9b68-b7e202f3e3ff",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"laps": {
|
||||||
|
"name": "laps",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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_workout_id_workouts_id_fk",
|
||||||
|
"tableFrom": "laps",
|
||||||
|
"tableTo": "workouts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"workout_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
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785854381700,
|
||||||
|
"tag": "0000_mushy_groot",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo
|
||||||
|
|
||||||
|
import journal from './meta/_journal.json';
|
||||||
|
import m0000 from './0000_mushy_groot.sql';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
journal,
|
||||||
|
migrations: {
|
||||||
|
m0000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/preflight.css" layer(base);
|
||||||
|
@import "tailwindcss/utilities.css";
|
||||||
|
|
||||||
|
@import "nativewind/theme";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withNativewind } = require("nativewind/metro");
|
||||||
|
|
||||||
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.resolver.sourceExts.push("sql");
|
||||||
|
|
||||||
|
module.exports = withNativewind(config);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-native-css/types" />
|
||||||
Generated
+3119
-119
File diff suppressed because it is too large
Load Diff
+25
-5
@@ -3,20 +3,40 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bacons/apple-targets": "^5.0.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.18.14",
|
||||||
|
"@react-navigation/native": "^7.3.14",
|
||||||
|
"@react-navigation/native-stack": "^7.18.6",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"expo": "~57.0.10",
|
"expo": "~57.0.10",
|
||||||
|
"expo-sqlite": "~57.0.1",
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-system-ui": "~57.0.2",
|
||||||
|
"nativewind": "^5.0.0-preview.4",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.86.2"
|
"react-native": "0.86.2",
|
||||||
|
"react-native-css": "^3.0.7",
|
||||||
|
"react-native-mmkv": "^4.3.2",
|
||||||
|
"react-native-reanimated": "4.5.1",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "~4.26.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"babel-plugin-inline-import": "^3.0.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"tailwindcss": "^4",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web",
|
||||||
|
"db:generate": "drizzle-kit generate"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true,
|
||||||
|
"overrides": {
|
||||||
|
"lightningcss": "1.30.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Pressable, Text, ActivityIndicator, type PressableProps } from "react-native";
|
||||||
|
|
||||||
|
type ButtonProps = PressableProps & {
|
||||||
|
title: string;
|
||||||
|
variant?: "primary" | "secondary" | "ghost";
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ title, variant = "primary", loading, disabled, ...props }: ButtonProps) {
|
||||||
|
const base = "rounded-xl px-6 py-3 items-center justify-center";
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-sky-500",
|
||||||
|
secondary: "bg-slate-100",
|
||||||
|
ghost: "bg-transparent",
|
||||||
|
};
|
||||||
|
const textVariants = {
|
||||||
|
primary: "text-white font-semibold",
|
||||||
|
secondary: "text-slate-700 font-semibold",
|
||||||
|
ghost: "text-sky-500 font-medium",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
className={`${base} ${variants[variant]} ${disabled ? "opacity-50" : ""}`}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color={variant === "primary" ? "#fff" : "#0ea5e9"} />
|
||||||
|
) : (
|
||||||
|
<Text className={textVariants[variant]}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
|
||||||
|
type CardProps = ViewProps & {
|
||||||
|
variant?: "default" | "highlighted";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Card({ variant = "default", className = "", children, ...props }: CardProps) {
|
||||||
|
const base = "rounded-2xl p-4";
|
||||||
|
const variants = {
|
||||||
|
default: "bg-white border border-slate-200",
|
||||||
|
highlighted: "bg-sky-50 border border-sky-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={`${base} ${variants[variant]} ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { View, Text, ActivityIndicator } from "react-native";
|
||||||
|
|
||||||
|
type EmptyStateProps = {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
icon?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyState({ title, message }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-12 px-4">
|
||||||
|
<Text className="text-lg font-semibold text-slate-900 mb-2">{title}</Text>
|
||||||
|
{message && <Text className="text-slate-500 text-center">{message}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadingSpinnerProps = {
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({ message }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center py-12">
|
||||||
|
<ActivityIndicator size="large" color="#0ea5e9" />
|
||||||
|
{message && <Text className="text-slate-500 mt-3">{message}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { TextInput, View, Text, type TextInputProps } from "react-native";
|
||||||
|
|
||||||
|
type InputProps = TextInputProps & {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Input({ label, error, className = "", ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<View className="mb-3">
|
||||||
|
{label && <Text className="text-sm font-medium text-slate-500 mb-1">{label}</Text>}
|
||||||
|
<TextInput
|
||||||
|
className={`bg-white border ${error ? "border-red-400" : "border-slate-300"} rounded-lg px-3 py-2.5 text-slate-900 ${className}`}
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <Text className="text-red-500 text-xs mt-1">{error}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
type ScreenContainerProps = ViewProps & {
|
||||||
|
padded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScreenContainer({ padded = true, className = "", children, ...props }: ScreenContainerProps) {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`flex-1 bg-white ${padded ? "p-4" : ""} ${className}`}
|
||||||
|
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const COLORS = {
|
||||||
|
primary: "#0ea5e9",
|
||||||
|
primaryDark: "#0284c7",
|
||||||
|
primaryLight: "#e0f2fe",
|
||||||
|
background: "#ffffff",
|
||||||
|
surface: "#f8fafc",
|
||||||
|
text: "#0f172a",
|
||||||
|
textSecondary: "#64748b",
|
||||||
|
border: "#e2e8f0",
|
||||||
|
success: "#10b981",
|
||||||
|
warning: "#f59e0b",
|
||||||
|
error: "#ef4444",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const POOL_LENGTHS = [25, 50] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_POOL_LENGTH = 25;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const STROKE_TYPES = [
|
||||||
|
"Freestyle",
|
||||||
|
"Backstroke",
|
||||||
|
"Breaststroke",
|
||||||
|
"Butterfly",
|
||||||
|
"Mixed",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const STROKE_COLORS: Record<string, string> = {
|
||||||
|
Freestyle: "#0ea5e9",
|
||||||
|
Backstroke: "#8b5cf6",
|
||||||
|
Breaststroke: "#10b981",
|
||||||
|
Butterfly: "#f59e0b",
|
||||||
|
Mixed: "#6b7280",
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { openDatabaseSync } from "expo-sqlite";
|
||||||
|
import { drizzle } from "drizzle-orm/expo-sqlite";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
export const DATABASE_NAME = "swimbuddy.db";
|
||||||
|
|
||||||
|
const expoDb = openDatabaseSync(DATABASE_NAME, { enableChangeListener: true });
|
||||||
|
|
||||||
|
export const db = drizzle(expoDb, { schema });
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { db } from "./client";
|
||||||
|
import { workouts, sets, laps, templates } from "./schema";
|
||||||
|
import { eq, desc, sql } from "drizzle-orm";
|
||||||
|
import type { WorkoutSet, LapData } from "@types/workout";
|
||||||
|
|
||||||
|
// ─── Templates ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveTemplate(name: string, poolLength: number, setsData: WorkoutSet[]) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await db.insert(templates).values({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
poolLength,
|
||||||
|
jsonData: JSON.stringify(setsData),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplates() {
|
||||||
|
return db.select().from(templates).orderBy(desc(templates.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: string) {
|
||||||
|
await db.delete(templates).where(eq(templates.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleTemplateFavorite(id: string, isFavorite: boolean) {
|
||||||
|
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workouts ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveWorkout(
|
||||||
|
name: string | null,
|
||||||
|
poolLength: number,
|
||||||
|
setsData: WorkoutSet[],
|
||||||
|
templateId?: string
|
||||||
|
) {
|
||||||
|
const workoutId = crypto.randomUUID();
|
||||||
|
const totalDistance = setsData.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
|
||||||
|
|
||||||
|
await db.insert(workouts).values({
|
||||||
|
id: workoutId,
|
||||||
|
name,
|
||||||
|
templateId: templateId ?? null,
|
||||||
|
poolLength,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
totalDistance,
|
||||||
|
source: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < setsData.length; i++) {
|
||||||
|
const s = setsData[i];
|
||||||
|
await db.insert(sets).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
workoutId,
|
||||||
|
position: i + 1,
|
||||||
|
stroke: s.stroke,
|
||||||
|
distancePerRep: s.distancePerRep,
|
||||||
|
repCount: s.repCount,
|
||||||
|
restType: s.restType,
|
||||||
|
restDuration: s.restDuration,
|
||||||
|
targetTime: s.targetTime ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return workoutId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finishWorkout(
|
||||||
|
workoutId: string,
|
||||||
|
duration: number,
|
||||||
|
calories: number,
|
||||||
|
avgHeartRate: number
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(workouts)
|
||||||
|
.set({
|
||||||
|
finishedAt: new Date().toISOString(),
|
||||||
|
totalDuration: duration,
|
||||||
|
calories,
|
||||||
|
avgHeartRate,
|
||||||
|
})
|
||||||
|
.where(eq(workouts.id, workoutId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkoutWithSets(workoutId: string) {
|
||||||
|
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
|
||||||
|
if (!workout) return null;
|
||||||
|
|
||||||
|
const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId));
|
||||||
|
const workoutLaps = await db.select().from(laps).where(eq(laps.workoutId, workoutId));
|
||||||
|
|
||||||
|
return { ...workout, sets: workoutSets, laps: workoutLaps };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllWorkouts() {
|
||||||
|
return db.select().from(workouts).orderBy(desc(workouts.startedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkout(id: string) {
|
||||||
|
await db.delete(laps).where(eq(laps.workoutId, id));
|
||||||
|
await db.delete(sets).where(eq(sets.workoutId, id));
|
||||||
|
await db.delete(workouts).where(eq(workouts.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Laps ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function saveLap(workoutId: string, lap: LapData) {
|
||||||
|
const swolf =
|
||||||
|
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
|
||||||
|
|
||||||
|
await db.insert(laps).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
workoutId,
|
||||||
|
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(workoutId: string, lapsData: LapData[]) {
|
||||||
|
for (const lap of lapsData) {
|
||||||
|
await saveLap(workoutId, lap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWorkoutLaps(workoutId: string) {
|
||||||
|
return db.select().from(laps).where(eq(laps.workoutId, workoutId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stats ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getWeeklyStats() {
|
||||||
|
const now = new Date();
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
|
||||||
|
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
|
||||||
|
avgCalories: sql<number>`coalesce(avg(${workouts.calories}), 0)`,
|
||||||
|
})
|
||||||
|
.from(workouts)
|
||||||
|
.where(sql`${workouts.startedAt} >= ${weekAgo.toISOString()}`)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersonalBests() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
distance: laps.distance,
|
||||||
|
bestTime: sql<number>`min(${laps.duration})`,
|
||||||
|
bestSwolf: sql<number>`min(${laps.swolf})`,
|
||||||
|
})
|
||||||
|
.from(laps)
|
||||||
|
.groupBy(laps.distance)
|
||||||
|
.having(sql`${laps.distance} > 0`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTotalStats() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
totalWorkouts: sql<number>`count(*)`,
|
||||||
|
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
|
||||||
|
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
|
||||||
|
})
|
||||||
|
.from(workouts)
|
||||||
|
.where(sql`${workouts.finishedAt} IS NOT NULL`)
|
||||||
|
.get();
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { workouts, sets, laps, templates } from "./schema";
|
||||||
|
|
||||||
|
export const templatesRelations = relations(templates, ({ many }) => ({
|
||||||
|
workouts: many(workouts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const workoutsRelations = relations(workouts, ({ one, many }) => ({
|
||||||
|
template: one(templates, {
|
||||||
|
fields: [workouts.templateId],
|
||||||
|
references: [templates.id],
|
||||||
|
}),
|
||||||
|
sets: many(sets),
|
||||||
|
laps: many(laps),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const setsRelations = relations(sets, ({ one }) => ({
|
||||||
|
workout: one(workouts, {
|
||||||
|
fields: [sets.workoutId],
|
||||||
|
references: [workouts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const lapsRelations = relations(laps, ({ one }) => ({
|
||||||
|
workout: one(workouts, {
|
||||||
|
fields: [laps.workoutId],
|
||||||
|
references: [workouts.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const templates = sqliteTable("templates", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
poolLength: real("pool_length").notNull().default(25),
|
||||||
|
jsonData: text("json_data").notNull(),
|
||||||
|
isFavorite: integer("is_favorite", { mode: "boolean" }).notNull().default(false),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workouts = sqliteTable("workouts", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
templateId: text("template_id"),
|
||||||
|
poolLength: real("pool_length").notNull().default(25),
|
||||||
|
startedAt: text("started_at").notNull(),
|
||||||
|
finishedAt: text("finished_at"),
|
||||||
|
totalDistance: integer("total_distance"),
|
||||||
|
totalDuration: real("total_duration"),
|
||||||
|
calories: real("calories"),
|
||||||
|
avgHeartRate: integer("avg_heart_rate"),
|
||||||
|
source: text("source").notNull().default("manual"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sets = sqliteTable("sets", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
workoutId: text("workout_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workouts.id),
|
||||||
|
position: integer("position").notNull(),
|
||||||
|
stroke: text("stroke").notNull(),
|
||||||
|
distancePerRep: integer("distance_per_rep").notNull(),
|
||||||
|
repCount: integer("rep_count").notNull(),
|
||||||
|
restType: text("rest_type").notNull().default("fixed_rest"),
|
||||||
|
restDuration: real("rest_duration").notNull().default(0),
|
||||||
|
targetTime: real("target_time"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const laps = sqliteTable("laps", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
workoutId: text("workout_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => workouts.id),
|
||||||
|
setPosition: integer("set_position").notNull(),
|
||||||
|
repNumber: integer("rep_number").notNull(),
|
||||||
|
distance: integer("distance").notNull(),
|
||||||
|
duration: real("duration").notNull(),
|
||||||
|
strokeCount: integer("stroke_count"),
|
||||||
|
heartRate: integer("heart_rate"),
|
||||||
|
swolf: real("swolf"),
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, Pressable } from "react-native";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "@navigation/types";
|
||||||
|
import type { RouteProp } from "@react-navigation/native";
|
||||||
|
import { getWorkoutWithSets, getWorkoutLaps } from "@db/operations";
|
||||||
|
import { formatTime } from "@utils/formatTime";
|
||||||
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
|
||||||
|
export function PostSwimScreen() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
const route = useRoute<RouteProp<RootStackParamList, "PostSwim">>();
|
||||||
|
const [summary, setSummary] = useState<{
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
laps: number;
|
||||||
|
avgSwolf: number | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSummary();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
const workout = await getWorkoutWithSets(route.params.workoutId);
|
||||||
|
const laps = await getWorkoutLaps(route.params.workoutId);
|
||||||
|
|
||||||
|
if (!workout) return;
|
||||||
|
|
||||||
|
const totalDuration = workout.laps?.reduce((acc: number, l: any) => acc + l.duration, 0) ?? 0;
|
||||||
|
const avgSwolf =
|
||||||
|
laps.length > 0
|
||||||
|
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
setSummary({
|
||||||
|
distance: workout.totalDistance ?? 0,
|
||||||
|
duration: totalDuration,
|
||||||
|
laps: laps.length,
|
||||||
|
avgSwolf,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white p-4">
|
||||||
|
<View className="items-center mb-8">
|
||||||
|
<Text className="text-3xl mb-2">🏊</Text>
|
||||||
|
<Text className="text-2xl font-bold text-slate-900 mb-1">Swim Complete!</Text>
|
||||||
|
<Text className="text-slate-500">Great job getting in the water</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{summary ? (
|
||||||
|
<View className="gap-3 mb-8">
|
||||||
|
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||||
|
<Text className="text-slate-600">Distance</Text>
|
||||||
|
<Text className="text-xl font-bold text-sky-600">
|
||||||
|
{formatDistance(summary.distance)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||||
|
<Text className="text-slate-600">Duration</Text>
|
||||||
|
<Text className="text-xl font-bold text-sky-600">
|
||||||
|
{formatTime(summary.duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||||
|
<Text className="text-slate-600">Laps</Text>
|
||||||
|
<Text className="text-xl font-bold text-sky-600">{summary.laps}</Text>
|
||||||
|
</View>
|
||||||
|
{summary.avgSwolf && (
|
||||||
|
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||||
|
<Text className="text-slate-600">Avg SWOLF</Text>
|
||||||
|
<Text className="text-xl font-bold text-sky-600">{summary.avgSwolf}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className="py-8 items-center">
|
||||||
|
<Text className="text-slate-400">Loading summary...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.popToTop()}
|
||||||
|
className="bg-slate-900 rounded-2xl py-4 items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||||
|
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "@navigation/types";
|
||||||
|
import type { RouteProp } from "@react-navigation/native";
|
||||||
|
import { getWorkoutWithSets } from "@db/operations";
|
||||||
|
import { useSyncWorkout } from "../hooks/useSyncWorkout";
|
||||||
|
import { useWatchConnection } from "../hooks/useWatchConnection";
|
||||||
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
import { STROKE_COLORS } from "@constants/strokes";
|
||||||
|
import type { WorkoutSet } from "@types/workout";
|
||||||
|
|
||||||
|
type WorkoutWithSets = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
poolLength: number;
|
||||||
|
totalDistance: number | null;
|
||||||
|
sets: WorkoutSet[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PreSwimScreen() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
|
||||||
|
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
|
||||||
|
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
|
||||||
|
const { status, isPaired, isInstalled } = useWatchConnection();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkout();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadWorkout = async () => {
|
||||||
|
const data = await getWorkoutWithSets(route.params.workoutId);
|
||||||
|
setWorkout(data as WorkoutWithSets | null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartSwim = async () => {
|
||||||
|
if (!workout) return;
|
||||||
|
|
||||||
|
if (status !== "connected") {
|
||||||
|
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendWorkoutToWatch(
|
||||||
|
workout.id,
|
||||||
|
workout.name,
|
||||||
|
workout.poolLength,
|
||||||
|
workout.sets
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!workout) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#0ea5e9" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white p-4">
|
||||||
|
<View className="items-center mb-6">
|
||||||
|
<Text className="text-2xl font-bold text-slate-900 mb-1">
|
||||||
|
{workout.name || "Workout"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-slate-500">
|
||||||
|
{formatDistance(workout.totalDistance ?? 0)} · {workout.poolLength}m pool
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="bg-sky-50 rounded-2xl p-4 mb-4 border border-sky-100">
|
||||||
|
<Text className="text-xs font-semibold text-sky-700 mb-3 uppercase tracking-wide">
|
||||||
|
Sets
|
||||||
|
</Text>
|
||||||
|
{workout.sets.map((set, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className="flex-row items-center justify-between py-2 border-b border-sky-100 last:border-b-0"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
|
||||||
|
/>
|
||||||
|
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-slate-600">
|
||||||
|
{set.repCount}×{set.distancePerRep}m
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
|
||||||
|
<View className="flex-row justify-between mb-2">
|
||||||
|
<Text className="text-slate-500">Rest</Text>
|
||||||
|
<Text className="text-slate-900 font-medium">
|
||||||
|
{workout.sets[0]?.restDuration ?? 0}s between sets
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="bg-slate-50 rounded-2xl p-3 mb-6 flex-row items-center gap-3">
|
||||||
|
<View
|
||||||
|
className={`w-3 h-3 rounded-full ${
|
||||||
|
status === "connected" ? "bg-green-500" : "bg-slate-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Text className="text-sm text-slate-600">
|
||||||
|
{status === "connected"
|
||||||
|
? "Apple Watch connected"
|
||||||
|
: isPaired
|
||||||
|
? "Watch paired but not connected"
|
||||||
|
: "No Watch paired"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleStartSwim}
|
||||||
|
disabled={isSyncing}
|
||||||
|
className={`rounded-2xl py-4 items-center ${
|
||||||
|
isSyncing ? "bg-sky-300" : "bg-sky-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text className="text-white font-bold text-lg">
|
||||||
|
{status === "connected" ? "Send to Watch & Start" : "Start Without Watch"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text className="text-slate-400 text-xs text-center mt-3">
|
||||||
|
{status === "connected"
|
||||||
|
? "Workout will be sent to your Apple Watch"
|
||||||
|
: "Workout will be tracked on this device"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, Pressable, FlatList } from "react-native";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { HistoryStackParamList } from "@navigation/types";
|
||||||
|
import { getAllWorkouts } from "@db/operations";
|
||||||
|
import { formatTime } from "@utils/formatTime";
|
||||||
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
|
||||||
|
type WorkoutRow = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
totalDistance: number | null;
|
||||||
|
totalDuration: number | null;
|
||||||
|
calories: number | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HistoryScreen() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
||||||
|
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkouts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadWorkouts = async () => {
|
||||||
|
const data = await getAllWorkouts();
|
||||||
|
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
|
||||||
|
const date = new Date(w.startedAt).toLocaleDateString();
|
||||||
|
if (!acc[date]) acc[date] = [];
|
||||||
|
acc[date].push(w);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const sections = Object.entries(grouped);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<View className="p-4 pb-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate("Stats")}
|
||||||
|
className="bg-slate-100 rounded-2xl p-3 items-center mb-4"
|
||||||
|
>
|
||||||
|
<Text className="text-sky-600 font-semibold">View Statistics</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={sections}
|
||||||
|
keyExtractor={([date]) => date}
|
||||||
|
contentContainerClassName="px-4 pb-8"
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="py-12 items-center">
|
||||||
|
<Text className="text-slate-400 text-lg">No completed workouts yet</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item: [date, rows] }) => (
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
|
||||||
|
{date}
|
||||||
|
</Text>
|
||||||
|
{rows.map((w) => (
|
||||||
|
<Pressable
|
||||||
|
key={w.id}
|
||||||
|
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
|
||||||
|
className="bg-slate-50 rounded-2xl p-4 mb-2 border border-slate-200"
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<View>
|
||||||
|
<Text className="font-semibold text-slate-900">
|
||||||
|
{w.name || "Swim Session"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-slate-500 text-sm">
|
||||||
|
{formatDistance(w.totalDistance ?? 0)}
|
||||||
|
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
|
||||||
|
{w.calories ? ` · ${Math.round(w.calories)} kcal` : ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-slate-400 text-xs">
|
||||||
|
{new Date(w.startedAt).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, ScrollView } from "react-native";
|
||||||
|
import type { HistoryStackProps } from "@navigation/types";
|
||||||
|
import { getWorkoutWithSets } from "@db/operations";
|
||||||
|
import { formatTime } from "@utils/formatTime";
|
||||||
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
import { STROKE_COLORS } from "@constants/strokes";
|
||||||
|
|
||||||
|
type WorkoutDetail = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
poolLength: number;
|
||||||
|
totalDistance: number | null;
|
||||||
|
totalDuration: number | null;
|
||||||
|
calories: number | null;
|
||||||
|
avgHeartRate: number | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
sets: {
|
||||||
|
stroke: string;
|
||||||
|
distancePerRep: number;
|
||||||
|
repCount: number;
|
||||||
|
position: number;
|
||||||
|
}[];
|
||||||
|
laps: {
|
||||||
|
setPosition: number;
|
||||||
|
repNumber: number;
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
strokeCount: number | null;
|
||||||
|
heartRate: number | null;
|
||||||
|
swolf: number | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
|
||||||
|
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkout();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadWorkout = async () => {
|
||||||
|
const data = await getWorkoutWithSets(route.params.sessionId);
|
||||||
|
setWorkout(data as WorkoutDetail | null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!workout) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white items-center justify-center">
|
||||||
|
<Text className="text-slate-400">Loading...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgSwolf =
|
||||||
|
workout.laps.length > 0
|
||||||
|
? Math.round(
|
||||||
|
workout.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / workout.laps.length
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="flex-1 bg-white p-4">
|
||||||
|
<View className="items-center mb-6">
|
||||||
|
<Text className="text-2xl font-bold text-slate-900 mb-1">
|
||||||
|
{workout.name || "Swim Session"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-slate-500">
|
||||||
|
{new Date(workout.startedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3 mb-6">
|
||||||
|
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
||||||
|
<Text className="text-2xl font-bold text-sky-600">
|
||||||
|
{formatDistance(workout.totalDistance ?? 0)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500 mt-1">Distance</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
||||||
|
<Text className="text-2xl font-bold text-sky-600">
|
||||||
|
{formatTime(workout.totalDuration ?? 0)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500 mt-1">Duration</Text>
|
||||||
|
</View>
|
||||||
|
{workout.calories && (
|
||||||
|
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
||||||
|
<Text className="text-2xl font-bold text-sky-600">
|
||||||
|
{Math.round(workout.calories)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500 mt-1">kcal</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{avgSwolf && (
|
||||||
|
<View className="bg-slate-50 rounded-2xl p-4 mb-4">
|
||||||
|
<Text className="text-sm font-semibold text-slate-700">Avg SWOLF</Text>
|
||||||
|
<Text className="text-xl font-bold text-slate-900">{avgSwolf}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
|
Sets
|
||||||
|
</Text>
|
||||||
|
{workout.sets.map((set, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className="flex-row items-center justify-between py-3 border-b border-slate-100"
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<View
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
|
||||||
|
/>
|
||||||
|
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-slate-600">
|
||||||
|
{set.repCount}×{set.distancePerRep}m
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{workout.laps.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mt-6 mb-2">
|
||||||
|
Laps
|
||||||
|
</Text>
|
||||||
|
{workout.laps.map((lap, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
className="flex-row justify-between py-2 border-b border-slate-100"
|
||||||
|
>
|
||||||
|
<Text className="text-slate-600">
|
||||||
|
Lap {i + 1} · {lap.distance}m
|
||||||
|
</Text>
|
||||||
|
<Text className="text-slate-600 font-medium">
|
||||||
|
{formatTime(lap.duration)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, ScrollView } from "react-native";
|
||||||
|
import { getWeeklyStats, getTotalStats, getPersonalBests } from "@db/operations";
|
||||||
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
import { formatTime } from "@utils/formatTime";
|
||||||
|
|
||||||
|
type WeeklyStats = {
|
||||||
|
count: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
avgCalories: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TotalStats = {
|
||||||
|
totalWorkouts: number;
|
||||||
|
totalDistance: number;
|
||||||
|
totalDuration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PersonalBest = {
|
||||||
|
distance: number;
|
||||||
|
bestTime: number;
|
||||||
|
bestSwolf: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatsScreen() {
|
||||||
|
const [weekly, setWeekly] = useState<WeeklyStats | null>(null);
|
||||||
|
const [total, setTotal] = useState<TotalStats | null>(null);
|
||||||
|
const [bests, setBests] = useState<PersonalBest[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
const [w, t, b] = await Promise.all([
|
||||||
|
getWeeklyStats(),
|
||||||
|
getTotalStats(),
|
||||||
|
getPersonalBests(),
|
||||||
|
]);
|
||||||
|
setWeekly(w as WeeklyStats);
|
||||||
|
setTotal(t as TotalStats);
|
||||||
|
setBests(b as PersonalBest[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="flex-1 bg-white p-4">
|
||||||
|
<Text className="text-2xl font-bold text-slate-900 mb-6">Statistics</Text>
|
||||||
|
|
||||||
|
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
|
This Week
|
||||||
|
</Text>
|
||||||
|
<View className="bg-sky-50 rounded-2xl p-4 mb-6">
|
||||||
|
{weekly ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="items-center flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-sky-600">{weekly.count}</Text>
|
||||||
|
<Text className="text-xs text-slate-500">Workouts</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-center flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-sky-600">
|
||||||
|
{formatDistance(weekly.totalDistance)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500">Distance</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-center flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-sky-600">
|
||||||
|
{formatTime(weekly.totalDuration)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500">Duration</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-slate-400 text-center py-4">No data yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
|
All Time
|
||||||
|
</Text>
|
||||||
|
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
|
||||||
|
{total ? (
|
||||||
|
<View className="flex-row justify-between">
|
||||||
|
<View className="items-center flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-slate-700">{total.totalWorkouts}</Text>
|
||||||
|
<Text className="text-xs text-slate-500">Workouts</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-center flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-slate-700">
|
||||||
|
{formatDistance(total.totalDistance)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500">Distance</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-center flex-1">
|
||||||
|
<Text className="text-2xl font-bold text-slate-700">
|
||||||
|
{formatTime(total.totalDuration)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-slate-500">Duration</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-slate-400 text-center py-4">No data yet</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{bests.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||||
|
Personal Bests
|
||||||
|
</Text>
|
||||||
|
{bests.map((best) => (
|
||||||
|
<View
|
||||||
|
key={best.distance}
|
||||||
|
className="bg-green-50 rounded-2xl p-4 mb-2 flex-row justify-between items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-green-700 font-medium">{best.distance}m</Text>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-green-800 font-bold">{formatTime(best.bestTime)}</Text>
|
||||||
|
{best.bestSwolf && (
|
||||||
|
<Text className="text-green-600 text-xs">SWOLF: {best.bestSwolf}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { View, Text, Pressable } from "react-native";
|
||||||
|
import { useSettings } from "../../providers/WatchProvider";
|
||||||
|
|
||||||
|
export function SettingsScreen() {
|
||||||
|
const { poolLength, setPoolLength } = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white p-4">
|
||||||
|
<Text className="text-2xl font-bold text-slate-900 mb-6">Settings</Text>
|
||||||
|
|
||||||
|
<View className="mb-6">
|
||||||
|
<Text className="text-sm font-medium text-slate-500 mb-2">Default Pool Length</Text>
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
{[25, 50].map((len) => (
|
||||||
|
<Pressable
|
||||||
|
key={len}
|
||||||
|
onPress={() => setPoolLength(len)}
|
||||||
|
className={`flex-1 py-3 rounded-xl items-center ${
|
||||||
|
poolLength === len ? "bg-sky-500" : "bg-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-semibold ${
|
||||||
|
poolLength === len ? "text-white" : "text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{len}m
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="border-t border-slate-200 pt-4 mt-2">
|
||||||
|
<Text className="text-sm text-slate-400">SwimBuddy v1.0.0</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { getTemplates, deleteTemplate, toggleTemplateFavorite } from "@db/operations";
|
||||||
|
import type { WorkoutSet } from "@types/workout";
|
||||||
|
|
||||||
|
type Template = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
poolLength: number;
|
||||||
|
jsonData: string;
|
||||||
|
isFavorite: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTemplates() {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getTemplates();
|
||||||
|
setTemplates(data as Template[]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
await deleteTemplate(id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleFavorite = useCallback(
|
||||||
|
async (id: string, current: boolean) => {
|
||||||
|
await toggleTemplateFavorite(id, !current);
|
||||||
|
setTemplates((prev) =>
|
||||||
|
prev.map((t) => (t.id === id ? { ...t, isFavorite: !current } : t))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseSets = (template: Template): WorkoutSet[] => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(template.jsonData);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { templates, isLoading, remove, toggleFavorite, parseSets, reload: load };
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { WorkoutStackParamList } from "@navigation/types";
|
||||||
|
import { useTemplates } from "../hooks/useTemplates";
|
||||||
|
|
||||||
|
export function TemplatesScreen() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||||
|
const { templates, remove, toggleFavorite } = useTemplates();
|
||||||
|
|
||||||
|
const handleDelete = (id: string, name: string) => {
|
||||||
|
Alert.alert("Delete Template", `Delete "${name}"?`, [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{ text: "Delete", style: "destructive", onPress: () => remove(id) },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<FlatList
|
||||||
|
data={templates}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerClassName="p-4"
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="py-12 items-center">
|
||||||
|
<Text className="text-slate-400 text-lg">No templates yet</Text>
|
||||||
|
<Text className="text-slate-400 text-sm mt-1">
|
||||||
|
Save a workout as a template to reuse it
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate("Builder", { templateId: item.id })}
|
||||||
|
className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200"
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-start">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="font-semibold text-slate-900 text-base">{item.name}</Text>
|
||||||
|
<Text className="text-slate-500 text-sm mt-1">
|
||||||
|
{item.poolLength}m pool
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => toggleFavorite(item.id, item.isFavorite)}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
<Text className="text-lg">{item.isFavorite ? "★" : "☆"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleDelete(item.id, item.name)}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
<Text className="text-red-400 text-lg">×</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import WatchConnectivity from "../../modules/watch-connectivity";
|
||||||
|
import type { WorkoutSet } from "@types/workout";
|
||||||
|
|
||||||
|
type SyncResult = { status: string } | { error: string };
|
||||||
|
|
||||||
|
export function useSyncWorkout() {
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
|
||||||
|
const sendWorkoutToWatch = async (
|
||||||
|
workoutId: string,
|
||||||
|
name: string | null,
|
||||||
|
poolLength: number,
|
||||||
|
sets: WorkoutSet[]
|
||||||
|
): Promise<SyncResult> => {
|
||||||
|
setIsSyncing(true);
|
||||||
|
try {
|
||||||
|
const plan = {
|
||||||
|
id: workoutId,
|
||||||
|
name: name ?? "Workout",
|
||||||
|
poolLength,
|
||||||
|
sets: sets.map((s) => ({
|
||||||
|
stroke: s.stroke,
|
||||||
|
distancePerRep: s.distancePerRep,
|
||||||
|
repCount: s.repCount,
|
||||||
|
restType: s.restType,
|
||||||
|
restDuration: s.restDuration,
|
||||||
|
targetTime: s.targetTime ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await WatchConnectivity.sendWorkoutPlan(plan);
|
||||||
|
return result as SyncResult;
|
||||||
|
} catch (error: any) {
|
||||||
|
return { error: error.message ?? "Failed to sync" };
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isSyncing, sendWorkoutToWatch };
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import WatchConnectivity from "../../modules/watch-connectivity";
|
||||||
|
|
||||||
|
type WatchConnectionStatus = "connected" | "disconnected" | "pending";
|
||||||
|
|
||||||
|
export function useWatchConnection() {
|
||||||
|
const [status, setStatus] = useState<WatchConnectionStatus>("pending");
|
||||||
|
const [isPaired, setIsPaired] = useState(false);
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
const interval = setInterval(checkStatus, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
try {
|
||||||
|
const supported = await WatchConnectivity.isSupported();
|
||||||
|
if (!supported) {
|
||||||
|
setStatus("disconnected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paired = await WatchConnectivity.isPaired();
|
||||||
|
const installed = await WatchConnectivity.isWatchAppInstalled();
|
||||||
|
const reachable = await WatchConnectivity.isReachable();
|
||||||
|
|
||||||
|
setIsPaired(paired);
|
||||||
|
setIsInstalled(installed);
|
||||||
|
setStatus(reachable ? "connected" : "disconnected");
|
||||||
|
} catch {
|
||||||
|
setStatus("disconnected");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { status, isPaired, isInstalled };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import WatchConnectivity from "../../modules/watch-connectivity";
|
||||||
|
|
||||||
|
export function useWatchReachability() {
|
||||||
|
const [isReachable, setIsReachable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkReachability();
|
||||||
|
const interval = setInterval(checkReachability, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkReachability = async () => {
|
||||||
|
try {
|
||||||
|
const supported = await WatchConnectivity.isSupported();
|
||||||
|
if (supported) {
|
||||||
|
const reachable = await WatchConnectivity.isReachable();
|
||||||
|
setIsReachable(reachable);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsReachable(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return isReachable;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { WorkoutSet, StrokeType, RestType } from "@types/workout";
|
||||||
|
import { saveWorkout, saveTemplate } from "@db/operations";
|
||||||
|
|
||||||
|
const DEFAULT_SET: WorkoutSet = {
|
||||||
|
stroke: "Freestyle",
|
||||||
|
distancePerRep: 200,
|
||||||
|
repCount: 1,
|
||||||
|
restType: "fixed_rest",
|
||||||
|
restDuration: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkoutBuilder(initialSets?: WorkoutSet[]) {
|
||||||
|
const [sets, setSets] = useState<WorkoutSet[]>(initialSets ?? [{ ...DEFAULT_SET }]);
|
||||||
|
const [workoutName, setWorkoutName] = useState("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const addSet = useCallback(() => {
|
||||||
|
setSets((prev) => [...prev, { ...DEFAULT_SET }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSet = useCallback((index: number, updates: Partial<WorkoutSet>) => {
|
||||||
|
setSets((prev) => prev.map((s, i) => (i === index ? { ...s, ...updates } : s)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeSet = useCallback((index: number) => {
|
||||||
|
setSets((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalDistance = sets.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (poolLength: number, templateId?: string) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const name = workoutName.trim() || `Workout ${new Date().toLocaleDateString()}`;
|
||||||
|
const id = await saveWorkout(name, poolLength, sets, templateId);
|
||||||
|
return id;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workoutName, sets]
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveAsTemplate = useCallback(
|
||||||
|
async (templateName: string, poolLength: number) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const id = await saveTemplate(templateName, poolLength, sets);
|
||||||
|
return id;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sets]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadTemplate = useCallback((templateSets: WorkoutSet[], name?: string) => {
|
||||||
|
setSets(templateSets);
|
||||||
|
if (name) setWorkoutName(name);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setSets([{ ...DEFAULT_SET }]);
|
||||||
|
setWorkoutName("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sets,
|
||||||
|
workoutName,
|
||||||
|
setWorkoutName,
|
||||||
|
totalDistance,
|
||||||
|
isSaving,
|
||||||
|
addSet,
|
||||||
|
updateSet,
|
||||||
|
removeSet,
|
||||||
|
save,
|
||||||
|
saveAsTemplate,
|
||||||
|
loadTemplate,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { RootStackParamList } from "@navigation/types";
|
||||||
|
import { useWorkoutBuilder } from "../hooks/useWorkoutBuilder";
|
||||||
|
import { useSettings } from "../../../providers/WatchProvider";
|
||||||
|
import type { StrokeType } from "@types/workout";
|
||||||
|
|
||||||
|
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
|
||||||
|
|
||||||
|
export function BuilderScreen() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
const { poolLength } = useSettings();
|
||||||
|
const {
|
||||||
|
sets,
|
||||||
|
workoutName,
|
||||||
|
setWorkoutName,
|
||||||
|
totalDistance,
|
||||||
|
isSaving,
|
||||||
|
addSet,
|
||||||
|
updateSet,
|
||||||
|
removeSet,
|
||||||
|
save,
|
||||||
|
} = useWorkoutBuilder();
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const workoutId = await save(poolLength);
|
||||||
|
navigation.navigate("PreSwim", { workoutId });
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert("Error", "Failed to save workout");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView className="flex-1 bg-white">
|
||||||
|
<View className="p-4">
|
||||||
|
<Text className="text-2xl font-bold text-slate-900 mb-1">Build Workout</Text>
|
||||||
|
<Text className="text-slate-500 mb-4">
|
||||||
|
{totalDistance}m total · {poolLength}m pool
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm font-medium text-slate-500 mb-1">Workout Name</Text>
|
||||||
|
<TextInput
|
||||||
|
value={workoutName}
|
||||||
|
onChangeText={setWorkoutName}
|
||||||
|
placeholder="Morning Swim"
|
||||||
|
placeholderTextColor="#94a3b8"
|
||||||
|
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-slate-900"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{sets.map((set, index) => (
|
||||||
|
<View key={index} className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200">
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<Text className="font-semibold text-slate-700">Set {index + 1}</Text>
|
||||||
|
{sets.length > 1 && (
|
||||||
|
<Pressable onPress={() => removeSet(index)}>
|
||||||
|
<Text className="text-red-500 text-sm font-medium">Remove</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-xs font-medium text-slate-500 mb-1.5 uppercase tracking-wide">
|
||||||
|
Stroke
|
||||||
|
</Text>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-3">
|
||||||
|
{STROKES.map((stroke) => (
|
||||||
|
<Pressable
|
||||||
|
key={stroke}
|
||||||
|
onPress={() => updateSet(index, { stroke })}
|
||||||
|
className={`mr-2 px-3 py-1.5 rounded-full ${
|
||||||
|
set.stroke === stroke ? "bg-sky-500" : "bg-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
set.stroke === stroke ? "text-white" : "text-slate-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stroke}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View className="flex-row gap-2">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs font-medium text-slate-500 mb-1">Distance (m)</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={String(set.distancePerRep)}
|
||||||
|
onChangeText={(t) => updateSet(index, { distancePerRep: Number(t) || 0 })}
|
||||||
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 text-center"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs font-medium text-slate-500 mb-1">Reps</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={String(set.repCount)}
|
||||||
|
onChangeText={(t) => updateSet(index, { repCount: Number(t) || 1 })}
|
||||||
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 text-center"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-xs font-medium text-slate-500 mb-1">Rest (s)</Text>
|
||||||
|
<TextInput
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={String(set.restDuration)}
|
||||||
|
onChangeText={(t) => updateSet(index, { restDuration: Number(t) || 0 })}
|
||||||
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 text-center"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={addSet}
|
||||||
|
className="bg-slate-100 rounded-2xl py-3 items-center mb-6 border border-slate-200"
|
||||||
|
>
|
||||||
|
<Text className="text-sky-600 font-semibold">+ Add Set</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`rounded-2xl py-4 items-center mb-8 ${isSaving ? "bg-sky-300" : "bg-sky-500"}`}
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">
|
||||||
|
{isSaving ? "Saving..." : "Start Swim"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, Pressable, FlatList } from "react-native";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import type { WorkoutStackParamList } from "@navigation/types";
|
||||||
|
import { getAllWorkouts, deleteWorkout } from "@db/operations";
|
||||||
|
import { formatTime } from "@utils/formatTime";
|
||||||
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
|
||||||
|
type WorkoutRow = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
totalDistance: number | null;
|
||||||
|
totalDuration: number | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkoutListScreen() {
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||||
|
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkouts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadWorkouts = async () => {
|
||||||
|
const data = await getAllWorkouts();
|
||||||
|
setWorkouts(data as WorkoutRow[]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-white">
|
||||||
|
<View className="p-4 pb-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate("Builder")}
|
||||||
|
className="bg-sky-500 rounded-2xl p-4 items-center mb-3"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-bold text-lg">+ New Workout</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate("Templates")}
|
||||||
|
className="bg-slate-100 rounded-2xl p-3 items-center mb-4"
|
||||||
|
>
|
||||||
|
<Text className="text-sky-600 font-semibold">Browse Templates</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={workouts}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerClassName="px-4 pb-8"
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className="py-12 items-center">
|
||||||
|
<Text className="text-slate-400 text-lg">No workouts yet</Text>
|
||||||
|
<Text className="text-slate-400 text-sm mt-1">Tap "New Workout" to get started</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
|
||||||
|
className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200"
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-start">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="font-semibold text-slate-900 text-base">
|
||||||
|
{item.name || "Untitled Workout"}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-slate-500 text-sm mt-1">
|
||||||
|
{formatDistance(item.totalDistance ?? 0)}
|
||||||
|
{item.totalDuration ? ` · ${formatTime(item.totalDuration)}` : ""}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-slate-400 text-xs mt-1">
|
||||||
|
{new Date(item.startedAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.finishedAt && (
|
||||||
|
<View className="bg-green-100 rounded-full px-2 py-0.5">
|
||||||
|
<Text className="text-green-700 text-xs font-medium">Done</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class WatchConnectivityModule: Module {
|
||||||
|
public func definition() -> Definition {
|
||||||
|
Name("WatchConnectivity")
|
||||||
|
|
||||||
|
AsyncFunction("isSupported") { () -> Bool in
|
||||||
|
return WCSession.isSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isPaired") { () -> Bool in
|
||||||
|
return WCSession.default.isPaired
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isWatchAppInstalled") { () -> Bool in
|
||||||
|
return WCSession.default.isWatchAppInstalled
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("isReachable") { () -> Bool in
|
||||||
|
return WCSession.default.isReachable
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in
|
||||||
|
var message = plan
|
||||||
|
message["type"] = "workoutPlan"
|
||||||
|
|
||||||
|
if WCSession.default.isReachable {
|
||||||
|
WCSession.default.sendMessage(message, replyHandler: { reply in
|
||||||
|
promise.resolve(reply)
|
||||||
|
}, errorHandler: { error in
|
||||||
|
promise.reject(error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
WCSession.default.transferUserInfo(message)
|
||||||
|
promise.resolve(["status": "queued"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
|
||||||
|
var message = data
|
||||||
|
message["type"] = "workoutCompleted"
|
||||||
|
|
||||||
|
if WCSession.default.isReachable {
|
||||||
|
WCSession.default.sendMessage(message, replyHandler: { reply in
|
||||||
|
promise.resolve(reply)
|
||||||
|
}, errorHandler: { error in
|
||||||
|
promise.reject(error)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
WCSession.default.transferUserInfo(message)
|
||||||
|
promise.resolve(["status": "queued"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("getApplicationContext") { () -> [String: Any]? in
|
||||||
|
return WCSession.default.receivedApplicationContext as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("updateApplicationContext") { (context: [String: Any]) in
|
||||||
|
try? WCSession.default.updateApplicationContext(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { requireNativeModule } from "expo-modules-core";
|
||||||
|
|
||||||
|
export default requireNativeModule("WatchConnectivity");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import { HistoryStackParamList } from "./types";
|
||||||
|
import { HistoryScreen } from "../features/history/screens/HistoryScreen";
|
||||||
|
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
|
||||||
|
import { StatsScreen } from "../features/history/screens/StatsScreen";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<HistoryStackParamList>();
|
||||||
|
|
||||||
|
export function HistoryNavigator() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: "#0ea5e9" },
|
||||||
|
headerTintColor: "#fff",
|
||||||
|
headerTitleStyle: { fontWeight: "bold" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="HistoryList"
|
||||||
|
component={HistoryScreen}
|
||||||
|
options={{ title: "History" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="SessionDetail"
|
||||||
|
component={SessionDetailScreen}
|
||||||
|
options={{ title: "Session Detail" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Stats"
|
||||||
|
component={StatsScreen}
|
||||||
|
options={{ title: "Statistics" }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
|
import { MainTabParamList } from "./types";
|
||||||
|
import { WorkoutNavigator } from "./WorkoutNavigator";
|
||||||
|
import { HistoryNavigator } from "./HistoryNavigator";
|
||||||
|
import { SettingsScreen } from "../features/settings/screens/SettingsScreen";
|
||||||
|
|
||||||
|
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||||
|
|
||||||
|
export function MainTabNavigator() {
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: "#0ea5e9",
|
||||||
|
tabBarInactiveTintColor: "#94a3b8",
|
||||||
|
headerStyle: { backgroundColor: "#0ea5e9" },
|
||||||
|
headerTintColor: "#fff",
|
||||||
|
headerTitleStyle: { fontWeight: "bold" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Workouts"
|
||||||
|
component={WorkoutNavigator}
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarIcon: ({ color, size }) => {
|
||||||
|
// Placeholder — replace with Expo vector icons later
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="History"
|
||||||
|
component={HistoryNavigator}
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarIcon: ({ color, size }) => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tab.Screen
|
||||||
|
name="Settings"
|
||||||
|
component={SettingsScreen}
|
||||||
|
options={{
|
||||||
|
tabBarIcon: ({ color, size }) => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tab.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import { MainTabNavigator } from "./MainTabNavigator";
|
||||||
|
import { RootStackParamList } from "./types";
|
||||||
|
import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen";
|
||||||
|
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
|
||||||
|
import { PostSwimScreen } from "../features/active-workout/screens/PostSwimScreen";
|
||||||
|
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
|
||||||
|
import { StatsScreen } from "../features/history/screens/StatsScreen";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
|
export function RootNavigator() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: "#0ea5e9" },
|
||||||
|
headerTintColor: "#fff",
|
||||||
|
headerTitleStyle: { fontWeight: "bold" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Main"
|
||||||
|
component={MainTabNavigator}
|
||||||
|
options={{ headerShown: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="WorkoutBuilder"
|
||||||
|
component={BuilderScreen}
|
||||||
|
options={{ title: "New Workout" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="PreSwim"
|
||||||
|
component={PreSwimScreen}
|
||||||
|
options={{ title: "Ready to Swim" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="PostSwim"
|
||||||
|
component={PostSwimScreen}
|
||||||
|
options={{ title: "Swim Complete", headerLeft: () => null }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="SessionDetail"
|
||||||
|
component={SessionDetailScreen}
|
||||||
|
options={{ title: "Session" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Stats"
|
||||||
|
component={StatsScreen}
|
||||||
|
options={{ title: "Statistics" }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import { WorkoutStackParamList } from "./types";
|
||||||
|
import { WorkoutListScreen } from "../features/workout-builder/screens/WorkoutListScreen";
|
||||||
|
import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen";
|
||||||
|
import { TemplatesScreen } from "../features/templates/screens/TemplatesScreen";
|
||||||
|
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<WorkoutStackParamList>();
|
||||||
|
|
||||||
|
export function WorkoutNavigator() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
screenOptions={{
|
||||||
|
headerStyle: { backgroundColor: "#0ea5e9" },
|
||||||
|
headerTintColor: "#fff",
|
||||||
|
headerTitleStyle: { fontWeight: "bold" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen
|
||||||
|
name="WorkoutList"
|
||||||
|
component={WorkoutListScreen}
|
||||||
|
options={{ title: "Workouts" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Builder"
|
||||||
|
component={BuilderScreen}
|
||||||
|
options={{ title: "Build Workout" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Templates"
|
||||||
|
component={TemplatesScreen}
|
||||||
|
options={{ title: "Templates" }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="PreSwim"
|
||||||
|
component={PreSwimScreen}
|
||||||
|
options={{ title: "Start Swim" }}
|
||||||
|
/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
|
||||||
|
export type RootStackParamList = {
|
||||||
|
Main: undefined;
|
||||||
|
WorkoutBuilder: { templateId?: string } | undefined;
|
||||||
|
PreSwim: { workoutId: string };
|
||||||
|
PostSwim: { workoutId: string };
|
||||||
|
SessionDetail: { sessionId: string };
|
||||||
|
Stats: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MainTabParamList = {
|
||||||
|
Dashboard: undefined;
|
||||||
|
Workouts: undefined;
|
||||||
|
History: undefined;
|
||||||
|
Settings: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkoutStackParamList = {
|
||||||
|
WorkoutList: undefined;
|
||||||
|
Builder: { templateId?: string } | undefined;
|
||||||
|
Templates: undefined;
|
||||||
|
PreSwim: { workoutId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HistoryStackParamList = {
|
||||||
|
HistoryList: undefined;
|
||||||
|
SessionDetail: { sessionId: string };
|
||||||
|
Stats: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RootStackProps<T extends keyof RootStackParamList> =
|
||||||
|
NativeStackScreenProps<RootStackParamList, T>;
|
||||||
|
|
||||||
|
export type WorkoutStackProps<T extends keyof WorkoutStackParamList> =
|
||||||
|
NativeStackScreenProps<WorkoutStackParamList, T>;
|
||||||
|
|
||||||
|
export type HistoryStackProps<T extends keyof HistoryStackParamList> =
|
||||||
|
NativeStackScreenProps<HistoryStackParamList, T>;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { SQLiteProvider } from "expo-sqlite";
|
||||||
|
import { DATABASE_NAME } from "../db/client";
|
||||||
|
|
||||||
|
export function DatabaseProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SQLiteProvider databaseName={DATABASE_NAME} useSuspense>
|
||||||
|
{children}
|
||||||
|
</SQLiteProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
|
const storage = new MMKV();
|
||||||
|
|
||||||
|
type SettingsContextType = {
|
||||||
|
poolLength: number;
|
||||||
|
setPoolLength: (length: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SettingsContext = createContext<SettingsContextType>({
|
||||||
|
poolLength: 25,
|
||||||
|
setPoolLength: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return useContext(SettingsContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [poolLength, setPoolLengthState] = useState<number>(() => {
|
||||||
|
const stored = storage.getNumber("poolLength");
|
||||||
|
return stored ?? 25;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPoolLength = (length: number) => {
|
||||||
|
setPoolLengthState(length);
|
||||||
|
storage.set("poolLength", length);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ poolLength, setPoolLength }}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type WorkoutSource = "watch" | "manual";
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type StrokeType = "Freestyle" | "Backstroke" | "Breaststroke" | "Butterfly" | "Mixed";
|
||||||
|
|
||||||
|
export type RestType = "fixed_rest" | "leave_time" | "none";
|
||||||
|
|
||||||
|
export type WorkoutSet = {
|
||||||
|
stroke: StrokeType;
|
||||||
|
distancePerRep: number;
|
||||||
|
repCount: number;
|
||||||
|
restType: RestType;
|
||||||
|
restDuration: number;
|
||||||
|
targetTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkoutPlan = {
|
||||||
|
name?: string;
|
||||||
|
poolLength: number;
|
||||||
|
sets: WorkoutSet[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LapData = {
|
||||||
|
setPosition: number;
|
||||||
|
repNumber: number;
|
||||||
|
distance: number;
|
||||||
|
duration: number;
|
||||||
|
strokeCount?: number;
|
||||||
|
heartRate?: number;
|
||||||
|
swolf?: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { type WorkoutSet } from "@types/workout";
|
||||||
|
|
||||||
|
export function calculateTotalDistance(sets: WorkoutSet[]): number {
|
||||||
|
return sets.reduce((acc, set) => acc + set.distancePerRep * set.repCount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTotalDuration(sets: WorkoutSet[]): number {
|
||||||
|
return sets.reduce((acc, set) => {
|
||||||
|
const swimTime = set.targetTime
|
||||||
|
? set.targetTime * set.repCount
|
||||||
|
: 0;
|
||||||
|
const restTime = set.restType === "none" ? 0 : set.restDuration * (set.repCount - 1);
|
||||||
|
return acc + swimTime + restTime;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function calculateSwolf(strokeCount: number, durationSeconds: number): number {
|
||||||
|
return strokeCount + Math.round(durationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateAvgSwolf(
|
||||||
|
laps: { strokeCount?: number; duration: number }[]
|
||||||
|
): number {
|
||||||
|
const validLaps = laps.filter((l) => l.strokeCount != null);
|
||||||
|
if (validLaps.length === 0) return 0;
|
||||||
|
const total = validLaps.reduce(
|
||||||
|
(acc, l) => acc + calculateSwolf(l.strokeCount!, l.duration),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return Math.round(total / validLaps.length);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function formatDistance(meters: number): string {
|
||||||
|
if (meters >= 1000) {
|
||||||
|
return `${(meters / 1000).toFixed(1)}km`;
|
||||||
|
}
|
||||||
|
return `${meters}m`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function formatTime(seconds: number): string {
|
||||||
|
const hrs = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${hrs}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPace(distanceMeters: number, durationSeconds: number): string {
|
||||||
|
if (distanceMeters === 0) return "--:--";
|
||||||
|
const paceSeconds = (durationSeconds / distanceMeters) * 100;
|
||||||
|
const mins = Math.floor(paceSeconds / 60);
|
||||||
|
const secs = Math.floor(paceSeconds % 60);
|
||||||
|
return `${mins}:${String(secs).padStart(2, "0")}/100m`;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "App-Icon-1024x1024@1x.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"size": "1024x1024",
|
||||||
|
"platform": "watchos"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"version": 1,
|
||||||
|
"author": "expo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
class PhoneConnector: NSObject, ObservableObject, WCSessionDelegate {
|
||||||
|
static let shared = PhoneConnector()
|
||||||
|
private var workoutManager: WorkoutManager?
|
||||||
|
|
||||||
|
private override init() {
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setup(workoutManager: WorkoutManager) {
|
||||||
|
self.workoutManager = workoutManager
|
||||||
|
if WCSession.isSupported() {
|
||||||
|
let session = WCSession.default
|
||||||
|
session.delegate = self
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send completed workout data to phone
|
||||||
|
static func sendCompletedWorkout(_ laps: [[String: Any]], duration: TimeInterval, workoutId: String) {
|
||||||
|
let message: [String: Any] = [
|
||||||
|
"type": "workoutCompleted",
|
||||||
|
"workoutId": workoutId,
|
||||||
|
"laps": laps,
|
||||||
|
"duration": duration,
|
||||||
|
]
|
||||||
|
|
||||||
|
if WCSession.default.isReachable {
|
||||||
|
WCSession.default.sendMessage(message, replyHandler: nil) { error in
|
||||||
|
print("Failed to send: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WCSession.default.transferUserInfo(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WCSessionDelegate
|
||||||
|
|
||||||
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||||
|
print("WatchConnectivity activated: \(activationState.rawValue)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.handleMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.handleMessage(userInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleMessage(_ message: [String: Any]) {
|
||||||
|
guard let type = message["type"] as? String else { return }
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case "workoutPlan":
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: message),
|
||||||
|
let workout = try? JSONDecoder().decode(StoredWorkout.self, from: data) {
|
||||||
|
workoutManager?.addWorkoutFromPhone(workout)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@StateObject private var workoutManager = WorkoutManager()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if workoutManager.isWorkoutActive {
|
||||||
|
ActiveWorkoutView(workoutManager: workoutManager)
|
||||||
|
} else {
|
||||||
|
WorkoutListView(workoutManager: workoutManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("SwimBuddy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkoutListView: View {
|
||||||
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
if workoutManager.savedWorkouts.isEmpty {
|
||||||
|
Section {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "figure.pool.swim")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.cyan)
|
||||||
|
Text("No workouts yet")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Create a workout on your iPhone to get started")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section("Saved Workouts") {
|
||||||
|
ForEach(workoutManager.savedWorkouts) { (workout: StoredWorkout) in
|
||||||
|
Button {
|
||||||
|
workoutManager.selectWorkout(workout)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(workout.name ?? "Workout")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("\(workout.totalDistance)m · \(workout.sets.count) sets")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if workoutManager.completedWorkouts.count > 0 {
|
||||||
|
Section("Recent") {
|
||||||
|
ForEach(workoutManager.completedWorkouts.prefix(3)) { workout in
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(workout.name ?? "Workout")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text(workout.completedAt ?? "")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.grouped)
|
||||||
|
.onAppear {
|
||||||
|
workoutManager.loadSavedWorkouts()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict/>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ActiveWorkoutView: View {
|
||||||
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if workoutManager.isResting {
|
||||||
|
RestView(workoutManager: workoutManager)
|
||||||
|
} else {
|
||||||
|
SwimView(workoutManager: workoutManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SwimView: View {
|
||||||
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
if let workout = workoutManager.selectedWorkout,
|
||||||
|
workoutManager.currentSetIndex < workout.sets.count {
|
||||||
|
let currentSet = workout.sets[workoutManager.currentSetIndex]
|
||||||
|
|
||||||
|
Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(currentSet.stroke)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.cyan)
|
||||||
|
|
||||||
|
Text("Rep \(workoutManager.currentRep + 1)/\(currentSet.repCount)")
|
||||||
|
.font(.subheadline)
|
||||||
|
|
||||||
|
Text("\(currentSet.distancePerRep)m")
|
||||||
|
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.cyan)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(formatTime(workoutManager.elapsedSeconds))
|
||||||
|
.font(.system(.title, design: .monospaced))
|
||||||
|
|
||||||
|
if workoutManager.heartRate > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "heart.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("\(Int(workoutManager.heartRate))")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
workoutManager.completeLap()
|
||||||
|
} label: {
|
||||||
|
Text("Complete Lap")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.cyan)
|
||||||
|
|
||||||
|
Button("End") {
|
||||||
|
workoutManager.endWorkout()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||||
|
let mins = Int(seconds) / 60
|
||||||
|
let secs = Int(seconds) % 60
|
||||||
|
return String(format: "%d:%02d", mins, secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RestView: View {
|
||||||
|
@ObservedObject var workoutManager: WorkoutManager
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Text("Rest")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text(formatTime(workoutManager.restTimeRemaining))
|
||||||
|
.font(.system(size: 44, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundColor(.cyan)
|
||||||
|
|
||||||
|
if let workout = workoutManager.selectedWorkout,
|
||||||
|
workoutManager.currentSetIndex + 1 < workout.sets.count {
|
||||||
|
let nextSet = workout.sets[workoutManager.currentSetIndex + 1]
|
||||||
|
Text("Next: \(nextSet.stroke) \(nextSet.distancePerRep)m")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ seconds: TimeInterval) -> String {
|
||||||
|
let mins = Int(seconds) / 60
|
||||||
|
let secs = Int(seconds) % 60
|
||||||
|
return String(format: "%d:%02d", mins, secs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import HealthKit
|
||||||
|
import WatchKit
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
|
class WorkoutManager: ObservableObject {
|
||||||
|
@Published var isWorkoutActive = false
|
||||||
|
@Published var savedWorkouts: [StoredWorkout] = []
|
||||||
|
@Published var completedWorkouts: [StoredWorkout] = []
|
||||||
|
@Published var selectedWorkout: StoredWorkout?
|
||||||
|
@Published var currentSetIndex = 0
|
||||||
|
@Published var currentRep = 0
|
||||||
|
@Published var currentLapDistance = 0
|
||||||
|
@Published var elapsedSeconds: TimeInterval = 0
|
||||||
|
@Published var heartRate: Double = 0
|
||||||
|
@Published var isResting = false
|
||||||
|
@Published var restTimeRemaining: TimeInterval = 0
|
||||||
|
|
||||||
|
private var workoutTimer: Timer?
|
||||||
|
private var restTimer: Timer?
|
||||||
|
private var healthStore: HKHealthStore?
|
||||||
|
private var activeWorkout: HKWorkoutSession?
|
||||||
|
private var builder: HKWorkoutBuilder?
|
||||||
|
private var lapStartTime: Date?
|
||||||
|
private var completedLaps: [CompletedLap] = []
|
||||||
|
|
||||||
|
private let suiteName = "group.com.swimbuddy.app"
|
||||||
|
|
||||||
|
init() {
|
||||||
|
healthStore = HKHealthStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data Persistence
|
||||||
|
|
||||||
|
func loadSavedWorkouts() {
|
||||||
|
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
||||||
|
|
||||||
|
if let data = defaults.data(forKey: "savedWorkouts"),
|
||||||
|
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
|
||||||
|
savedWorkouts = workouts
|
||||||
|
}
|
||||||
|
|
||||||
|
if let data = defaults.data(forKey: "completedWorkouts"),
|
||||||
|
let workouts = try? JSONDecoder().decode([StoredWorkout].self, from: data) {
|
||||||
|
completedWorkouts = workouts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveWorkoutsToStorage() {
|
||||||
|
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
||||||
|
if let data = try? JSONEncoder().encode(savedWorkouts) {
|
||||||
|
defaults.set(data, forKey: "savedWorkouts")
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(completedWorkouts) {
|
||||||
|
defaults.set(data, forKey: "completedWorkouts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWorkoutFromPhone(_ plan: StoredWorkout) {
|
||||||
|
savedWorkouts.append(plan)
|
||||||
|
saveWorkoutsToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout Selection
|
||||||
|
|
||||||
|
func selectWorkout(_ workout: StoredWorkout) {
|
||||||
|
selectedWorkout = workout
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout Control
|
||||||
|
|
||||||
|
func startWorkout() {
|
||||||
|
guard selectedWorkout != nil else { return }
|
||||||
|
|
||||||
|
isWorkoutActive = true
|
||||||
|
currentSetIndex = 0
|
||||||
|
currentRep = 0
|
||||||
|
elapsedSeconds = 0
|
||||||
|
completedLaps = []
|
||||||
|
|
||||||
|
startHealthKitWorkout()
|
||||||
|
startTimer()
|
||||||
|
WKInterfaceDevice.current().play(.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pauseWorkout() {
|
||||||
|
workoutTimer?.invalidate()
|
||||||
|
WKInterfaceDevice.current().play(.notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumeWorkout() {
|
||||||
|
startTimer()
|
||||||
|
WKInterfaceDevice.current().play(.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
func endWorkout() {
|
||||||
|
workoutTimer?.invalidate()
|
||||||
|
restTimer?.invalidate()
|
||||||
|
isWorkoutActive = false
|
||||||
|
|
||||||
|
endHealthKitWorkout()
|
||||||
|
syncCompletedWorkout()
|
||||||
|
WKInterfaceDevice.current().play(.success)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lap Tracking
|
||||||
|
|
||||||
|
func completeLap() {
|
||||||
|
guard let workout = selectedWorkout else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let duration = lapStartTime.map { now.timeIntervalSince($0) } ?? 0
|
||||||
|
|
||||||
|
let lap = CompletedLap(
|
||||||
|
setPosition: currentSetIndex + 1,
|
||||||
|
repNumber: currentRep + 1,
|
||||||
|
distance: workout.sets[currentSetIndex].distancePerRep,
|
||||||
|
duration: duration,
|
||||||
|
heartRate: Int(heartRate)
|
||||||
|
)
|
||||||
|
completedLaps.append(lap)
|
||||||
|
|
||||||
|
WKInterfaceDevice.current().play(.click)
|
||||||
|
|
||||||
|
advanceToNextRep()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func advanceToNextRep() {
|
||||||
|
guard let workout = selectedWorkout else { return }
|
||||||
|
let currentSet = workout.sets[currentSetIndex]
|
||||||
|
|
||||||
|
if currentRep + 1 < currentSet.repCount {
|
||||||
|
currentRep += 1
|
||||||
|
lapStartTime = Date()
|
||||||
|
} else if currentSetIndex + 1 < workout.sets.count {
|
||||||
|
startRestPeriod()
|
||||||
|
} else {
|
||||||
|
endWorkout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rest Period
|
||||||
|
|
||||||
|
private func startRestPeriod() {
|
||||||
|
guard let workout = selectedWorkout else { return }
|
||||||
|
let currentSet = workout.sets[currentSetIndex]
|
||||||
|
|
||||||
|
guard currentSet.restType != "none", currentSet.restDuration > 0 else {
|
||||||
|
moveToNextSet()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isResting = true
|
||||||
|
restTimeRemaining = currentSet.restDuration
|
||||||
|
|
||||||
|
WKInterfaceDevice.current().play(.notification)
|
||||||
|
|
||||||
|
restTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.restTimeRemaining -= 1
|
||||||
|
|
||||||
|
if self.restTimeRemaining <= 0 {
|
||||||
|
self.restTimer?.invalidate()
|
||||||
|
self.moveToNextSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moveToNextSet() {
|
||||||
|
isResting = false
|
||||||
|
currentSetIndex += 1
|
||||||
|
currentRep = 0
|
||||||
|
lapStartTime = Date()
|
||||||
|
|
||||||
|
if currentSetIndex >= (selectedWorkout?.sets.count ?? 0) {
|
||||||
|
endWorkout()
|
||||||
|
} else {
|
||||||
|
WKInterfaceDevice.current().play(.start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Timer
|
||||||
|
|
||||||
|
private func startTimer() {
|
||||||
|
lapStartTime = Date()
|
||||||
|
workoutTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||||
|
self?.elapsedSeconds += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HealthKit
|
||||||
|
|
||||||
|
private func startHealthKitWorkout() {
|
||||||
|
guard let healthStore = healthStore else { return }
|
||||||
|
|
||||||
|
let config = HKWorkoutConfiguration()
|
||||||
|
config.activityType = .swimming
|
||||||
|
config.locationType = .indoor
|
||||||
|
|
||||||
|
do {
|
||||||
|
activeWorkout = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
||||||
|
let workoutBuilder = HKWorkoutBuilder(healthStore: healthStore, configuration: config, device: nil)
|
||||||
|
self.builder = workoutBuilder
|
||||||
|
activeWorkout?.startActivity(with: Date())
|
||||||
|
workoutBuilder.beginCollection(withStart: Date()) { _, _ in }
|
||||||
|
} catch {
|
||||||
|
print("Failed to start HealthKit workout: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endHealthKitWorkout() {
|
||||||
|
activeWorkout?.end()
|
||||||
|
builder?.endCollection(withEnd: Date()) { [weak self] _, _ in
|
||||||
|
self?.builder?.finishWorkout { _, _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync to Phone
|
||||||
|
|
||||||
|
private func syncCompletedWorkout() {
|
||||||
|
let workoutSummary: [String: Any] = [
|
||||||
|
"type": "workoutCompleted",
|
||||||
|
"workoutId": selectedWorkout?.id ?? "",
|
||||||
|
"laps": completedLaps.map { lap in
|
||||||
|
[
|
||||||
|
"setPosition": lap.setPosition,
|
||||||
|
"repNumber": lap.repNumber,
|
||||||
|
"distance": lap.distance,
|
||||||
|
"duration": lap.duration,
|
||||||
|
"heartRate": lap.heartRate ?? 0,
|
||||||
|
] as [String: Any]
|
||||||
|
},
|
||||||
|
"duration": elapsedSeconds,
|
||||||
|
]
|
||||||
|
|
||||||
|
if let defaults = UserDefaults(suiteName: suiteName) {
|
||||||
|
var completed = completedWorkouts
|
||||||
|
var summary = selectedWorkout
|
||||||
|
summary?.completedAt = ISO8601DateFormatter().string(from: Date())
|
||||||
|
if let s = summary {
|
||||||
|
completed.insert(s, at: 0)
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(completed) {
|
||||||
|
defaults.set(data, forKey: "completedWorkouts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if WCSession.default.isReachable {
|
||||||
|
WCSession.default.sendMessage(workoutSummary, replyHandler: nil) { error in
|
||||||
|
print("Failed to sync to phone: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
WCSession.default.transferUserInfo(workoutSummary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
struct StoredWorkout: Codable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String?
|
||||||
|
let poolLength: Double
|
||||||
|
let sets: [StoredSet]
|
||||||
|
var completedAt: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoredSet: Codable {
|
||||||
|
let stroke: String
|
||||||
|
let distancePerRep: Int
|
||||||
|
let repCount: Int
|
||||||
|
let restType: String
|
||||||
|
let restDuration: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompletedLap {
|
||||||
|
let setPosition: Int
|
||||||
|
let repNumber: Int
|
||||||
|
let distance: Int
|
||||||
|
let duration: TimeInterval
|
||||||
|
let heartRate: Int?
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
|
||||||
|
module.exports = (config) => ({
|
||||||
|
type: "watch",
|
||||||
|
icon: "../../assets/icon.png",
|
||||||
|
deploymentTarget: "10.0",
|
||||||
|
entitlements: {
|
||||||
|
"com.apple.security.application-groups":
|
||||||
|
config.ios?.entitlements?.["com.apple.security.application-groups"] ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SwimTrackerWatchApp: App {
|
||||||
|
@StateObject private var workoutManager = WorkoutManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(workoutManager)
|
||||||
|
.onAppear {
|
||||||
|
PhoneConnector.shared.setup(workoutManager: workoutManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-2
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true
|
"strict": true,
|
||||||
}
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@features/*": ["./src/features/*"],
|
||||||
|
"@components/*": ["./src/components/*"],
|
||||||
|
"@hooks/*": ["./src/hooks/*"],
|
||||||
|
"@utils/*": ["./src/utils/*"],
|
||||||
|
"@constants/*": ["./src/constants/*"],
|
||||||
|
"@db/*": ["./src/db/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user