Compare commits

...
65 Commits
Author SHA1 Message Date
JustZoe101 f07667e1f7 fix: correct path aliases and type imports across codebase 2026-08-24 14:58:27 +02:00
JustZoe101 0f167108fc chore(deps): update expo and expo-related dependencies 2026-08-24 14:49:15 +02:00
JustZoe101 630416ee3c feat(wearos): add release signing via EAS credentials.json 2026-08-24 10:50:12 +02:00
JustZoe101 a94ebf3653 feat(wearos): add pause/resume workout, water lock, and swipeable workout pages 2026-08-24 10:24:24 +02:00
JustZoe101 31b2a115ec fix(localization): change Danish locale code from dk to da and add HealthKit usage descriptions 2026-08-18 14:23:23 +02:00
JustZoe101 1fcacb2eb6 chore(eas): add build channel definitions per profile 2026-08-18 12:45:49 +02:00
JustZoe101 6394954afe fix(metro): simplify Sentry config using getSentryExpoConfig 2026-08-18 12:42:28 +02:00
JustZoe101 682d0c7eea chore(deps): downgrade sentry, update expo, add expo-font and worklets 2026-08-18 11:54:47 +02:00
JustZoe101 9ded984543 fix(scripts): update watch target regex to match SwimBuddyWatch without space 2026-08-18 11:46:19 +02:00
JustZoe101 5e797c32e7 fix(watch): remove space from watch target name 2026-08-18 11:43:18 +02:00
JustZoe101 7e3b95be8e fix: correct watch target name and Sentry script section ordering 2026-08-18 11:32:37 +02:00
JustZoe101 122aa443aa chore(app): remove space from watch target name 2026-08-18 11:05:11 +02:00
JustZoe101 dffee4a5f4 build(app): add EAS build config for Apple Watch extension 2026-08-18 11:02:23 +02:00
JustZoe101 078cb84031 chore(app): remove EAS build experimental iOS app extensions config 2026-08-18 10:51:45 +02:00
JustZoe101 92dcc08d59 fix(watch): rename watch target to SwimBuddy Watch for Xcode compatibility 2026-08-18 10:34:00 +02:00
JustZoe101 cad7f80607 chore(build): add eas-build-pre-install script to upgrade npm 2026-08-18 10:18:20 +02:00
JustZoe101 8c8bb9125b chore(eas): add environment fields to build profiles 2026-08-18 09:52:08 +02:00
JustZoe101 43e2fac10f chore(app): add iOS InfoPlist config and watch HealthKit entitlements 2026-08-18 09:47:03 +02:00
JustZoe101 ca9259d5bd feat(watch): add workout deletion sync and optimize delete operations 2026-08-12 13:52:23 +02:00
JustZoe101 2096489fd1 fix(history,workout-builder): memoize async load functions with useCallback 2026-08-12 12:59:27 +02:00
JustZoe101 2bb4225260 refactor(db): separate workout plans from completed sessions 2026-08-12 11:35:16 +02:00
JustZoe101 f14c2e5827 fix(app): auto-hide splash screen on mount 2026-08-12 10:11:06 +02:00
JustZoe101 b648878414 fix(watch): advance rep based on distance swum instead of lap count 2026-08-12 09:19:54 +02:00
JustZoe101 debfcf3843 feat: add expo-splash-screen configuration 2026-08-11 17:00:41 +02:00
JustZoe101 522337d332 fix(workout-builder): use navigation namespace for screen title translation 2026-08-11 16:49:35 +02:00
JustZoe101 070429627b fix(workout-builder): defer TextInput updates to onBlur using local state 2026-08-11 16:48:01 +02:00
JustZoe101 7f2fe0c300 feat(wearos): track swimming stroke count during workouts 2026-08-11 16:35:02 +02:00
JustZoe101 42e2f5fd47 feat(watch): use HealthKit lap detection and track stroke count/swolf 2026-08-11 16:03:08 +02:00
JustZoe101 098db881bb feat(watch): add rest duration display in workout views 2026-08-09 18:42:21 +02:00
JustZoe101 a17ae0dea5 feat(watch): display water lock status indicator during workout 2026-08-09 18:33:23 +02:00
JustZoe101 383f641ac2 feat(watch): improve lap detection with CMDeviceMotion and low-pass filtering 2026-08-09 18:24:55 +02:00
JustZoe101 4a07556a5d refactor(navigation): wrap settings screen in stack navigator 2026-08-09 18:24:39 +02:00
JustZoe101 8f96a12b18 refactor(watch): remove .listRowBackground(.clear) modifiers 2026-08-09 18:19:38 +02:00
JustZoe101 1387d6bbdb feat(watch): add localizations for workout UI strings 2026-08-09 18:03:36 +02:00
JustZoe101 8512bee27a fix(watch): remove default list row backgrounds for cleaner UI 2026-08-09 17:57:58 +02:00
JustZoe101 0979af288d feat(watch): refactor active workout into paged TabView with overview and controls 2026-08-09 17:53:39 +02:00
JustZoe101 acdf66bd84 feat(watch): add HealthKit workout tracking with authorization and live data 2026-08-09 17:45:03 +02:00
JustZoe101 2333ba7caa fix(builder): remove duplicate closing View tag 2026-08-09 13:37:25 +02:00
JustZoe101 9107a64595 fix(workout-builder): handle keyboard overlap on builder screen 2026-08-09 12:53:12 +02:00
JustZoe101 df3030ae72 refactor(components): simplify SwipeableRow gesture handling and add vertical scroll conflict prevention 2026-08-08 23:43:34 +02:00
JustZoe101 819d26e0f6 feat(workout-summary): add lengths, avg pace, and per-set pool pace stats; fix swipe direction 2026-08-08 23:39:21 +02:00
JustZoe101 eeb3069750 feat(app): wrap root in GestureHandlerRootView for gesture support 2026-08-08 23:27:58 +02:00
JustZoe101 284fd22c13 chore(watch): automate SentrySPM integration via post-prebuild script 2026-08-08 23:21:55 +02:00
JustZoe101 d6543cd8ca chore(watch): add version field to Localizable.xcstrings 2026-08-08 22:58:43 +02:00
JustZoe101 6802441553 feat(settings): display native app version using expo-application 2026-08-08 22:52:40 +02:00
JustZoe101 9e4777e0b9 fix(wearos): align applicationId with iOS bundle identifier 2026-08-08 22:29:54 +02:00
JustZoe101 f09f2b2f6e chore(build): update Gradle wrapper files 2026-08-08 17:04:51 +02:00
JustZoe101 3d4ee831c4 chore: update build config, Sentry setup, and WearOS app ID 2026-08-08 15:43:42 +02:00
JustZoe101 7ae2711e4b feat: add Sentry error tracking and Expo Updates 2026-08-08 15:10:12 +02:00
JustZoe101 0aa61ab7e8 chore(assets): update Android and app icon assets 2026-08-08 14:35:30 +02:00
JustZoe101 20e82870fa docs: add project README with setup and architecture overview 2026-08-08 12:56:28 +02:00
JustZoe101 3cbe65442f docs: add WearOS target documentation to AGENTS.md 2026-08-08 12:55:07 +02:00
JustZoe101 de90d9b937 chore(scripts): add WearOS build and launcher icon scripts 2026-08-08 12:52:21 +02:00
JustZoe101 065a564e1c fix(wear): improve client initialization, consolidate health callbacks, and reset workout state 2026-08-08 12:42:06 +02:00
JustZoe101 262d7d7fe4 feat(active-workout): require paired Watch to start workout 2026-08-08 12:30:07 +02:00
JustZoe101 20e6e73c81 refactor(workout): extract shared WorkoutSummary component from PostSwim and SessionDetail screens 2026-08-08 12:27:59 +02:00
JustZoe101 38082afdb9 fix(watch): handle missing workout and remove conflicting delegate 2026-08-08 12:19:57 +02:00
JustZoe101 f3b3a8b944 feat(watch): add workout summary screen with HealthKit data 2026-08-08 12:05:32 +02:00
JustZoe101 831ec6b836 feat(watch-connectivity): add WearOS support alongside Apple Watch 2026-08-08 12:00:02 +02:00
JustZoe101 2211b8ebe4 build(eas): add EAS build and submit configuration 2026-08-08 10:16:59 +02:00
JustZoe101 9946e78858 feat(i18n): add internationalization with i18next and expo-localization 2026-08-08 10:09:30 +02:00
JustZoe101 408a2fa10c chore(eas): add watch extension config and owner 2026-08-07 22:23:59 +02:00
JustZoe101 c14ccf1d2c feat(workouts): add swipe-to-delete with gesture handler and pull-to-refresh 2026-08-07 16:55:18 +02:00
JustZoe101 48286041c6 feat(watch): add lap detection via accelerometer and workout sync to phone 2026-08-07 16:35:20 +02:00
JustZoe101 00a8220096 fix(watch-connectivity): improve WCSession lifecycle and message reliability 2026-08-07 15:32:40 +02:00
173 changed files with 11195 additions and 1189 deletions
+8
View File
@@ -0,0 +1,8 @@
# Sentry
SENTRY_DSN=https://6197bcf085d63727d829aa89ede8e973@o4507430203293696.ingest.de.sentry.io/4511875297706064
SENTRY_ORG=your-org-slug
SENTRY_PROJECT=swimbuddy
SENTRY_AUTH_TOKEN=your-auth-token
# Firebase App Distribution (WearOS)
FIREBASE_WEAR_APP_ID=1:404955511024:android:3fac1d3a237f1791b96a35
+11 -1
View File
@@ -18,6 +18,9 @@ expo-env.d.ts
*.key
*.mobileprovision
# Firebase
google-services.json
# Metro
.metro-health-check*
@@ -31,7 +34,12 @@ yarn-error.*
*.pem
# local env files
.env*.local
.env
.env.local
.env.*.local
# Sentry
sentry.properties
# typescript
*.tsbuildinfo
@@ -39,3 +47,5 @@ yarn-error.*
# generated native folders
/ios
/android
/google-services.json
+51 -6
View File
@@ -1,6 +1,6 @@
# SwimBuddy
Swimming workout tracker with Apple Watch companion app. Expo 57 + React Native 0.86 + NativeWind v5 + Drizzle ORM + Swift Watch target.
Swimming workout tracker with Apple Watch and WearOS companion apps. Expo 57 + React Native 0.86 + NativeWind v5 + Drizzle ORM + Swift Watch target + Kotlin WearOS target.
## Commands
@@ -8,9 +8,17 @@ Swimming workout tracker with Apple Watch companion app. Expo 57 + React Native
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 expo prebuild -p ios && node scripts/add-sentry-watch.js # Prebuild + add SentrySPM to watch target
npx drizzle-kit generate # Regenerate SQL migrations after schema changes
```
**WearOS:**
```bash
./scripts/build-wearos.sh debug # Build debug APK (reads version from app.json)
./scripts/build-wearos.sh release # Build release APK (reads version from app.json)
./scripts/generate-wearos-icon.sh assets/icon.png # Generate launcher icons
```
No `npm test` or lint scripts exist yet. No `babel.config.js` — NativeWind v5 uses CSS-first config.
## Project Structure
@@ -25,11 +33,13 @@ src/
navigation/ # React Navigation (RootNav, TabNav, feature stacks)
db/ # Drizzle schema, client, relations, operations
providers/ # DatabaseProvider, SettingsProvider (MMKV)
utils/ # formatTime, calculateSwolf, etc.
utils/ # formatTime, calculateSwolf, uuid, etc.
constants/ # strokes, poolLengths, colors
types/ # workout.ts, common.ts
modules/watch-connectivity/# Expo native module bridging WCSession
modules/watch-connectivity/ # Expo native module bridging WCSession (Apple Watch)
modules/wear-connectivity/ # Expo native module bridging Wear Data Layer (WearOS)
targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives prebuild
targets/wearos/ # WearOS app (Kotlin/Compose) — survives prebuild
```
## Key Conventions
@@ -40,25 +50,59 @@ targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives preb
- **`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
## Watch Target (Apple Watch)
- 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
- Uses App Groups (`group.nl.guido-it.swimbuddy`) for data sharing
- 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`
- SentrySPM: `@bacons/apple-targets` doesn't support `swiftPackages` yet. Run `node scripts/add-sentry-watch.js` after `expo prebuild` to add it automatically. For EAS builds, this runs automatically via the `eas-build-post-install` hook in package.json.
## WearOS Target
- Lives in `targets/wearos/` — survives `expo prebuild --clean`
- Kotlin + Jetpack Compose with Wear OS Compose libraries
- Build: `./scripts/build-wearos.sh` or open `targets/wearos/` in Android Studio
- Uses Wear Data Layer API for phone↔watch communication
- Health Services Client for swim workout tracking
- Namespace: `nl.guidoit.swimbuddy`
## Database
- **expo-sqlite** + **Drizzle ORM** (SQLite dialect, expo driver)
- Schema: `src/db/schema.ts` — 4 tables: templates, workouts, sets, laps
- Schema: `src/db/schema.ts` — tables: templates, workouts, workoutSessions, sets, laps (laps references workoutSessions, not workouts)
- Relations: `src/db/relations.ts`
- CRUD: `src/db/operations.ts`
- Client: `src/db/client.ts` — exports `db` and `DATABASE_NAME`
- Migrations in `drizzle/` — generated by `npx drizzle-kit generate`
## WCSession Gotchas (Critical)
These are hard-won bugs that will waste hours if rediscovered:
- **`NSNull` crashes `sendMessage`**: JS `null` becomes `NSNull` in ObjC, causing `WCErrorCodePayloadUnsupportedTypes` (Code 7010). Always strip `null`/`undefined` values before sending via `cleanForWCSession()`.
- **Two delegate methods conflict**: Having both `session(_:didReceiveMessage:)` AND `session(_:didReceiveMessage:replyHandler:)` on the same delegate causes iOS to deliver neither. Watch uses `didReceiveMessage` (no replyHandler); phone uses `didReceiveMessage:replyHandler:` for bidirectional sync.
- **`isReachable` is unreliable**: Only `true` when both apps are foreground. Check `session.activationState == .activated` instead for connection readiness.
- **Phone `sendMessage` must use `replyHandler: nil`**: Otherwise JS thread blocks waiting for a reply that watch never sends. Use fire-and-forget for workout plans; use `transferUserInfo` for data sync.
- **Watch `suiteName` vs `UserDefaults.standard`**: Watch only needs local storage, so use `UserDefaults.standard` — the App Groups suite name is only needed for phone↔watch shared data.
- **Phone `pendingEvents` polling**: Phone polls `pollEvents()` every 2s because Expo Modules' `sendEvent` API doesn't work on `AppContext`. Watch data arrives via `didReceiveUserInfo` delegate → stored in `pendingEvents` array.
## Watch ↔ Phone Data Flow
- **Phone → Watch**: `sendMessage` with workout plan (fire-and-forget, promise resolves immediately with `["status":"sent"]`)
- **Watch → Phone**: `transferUserInfo` with completed workout data (reliable, works in background)
- **Phone receives**: `pollEvents()` called from JS every 2s, `saveWatchCompletedWorkout()` writes to SQLite
## UI Conventions
- Swipe-to-delete on lists uses `SwipeableRow` component (`react-native-gesture-handler`)
- BuilderScreen pool length is a 25m/50m toggle (not read-only from settings)
- Templates and Stats features removed from navigation (DB schema preserved but UI deleted)
- Ionicons used for bottom tab icons (`@expo/vector-icons`)
## Gotchas
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
@@ -66,6 +110,7 @@ targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives preb
- 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
- WearOS uses same `applicationId` as iOS bundle identifier — don't change without updating both
## Expo Docs
+36 -1
View File
@@ -1,19 +1,54 @@
import "./global.css";
import "./src/i18n";
import { useEffect } from "react";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { NavigationContainer } from "@react-navigation/native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { RootNavigator } from "./src/navigation/RootNavigator";
import { DatabaseProvider } from "./src/providers/DatabaseProvider";
import { SettingsProvider } from "./src/providers/WatchProvider";
import { useWatchWorkoutSync } from "./src/features/watch-connectivity/hooks/useWatchWorkoutSync";
import { useSystemLanguageListener } from "./src/i18n";
import * as Sentry from "@sentry/react-native";
SplashScreen.preventAutoHideAsync();
Sentry.init({
dsn: "https://6197bcf085d63727d829aa89ede8e973@o4507430203293696.ingest.de.sentry.io/4511875297706064",
tracesSampleRate: 1.0,
environment: __DEV__ ? "development" : "production",
});
function WatchSync() {
useWatchWorkoutSync();
return null;
}
function SystemLanguageListener() {
useSystemLanguageListener();
return null;
}
function App() {
useEffect(() => {
SplashScreen.hideAsync();
}, []);
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<DatabaseProvider>
<SettingsProvider>
<NavigationContainer>
<SystemLanguageListener />
<WatchSync />
<RootNavigator />
<StatusBar style="light" />
</NavigationContainer>
</SettingsProvider>
</DatabaseProvider>
</GestureHandlerRootView>
);
}
export default Sentry.wrap(App);
+162
View File
@@ -0,0 +1,162 @@
# SwimBuddy
Swimming workout tracker with Apple Watch and WearOS companion apps.
## Features
- Create and manage swimming workout templates
- Track workouts with detailed set/rep/lap data
- Apple Watch companion for real-time workout tracking
- WearOS companion for Android smartwatches
- HealthKit integration (iOS) and Health Services (WearOS)
- Multi-language support (EN, NL, DE, FR, IT, ES, PL, PT, DK)
- SWOLF score calculation
- Workout history and statistics
## Tech Stack
- **Framework**: Expo 57 + React Native 0.86
- **Styling**: NativeWind v5 (TailwindCSS for React Native)
- **Database**: expo-sqlite + Drizzle ORM
- **Navigation**: React Navigation 7
- **State**: react-native-mmkv (settings), SQLite (workouts)
- **Watch**: Swift/SwiftUI (Apple Watch), Kotlin/Compose (WearOS)
## Prerequisites
- Node.js 18+
- Expo CLI (`npm install -g expo-cli`)
- Xcode 15+ (for iOS/Watch development)
- Android Studio (for WearOS development)
- CocoaPods (for iOS)
## Installation
```bash
# Install dependencies
npm install
# Generate database migrations
npm run db:generate
# Start development server
npm start
```
## Development
```bash
# Start Expo dev server
npm start
# Run on iOS
npm run ios
# Run on Android
npm run android
```
## Building
### iOS & Apple Watch
```bash
# Build for iOS
npx expo run:ios
# Open in Xcode for Watch target
open ios/SwimBuddy.xcworkspace
```
### WearOS
```bash
# Build debug APK
./scripts/build-wearos.sh debug
# Build release APK
./scripts/build-wearos.sh release
# Generate launcher icons
./scripts/generate-wearos-icon.sh assets/icon.png
```
## Project Structure
```
src/
├── components/ # Shared UI primitives
├── config/ # App configuration
├── constants/ # Stroke types, pool lengths, colors
├── db/ # Drizzle schema, migrations, operations
├── features/
│ ├── active-workout/ # Live workout tracking
│ ├── history/ # Workout history
│ ├── settings/ # App settings
│ ├── templates/ # Workout template management
│ └── workout-builder/ # Create/edit workout templates
├── hooks/ # Shared React hooks
├── i18n/ # Translations (en, nl, de, fr, it, es, pl, pt, dk)
├── modules/
│ ├── watch-connectivity/ # Apple Watch WCSession bridge
│ └── wear-connectivity/ # WearOS Data Layer bridge
├── navigation/ # React Navigation setup
├── providers/ # DatabaseProvider, SettingsProvider
├── types/ # TypeScript types
└── utils/ # Utility functions
targets/
├── watch/ # Apple Watch app (Swift/SwiftUI)
└── wearos/ # WearOS app (Kotlin/Compose)
```
## Database
The app uses SQLite via Drizzle ORM with the following tables:
- `templates` - Workout templates with JSON data
- `workouts` - Completed workout sessions
- `sets` - Individual sets within workouts
- `laps` - Lap data for each set
### Schema Changes
After modifying `src/db/schema.ts`, regenerate migrations:
```bash
npm run db:generate
```
## Watch Integration
### Apple Watch
- Uses WCSession for phone↔watch communication
- HealthKit for swim workout tracking
- App Groups for data sharing between iPhone and Watch
- Real-time sync during workouts
### WearOS
- Uses Wear Data Layer API for communication
- Health Services for swim tracking
- Jetpack Compose with Wear OS Compose libraries
## Internationalization
Supported languages:
- English (en)
- Dutch (nl)
- German (de)
- French (fr)
- Italian (it)
- Spanish (es)
- Polish (pl)
- Portuguese (pt)
- Danish (dk)
Translation files are in `src/i18n/{locale}/`.
## License
MIT
+81 -2
View File
@@ -6,6 +6,12 @@
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"runtimeVersion": "1.0.0",
"updates": {
"url": "https://u.expo.dev/7b88af8c-86a3-4c08-b186-09a90e2bdfcc",
"fallbackToCacheTimeout": 0,
"checkAutomatically": "ON_LOAD"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "nl.guido-it.swimbuddy",
@@ -15,7 +21,23 @@
],
"com.apple.developer.healthkit": true
},
"appleTeamId": "7TABQZ5458"
"appleTeamId": "7TABQZ5458",
"infoPlist": {
"CFBundleLocalizations": [
"en",
"nl",
"de",
"fr",
"it",
"es",
"pl",
"pt",
"da"
],
"ITSAppUsesNonExemptEncryption": false,
"NSHealthShareUsageDescription": "SwimBuddy needs access to your health data to track your swim workouts.",
"NSHealthUpdateUsageDescription": "SwimBuddy needs to save your swim workout data to Health."
}
},
"android": {
"adaptiveIcon": {
@@ -31,6 +53,63 @@
"favicon": "./assets/favicon.png",
"bundler": "metro"
},
"plugins": ["expo-sqlite", "@bacons/apple-targets"]
"plugins": [
"expo-sqlite",
"@bacons/apple-targets",
[
"expo-localization",
{
"supportedLocales": [
"en",
"nl",
"de",
"fr",
"it",
"es",
"pl",
"pt",
"da"
]
}
],
"@sentry/react-native/expo",
"expo-updates",
"expo-system-ui",
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#5BC0F8"
}
],
"@sentry/react-native",
"expo-font"
],
"extra": {
"eas": {
"projectId": "7b88af8c-86a3-4c08-b186-09a90e2bdfcc",
"build": {
"experimental": {
"ios": {
"appExtensions": [
{
"bundleIdentifier": "nl.guido-it.swimbuddy.watch",
"targetName": "SwimBuddyWatch",
"entitlements": {
"com.apple.security.application-groups": [
"group.nl.guido-it.swimbuddy"
],
"com.apple.developer.healthkit": true,
"com.apple.developer.healthkit.access": []
}
}
]
}
}
}
}
},
"owner": "guido-it"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 53 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE `workout_sessions` (
`id` text PRIMARY KEY NOT NULL,
`workout_id` text,
`name` text,
`pool_length` real DEFAULT 25 NOT NULL,
`started_at` text NOT NULL,
`finished_at` text,
`total_distance` integer,
`total_duration` real,
`calories` real,
`avg_heart_rate` integer,
`source` text DEFAULT 'manual' NOT NULL,
FOREIGN KEY (`workout_id`) REFERENCES `workouts`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `workout_sessions` (`id`, `workout_id`, `name`, `pool_length`, `started_at`, `finished_at`, `total_distance`, `total_duration`, `calories`, `avg_heart_rate`, `source`)
SELECT `id`, `id`, `name`, `pool_length`, `started_at`, `finished_at`, `total_distance`, `total_duration`, `calories`, `avg_heart_rate`, `source`
FROM `workouts`
WHERE `finished_at` IS NOT NULL;
--> statement-breakpoint
ALTER TABLE `laps` RENAME COLUMN `workout_id` TO `session_id`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `started_at`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `finished_at`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `total_distance`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `total_duration`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `calories`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `avg_heart_rate`;
--> statement-breakpoint
ALTER TABLE `workouts` DROP COLUMN `source`;
+36
View File
@@ -0,0 +1,36 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_laps` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`set_position` integer NOT NULL,
`rep_number` integer NOT NULL,
`distance` integer NOT NULL,
`duration` real NOT NULL,
`stroke_count` integer,
`heart_rate` integer,
`swolf` real,
FOREIGN KEY (`session_id`) REFERENCES `workout_sessions`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_laps`("id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf") SELECT "id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf" FROM `laps`;--> statement-breakpoint
DROP TABLE `laps`;--> statement-breakpoint
ALTER TABLE `__new_laps` RENAME TO `laps`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_workout_sessions` (
`id` text PRIMARY KEY NOT NULL,
`workout_id` text,
`name` text,
`pool_length` real DEFAULT 25 NOT NULL,
`started_at` text NOT NULL,
`finished_at` text,
`total_distance` integer,
`total_duration` real,
`calories` real,
`avg_heart_rate` integer,
`source` text DEFAULT 'manual' NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_workout_sessions`("id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source") SELECT "id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source" FROM `workout_sessions`;--> statement-breakpoint
DROP TABLE `workout_sessions`;--> statement-breakpoint
ALTER TABLE `__new_workout_sessions` RENAME TO `workout_sessions`;--> statement-breakpoint
ALTER TABLE `workouts` ADD `created_at` text NOT NULL;
+397
View File
@@ -0,0 +1,397 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1a621e4a-ea8a-4479-a98c-0f02fc8e14e3",
"prevId": "a9688cc9-9314-4d59-9b68-b7e202f3e3ff",
"tables": {
"laps": {
"name": "laps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"set_position": {
"name": "set_position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_number": {
"name": "rep_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance": {
"name": "distance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke_count": {
"name": "stroke_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"heart_rate": {
"name": "heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"swolf": {
"name": "swolf",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"laps_workout_id_workouts_id_fk": {
"name": "laps_session_id_workout_sessions_id_fk",
"tableFrom": "laps",
"tableTo": "workout_sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sets": {
"name": "sets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke": {
"name": "stroke",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance_per_rep": {
"name": "distance_per_rep",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_count": {
"name": "rep_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rest_type": {
"name": "rest_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed_rest'"
},
"rest_duration": {
"name": "rest_duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"target_time": {
"name": "target_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sets_workout_id_workouts_id_fk": {
"name": "sets_workout_id_workouts_id_fk",
"tableFrom": "sets",
"tableTo": "workouts",
"columnsFrom": [
"workout_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"templates": {
"name": "templates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"json_data": {
"name": "json_data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workouts": {
"name": "workouts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_id": {
"name": "template_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workout_sessions": {
"name": "workout_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_distance": {
"name": "total_distance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_duration": {
"name": "total_duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calories": {
"name": "calories",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avg_heart_rate": {
"name": "avg_heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'manual'"
}
},
"indexes": {},
"foreignKeys": {
"workout_sessions_workout_id_workouts_id_fk": {
"name": "workout_sessions_workout_id_workouts_id_fk",
"tableFrom": "workout_sessions",
"tableTo": "workouts",
"columnsFrom": [
"workout_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3e3e9dde-1e8c-4a6a-94ac-b5a750829d2b",
"prevId": "1a621e4a-ea8a-4479-a98c-0f02fc8e14e3",
"tables": {
"laps": {
"name": "laps",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"set_position": {
"name": "set_position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_number": {
"name": "rep_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance": {
"name": "distance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke_count": {
"name": "stroke_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"heart_rate": {
"name": "heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"swolf": {
"name": "swolf",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"laps_session_id_workout_sessions_id_fk": {
"name": "laps_session_id_workout_sessions_id_fk",
"tableFrom": "laps",
"tableTo": "workout_sessions",
"columnsFrom": [
"session_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sets": {
"name": "sets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"stroke": {
"name": "stroke",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"distance_per_rep": {
"name": "distance_per_rep",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rep_count": {
"name": "rep_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rest_type": {
"name": "rest_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed_rest'"
},
"rest_duration": {
"name": "rest_duration",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"target_time": {
"name": "target_time",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sets_workout_id_workouts_id_fk": {
"name": "sets_workout_id_workouts_id_fk",
"tableFrom": "sets",
"tableTo": "workouts",
"columnsFrom": [
"workout_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"templates": {
"name": "templates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"json_data": {
"name": "json_data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workout_sessions": {
"name": "workout_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"workout_id": {
"name": "workout_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"started_at": {
"name": "started_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_distance": {
"name": "total_distance",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_duration": {
"name": "total_duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"calories": {
"name": "calories",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avg_heart_rate": {
"name": "avg_heart_rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'manual'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"workouts": {
"name": "workouts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_id": {
"name": "template_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pool_length": {
"name": "pool_length",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 25
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+14
View File
@@ -8,6 +8,20 @@
"when": 1785854381700,
"tag": "0000_mushy_groot",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1785940781700,
"tag": "0001_workout_sessions",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1786525398056,
"tag": "0002_gifted_annihilus",
"breakpoints": true
}
]
}
+80 -1
View File
@@ -54,9 +54,88 @@ CREATE TABLE \`workouts\` (
);
`;
const m0001 = `
CREATE TABLE \`workout_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`workout_id\` text,
\`name\` text,
\`pool_length\` real DEFAULT 25 NOT NULL,
\`started_at\` text NOT NULL,
\`finished_at\` text,
\`total_distance\` integer,
\`total_duration\` real,
\`calories\` real,
\`avg_heart_rate\` integer,
\`source\` text DEFAULT 'manual' NOT NULL,
FOREIGN KEY (\`workout_id\`) REFERENCES \`workouts\`(\`id\`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO \`workout_sessions\` (\`id\`, \`workout_id\`, \`name\`, \`pool_length\`, \`started_at\`, \`finished_at\`, \`total_distance\`, \`total_duration\`, \`calories\`, \`avg_heart_rate\`, \`source\`)
SELECT \`id\`, \`id\`, \`name\`, \`pool_length\`, \`started_at\`, \`finished_at\`, \`total_distance\`, \`total_duration\`, \`calories\`, \`avg_heart_rate\`, \`source\`
FROM \`workouts\`
WHERE \`finished_at\` IS NOT NULL;
--> statement-breakpoint
ALTER TABLE \`laps\` RENAME COLUMN \`workout_id\` TO \`session_id\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`started_at\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`finished_at\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`total_distance\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`total_duration\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`calories\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`avg_heart_rate\`;
--> statement-breakpoint
ALTER TABLE \`workouts\` DROP COLUMN \`source\`;
`;
const m0002 = `
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE \`__new_laps\` (
\`id\` text PRIMARY KEY NOT NULL,
\`session_id\` text NOT NULL,
\`set_position\` integer NOT NULL,
\`rep_number\` integer NOT NULL,
\`distance\` integer NOT NULL,
\`duration\` real NOT NULL,
\`stroke_count\` integer,
\`heart_rate\` integer,
\`swolf\` real,
FOREIGN KEY (\`session_id\`) REFERENCES \`workout_sessions\`(\`id\`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO \`__new_laps\`("id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf") SELECT "id", "session_id", "set_position", "rep_number", "distance", "duration", "stroke_count", "heart_rate", "swolf" FROM \`laps\`;--> statement-breakpoint
DROP TABLE \`laps\`;--> statement-breakpoint
ALTER TABLE \`__new_laps\` RENAME TO \`laps\`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE \`__new_workout_sessions\` (
\`id\` text PRIMARY KEY NOT NULL,
\`workout_id\` text,
\`name\` text,
\`pool_length\` real DEFAULT 25 NOT NULL,
\`started_at\` text NOT NULL,
\`finished_at\` text,
\`total_distance\` integer,
\`total_duration\` real,
\`calories\` real,
\`avg_heart_rate\` integer,
\`source\` text DEFAULT 'manual' NOT NULL
);
--> statement-breakpoint
INSERT INTO \`__new_workout_sessions\`("id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source") SELECT "id", "workout_id", "name", "pool_length", "started_at", "finished_at", "total_distance", "total_duration", "calories", "avg_heart_rate", "source" FROM \`workout_sessions\`;--> statement-breakpoint
DROP TABLE \`workout_sessions\`;--> statement-breakpoint
ALTER TABLE \`__new_workout_sessions\` RENAME TO \`workout_sessions\`;--> statement-breakpoint
ALTER TABLE \`workouts\` ADD \`created_at\` text NOT NULL;
`;
export default {
journal,
migrations: {
m0000
m0000,
m0001,
m0002
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"cli": {
"version": ">= 20.0.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"environment": "development",
"channel": "development"
},
"preview": {
"distribution": "store",
"autoIncrement": true,
"environment": "preview",
"channel": "preview"
},
"production": {
"autoIncrement": true,
"environment": "production",
"channel": "production"
}
},
"submit": {
"preview": {
"android": {
"serviceAccountKeyPath": "./google-services.json",
"track": "internal"
}
},
"production": {}
}
}
Vendored
+1
View File
@@ -0,0 +1 @@
declare module "*.css" {}
+2 -2
View File
@@ -1,8 +1,8 @@
const { getDefaultConfig } = require("expo/metro-config");
const { getSentryExpoConfig } = require("@sentry/react-native/metro");
const { withNativewind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const config = getSentryExpoConfig(__dirname);
config.resolver.sourceExts.push("sql");
+172 -19
View File
@@ -1,64 +1,217 @@
import ExpoModulesCore
import WatchConnectivity
private class SessionDelegate: NSObject, WCSessionDelegate {
var onActivationComplete: ((WCSessionActivationState) -> Void)?
var onMessageReceived: (([String: Any]) -> Void)?
var onUserInfoReceived: (([String: Any]) -> Void)?
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("[WatchConnectivity] Activation failed: \(error)")
} else {
print("[WatchConnectivity] Activation complete: state=\(activationState.rawValue)")
}
onActivationComplete?(activationState)
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
print("[WatchConnectivity] Received message with reply: \(message["type"] ?? "unknown")")
replyHandler(["status": "received"])
onMessageReceived?(message)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
print("[WatchConnectivity] Received userInfo: \(userInfo["type"] ?? "unknown")")
onUserInfoReceived?(userInfo)
}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
}
public class WatchConnectivityModule: Module {
private var session: WCSession?
private let delegate = SessionDelegate()
private var isActivated = false
private var pendingPromises: [(() -> Void)] = []
private var pendingEvents: [[String: Any]] = []
// WCSession only supports NSString, NSNumber, NSData, NSArray, NSDictionary
private func cleanForWCSession(_ obj: Any) -> Any {
if let dict = obj as? [String: Any] {
var cleaned: [String: Any] = [:]
for (key, value) in dict {
if value is NSNull { continue }
cleaned[key] = cleanForWCSession(value)
}
return cleaned
} else if let arr = obj as? [Any] {
return arr.filter { !($0 is NSNull) }.map { cleanForWCSession($0) }
}
return obj
}
public func definition() -> ModuleDefinition {
Name("WatchConnectivity")
OnCreate {
guard WCSession.isSupported() else { return }
let session = WCSession.default
session.delegate = self.delegate
self.delegate.onActivationComplete = { [weak self] state in
DispatchQueue.main.async {
self?.isActivated = (state == .activated)
print("[WatchConnectivity] isActivated = \(self?.isActivated ?? false)")
let pending = self?.pendingPromises ?? []
self?.pendingPromises = []
pending.forEach { $0() }
}
}
self.delegate.onMessageReceived = { [weak self] message in
self?.handleIncomingMessage(message)
}
self.delegate.onUserInfoReceived = { [weak self] userInfo in
self?.handleIncomingMessage(userInfo)
}
session.activate()
self.session = session
}
AsyncFunction("isSupported") { () -> Bool in
return WCSession.isSupported()
}
AsyncFunction("isPaired") { () -> Bool in
return WCSession.default.isPaired
guard let session = self.session else { return false }
return session.isPaired
}
AsyncFunction("isWatchAppInstalled") { () -> Bool in
return WCSession.default.isWatchAppInstalled
guard let session = self.session else { return false }
return session.isWatchAppInstalled
}
AsyncFunction("isReachable") { () -> Bool in
return WCSession.default.isReachable
guard let session = self.session else { return false }
return session.isReachable
}
AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in
guard let session = self.session else {
print("[WatchConnectivity] sendWorkoutPlan: NO_SESSION")
promise.reject("NO_SESSION", "WCSession not available")
return
}
func doSend() {
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)
print("[WatchConnectivity] sendWorkoutPlan: isReachable=\(session.isReachable), activationState=\(session.activationState.rawValue)")
guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else {
promise.resolve(["status": "sent"])
return
}
session.sendMessage(cleaned, replyHandler: nil, errorHandler: { error in
print("[WatchConnectivity] sendMessage failed: \(error), falling back to transferUserInfo")
session.transferUserInfo(cleaned)
})
promise.resolve(["status": "sent"])
}
if session.activationState == .activated {
doSend()
} else {
WCSession.default.transferUserInfo(message)
promise.resolve(["status": "queued"])
print("[WatchConnectivity] Session not activated yet (state=\(session.activationState.rawValue)), queuing...")
self.pendingPromises.append(doSend)
}
}
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
guard let session = self.session else {
promise.reject("NO_SESSION", "WCSession not available")
return
}
func doSend() {
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)
guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else {
promise.resolve(["status": "sent"])
return
}
if session.isReachable {
session.sendMessage(cleaned, replyHandler: nil, errorHandler: { error in
print("[WatchConnectivity] sendMessage failed: \(error), falling back to transferUserInfo")
session.transferUserInfo(cleaned)
})
} else {
WCSession.default.transferUserInfo(message)
promise.resolve(["status": "queued"])
session.transferUserInfo(cleaned)
}
promise.resolve(["status": "sent"])
}
if self.isActivated {
doSend()
} else {
self.pendingPromises.append(doSend)
}
}
AsyncFunction("sendWorkoutDeleted") { (id: String, promise: Promise) in
guard let session = self.session else {
promise.reject("NO_SESSION", "WCSession not available")
return
}
func doSend() {
let message: [String: Any] = ["type": "workoutDeleted", "id": id]
guard let cleaned = self.cleanForWCSession(message) as? [String: Any] else {
promise.resolve(["status": "sent"])
return
}
session.transferUserInfo(cleaned)
promise.resolve(["status": "sent"])
}
if self.isActivated {
doSend()
} else {
self.pendingPromises.append(doSend)
}
}
AsyncFunction("getApplicationContext") { () -> [String: Any]? in
return WCSession.default.receivedApplicationContext as [String: Any]
return self.session?.receivedApplicationContext as? [String: Any]
}
AsyncFunction("updateApplicationContext") { (context: [String: Any]) in
try? WCSession.default.updateApplicationContext(context)
try? self.session?.updateApplicationContext(context)
}
AsyncFunction("pollEvents") { () -> [[String: Any]] in
let events = self.pendingEvents
self.pendingEvents = []
return events
}
}
private func handleIncomingMessage(_ message: [String: Any]) {
guard let type = message["type"] as? String else { return }
print("[WatchConnectivity] handleIncomingMessage type=\(type)")
DispatchQueue.main.async {
self.pendingEvents.append(message)
}
}
}
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nl.guidoit.swimbuddy.wear">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<service
android:name=".WearMessageListenerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/swimbuddy" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,159 @@
package nl.guidoit.swimbuddy.wear
import com.google.android.gms.wearable.MessageClient
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.NodeClient
import com.google.android.gms.wearable.Wearable
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.tasks.await
import org.json.JSONObject
class WearConnectivityModule : Module() {
private var messageClient: MessageClient? = null
private var nodeClient: NodeClient? = null
private val pendingEvents = mutableListOf<Map<String, Any?>>()
private val MESSAGE_PATH = "/swimbuddy"
companion object {
var instance: WearConnectivityModule? = null
private set
}
private fun ensureClients(): Pair<MessageClient, NodeClient>? {
if (messageClient == null || nodeClient == null) {
val ctx = appContext.reactContext ?: return null
messageClient = messageClient ?: Wearable.getMessageClient(ctx)
nodeClient = nodeClient ?: Wearable.getNodeClient(ctx)
}
return messageClient!! to nodeClient!!
}
override fun definition() = ModuleDefinition {
Name("WearConnectivity")
OnCreate {
instance = this@WearConnectivityModule
val ctx = appContext.reactContext ?: return@OnCreate
messageClient = Wearable.getMessageClient(ctx)
nodeClient = Wearable.getNodeClient(ctx)
}
AsyncFunction("isSupported") {
try {
val ctx = appContext.reactContext ?: return@AsyncFunction false
com.google.android.gms.common.GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(ctx) == com.google.android.gms.common.ConnectionResult.SUCCESS
} catch (e: Exception) {
false
}
}
AsyncFunction("isPaired") {
try {
val (_, nc) = ensureClients() ?: return@AsyncFunction false
val nodes = nc.connectedNodes.await()
nodes.isNotEmpty()
} catch (e: Exception) {
false
}
}
AsyncFunction("isReachable") {
try {
val (_, nc) = ensureClients() ?: return@AsyncFunction false
val nodes = nc.connectedNodes.await()
nodes.any { it.isNearby }
} catch (e: Exception) {
false
}
}
AsyncFunction("sendWorkoutPlan") { plan: Map<String, Any?> ->
try {
val (mc, nc) = ensureClients()
?: return@AsyncFunction mapOf("error" to "No context")
val nodes = nc.connectedNodes.await()
val nearbyNode = nodes.firstOrNull { it.isNearby }
?: return@AsyncFunction mapOf("error" to "No nearby watch found")
val json = JSONObject()
json.put("type", "workoutPlan")
for ((key, value) in plan) {
if (value != null) {
json.put(key, value)
}
}
mc.sendMessage(
nearbyNode.id,
MESSAGE_PATH,
json.toString().toByteArray(Charsets.UTF_8)
).await()
mapOf("status" to "sent")
} catch (e: Exception) {
mapOf("error" to (e.message ?: "Failed to send"))
}
}
AsyncFunction("sendWorkoutCompletion") { data: Map<String, Any?> ->
try {
val (mc, nc) = ensureClients()
?: return@AsyncFunction mapOf("error" to "No context")
val nodes = nc.connectedNodes.await()
val nearbyNode = nodes.firstOrNull { it.isNearby }
?: return@AsyncFunction mapOf("error" to "No nearby watch found")
val json = JSONObject()
json.put("type", "workoutCompleted")
for ((key, value) in data) {
if (value != null) {
json.put(key, value)
}
}
mc.sendMessage(
nearbyNode.id,
MESSAGE_PATH,
json.toString().toByteArray(Charsets.UTF_8)
).await()
mapOf("status" to "sent")
} catch (e: Exception) {
mapOf("error" to (e.message ?: "Failed to send"))
}
}
AsyncFunction("pollEvents") {
synchronized(pendingEvents) {
val copy = ArrayList(pendingEvents)
pendingEvents.clear()
copy
}
}
}
fun handleIncomingMessage(messageEvent: MessageEvent) {
if (messageEvent.path != MESSAGE_PATH) return
try {
val json = String(messageEvent.data, Charsets.UTF_8)
val obj = JSONObject(json)
val map = mutableMapOf<String, Any?>()
for (key in obj.keys()) {
map[key] = if (obj.isNull(key)) null else obj.get(key)
}
synchronized(pendingEvents) {
pendingEvents.add(map)
}
} catch (e: Exception) {
// ignore malformed messages
}
}
override fun onDestroy() {
instance = null
}
}
@@ -0,0 +1,11 @@
package nl.guidoit.swimbuddy.wear
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
class WearMessageListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
WearConnectivityModule.instance?.handleIncomingMessage(messageEvent)
}
}
+40
View File
@@ -0,0 +1,40 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'expo-modules-core'
group = 'nl.guidoit.swimbuddy'
version = '0.1.0'
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
android {
namespace "nl.guidoit.swimbuddy.wear"
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
repositories {
mavenCentral()
google()
}
dependencies {
implementation "expo-modules-core:+"
implementation "com.google.android.gms:play-services-wearable:18.2.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3"
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["nl.guidoit.swimbuddy.wear.WearConnectivityModule"]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "wear-connectivity",
"version": "0.1.0"
}
+2
View File
@@ -0,0 +1,2 @@
-keep class nl.guidoit.swimbuddy.wear.** { *; }
-keep class com.google.android.gms.wearable.** { *; }
+1126 -551
View File
File diff suppressed because it is too large Load Diff
+25 -5
View File
@@ -8,36 +8,56 @@
"@react-navigation/bottom-tabs": "^7.18.14",
"@react-navigation/native": "^7.3.14",
"@react-navigation/native-stack": "^7.18.6",
"@sentry/react-native": "~7.11.0",
"@tailwindcss/postcss": "^4.3.3",
"drizzle-orm": "^0.45.2",
"expo": "~57.0.10",
"expo": "~57.0.16",
"expo-application": "~57.0.2",
"expo-font": "~57.0.1",
"expo-localization": "~57.0.1",
"expo-splash-screen": "~57.0.8",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"expo-system-ui": "~57.0.2",
"expo-updates": "~57.0.17",
"i18next": "^26.3.6",
"nativewind": "^5.0.0-preview.4",
"react": "19.2.3",
"react-i18next": "^17.0.11",
"react-native": "0.86.2",
"react-native-css": "^3.0.7",
"react-native-gesture-handler": "~2.32.0",
"react-native-mmkv": "^4.3.2",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0"
"react-native-screens": "~4.26.0",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
"@types/react": "~19.2.2",
"babel-plugin-inline-import": "^3.0.0",
"drizzle-kit": "^0.31.10",
"patch-package": "^8.0.1",
"tailwindcss": "^4",
"typescript": "~6.0.3"
},
"scripts": {
"postinstall": "patch-package",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"db:generate": "drizzle-kit generate"
"clean": "rm -rf .expo ios android node_modules && npm i",
"db:generate": "drizzle-kit generate",
"prebuild": "expo prebuild && npm run eas-build-post-install",
"prebuild:clean": "expo prebuild --clean && npm run eas-build-post-install",
"eas-build-pre-install": "npm install -g npm@11",
"eas-build-post-install": "node scripts/add-sentry-watch.js",
"build:wearos:debug": "./scripts/build-wearos.sh debug",
"build:wearos:release": "./scripts/build-wearos.sh release",
"deploy:ios": "eas build --platform ios --profile ${1:-preview} && eas submit --platform ios --profile ${1:-preview}",
"deploy:android": "eas build --platform android --profile ${1:-preview} && eas submit --platform android --profile ${1:-preview}",
"deploy:wearos": "./scripts/build-wearos.sh release ${2:-}",
"update": "eas update --branch ${1:-preview}",
"update:production": "eas update --branch production"
},
"private": true,
"overrides": {
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Post-prebuild script: adds SentrySPM Swift Package to the watch target.
*
* Run after `expo prebuild`:
* node scripts/add-sentry-watch.js
*/
const fs = require("fs");
const path = require("path");
const SENTRY_REPO_URL = "https://github.com/getsentry/sentry-cocoa.git";
const SENTRY_MIN_VERSION = "9.25.0";
const SENTRY_PRODUCT = "Sentry";
function genUuid() {
const hex = "0123456789ABCDEF";
let id = "";
for (let i = 0; i < 24; i++) id += hex[Math.floor(Math.random() * 16)];
return id;
}
const projectRoot = path.resolve(__dirname, "..");
const pbxprojPath = path.join(
projectRoot,
"ios",
"SwimBuddy.xcodeproj",
"project.pbxproj"
);
if (!fs.existsSync(pbxprojPath)) {
console.error("project.pbxproj not found. Run expo prebuild first.");
process.exit(1);
}
let content = fs.readFileSync(pbxprojPath, "utf-8");
// Already added?
if (content.includes(SENTRY_REPO_URL)) {
console.log("SentrySPM already present.");
process.exit(0);
}
// ── Find watch target UUID ──
const watchTargetMatch = content.match(
/(\w{24})\s*\/\* SwimBuddyWatch \*\/\s*=\s*\{\s*\n\s*isa = PBXNativeTarget;\s*\n\s*buildConfigurationList = \w+ /
);
if (!watchTargetMatch) {
console.error("Watch target not found");
process.exit(1);
}
const watchTargetUuid = watchTargetMatch[1];
console.log(`Watch target: ${watchTargetUuid}`);
// ── Find watch target's Frameworks build phase UUID ──
const watchTargetBlock = content.match(
new RegExp(
`${watchTargetUuid}\\s*\\/\\* SwimBuddyWatch \\*\\/\\s*=\\s*\\{[\\s\\S]*?buildPhases\\s*=\\s*\\(([\\s\\S]*?)\\);`
)
);
if (!watchTargetBlock) {
console.error("Cannot parse watch target buildPhases");
process.exit(1);
}
const fwPhaseMatch = watchTargetBlock[1].match(/(\w{24})\s*\/\* Frameworks \*\//);
if (!fwPhaseMatch) {
console.error("Watch Frameworks phase not found");
process.exit(1);
}
const watchFwPhaseId = fwPhaseMatch[1];
console.log(`Watch Frameworks phase: ${watchFwPhaseId}`);
// ── Generate UUIDs ──
const pkgRefId = genUuid();
const prodDepId = genUuid();
const buildFileId = genUuid();
// ── 1. PBXBuildFile (single-line format) ──
const buildFileLine = `\t\t${buildFileId} /* ${SENTRY_PRODUCT} in Frameworks */ = {isa = PBXBuildFile; productRef = ${prodDepId} /* ${SENTRY_PRODUCT} */; };`;
content = content.replace(
/\/\* End PBXBuildFile section \*\//,
`${buildFileLine}\n/* End PBXBuildFile section */`
);
// ── 2. XCRemoteSwiftPackageReference section ──
const pkgRefLine = `\t\t${pkgRefId} /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {isa = XCRemoteSwiftPackageReference; repositoryURL = "${SENTRY_REPO_URL}"; requirement = {kind = "upToNextMajorVersion"; minimumVersion = "${SENTRY_MIN_VERSION}"; }; };`;
if (!content.includes("/* Begin XCRemoteSwiftPackageReference section */")) {
content = content.replace(
/\/\* End PBXProject section \*\//,
`/* End PBXProject section */\n/* Begin XCRemoteSwiftPackageReference section */\n${pkgRefLine}\n/* End XCRemoteSwiftPackageReference section */`
);
} else {
content = content.replace(
/\/\* End XCRemoteSwiftPackageReference section \*\//,
`${pkgRefLine}\n/* End XCRemoteSwiftPackageReference section */`
);
}
// ── 3. XCSwiftPackageProductDependency section ──
const prodDepLine = `\t\t${prodDepId} /* ${SENTRY_PRODUCT} */ = {isa = XCSwiftPackageProductDependency; package = ${pkgRefId} /* XCRemoteSwiftPackageReference "sentry-cocoa" */; productName = ${SENTRY_PRODUCT}; };`;
if (!content.includes("/* Begin XCSwiftPackageProductDependency section */")) {
content = content.replace(
/\/\* End XCRemoteSwiftPackageReference section \*\//,
`/* End XCRemoteSwiftPackageReference section */\n/* Begin XCSwiftPackageProductDependency section */\n${prodDepLine}\n/* End XCSwiftPackageProductDependency section */`
);
} else {
content = content.replace(
/\/\* End XCSwiftPackageProductDependency section \*\//,
`${prodDepLine}\n/* End XCSwiftPackageProductDependency section */`
);
}
// ── 4. Add to watch Frameworks build phase ──
const fwPhasePattern = new RegExp(
`(${watchFwPhaseId}\\s*\\/\\* Frameworks \\*\\/ = \\{[\\s\\S]*?files = \\()[\\s\\S]*?(\\);[\\s\\S]*?runOnlyForDeploymentPostprocessing)`
);
const fwMatch = content.match(fwPhasePattern);
if (fwMatch) {
const insertLine = `\n\t\t\t\t${buildFileId} /* ${SENTRY_PRODUCT} in Frameworks */,`;
content = content.replace(fwPhasePattern, `$1${fwMatch[0].includes(buildFileId) ? "" : insertLine}$2`);
} else {
console.error("Cannot insert into watch Frameworks build phase");
process.exit(1);
}
// ── 5. packageReferences in PBXProject ──
const pkgRefComment = `${pkgRefId} /* XCRemoteSwiftPackageReference "sentry-cocoa" */`;
if (!content.includes("packageReferences = (")) {
content = content.replace(
/(targets\s*=\s*\([^)]*\);)/,
`$1\n\t\t\tpackageReferences = (\n\t\t\t\t${pkgRefComment},\n\t\t\t);`
);
} else {
content = content.replace(
/(packageReferences\s*=\s*\()/,
`$1\n\t\t\t\t${pkgRefComment},`
);
}
// ── 6. packageProductDependencies on watch target ──
const prodDepComment = `${prodDepId} /* ${SENTRY_PRODUCT} */`;
// Find the watch target block and check if packageProductDependencies exists
const wtPattern = new RegExp(
`(${watchTargetUuid}\\s*\\/\\* SwimBuddyWatch \\*\\/ = \\{[\\s\\S]*?fileSystemSynchronizedGroups = \\([\\s\\S]*?\\);[\\s\\S]*?)(name = )`
);
const wtMatch = content.match(wtPattern);
if (wtMatch && !content.includes("packageProductDependencies")) {
content = content.replace(
wtPattern,
`$1\n\t\t\tpackageProductDependencies = (\n\t\t\t\t${prodDepComment},\n\t\t\t);\n\t\t$2`
);
}
fs.writeFileSync(pbxprojPath, content);
console.log("SentrySPM added to watch target successfully.");
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Load .env file
if [ -f "$PROJECT_ROOT/.env.local" ]; then
set -a
source "$PROJECT_ROOT/.env.local"
set +a
elif [ -f "$PROJECT_ROOT/.env" ]; then
set -a
source "$PROJECT_ROOT/.env"
set +a
fi
PROFILE="${1:-debug}"
WEAR_DIR="$PROJECT_ROOT/targets/wearos"
FIREBASE_APP_ID="${FIREBASE_WEAR_APP_ID:-}"
# Read version from app.json (single source of truth)
VERSION=$(node -p "require('./app.json').expo.version")
WEAR_OFFSET=50000000
CODE=$(echo "$VERSION" | awk -F. '{printf "%d%02d%02d", $1, $2, $3}')
VERSION_CODE=$((CODE + WEAR_OFFSET))
cd "$WEAR_DIR"
echo "Building WearOS ($PROFILE) v$VERSION ($VERSION_CODE)..."
if [ "$PROFILE" = "release" ]; then
if [ ! -f "$WEAR_DIR/../credentials.json" ]; then
echo "credentials.json not found. Downloading from EAS..."
cd "$PROJECT_ROOT"
eas credentials --platform android --non-interactive 2>/dev/null || {
echo "Auto-download failed. Run manually:"
echo " eas credentials --platform android"
echo " → Choose 'credentials.json: Upload/Download credentials'"
echo " → Choose 'Download credentials from EAS to credentials.json'"
echo " Then: cp credentials.json targets/wearos/credentials.json"
exit 1
}
cp credentials.json "$WEAR_DIR/credentials.json"
echo "credentials.json copied to targets/wearos/"
cd "$WEAR_DIR"
fi
./gradlew assembleRelease -PversionName="$VERSION" -PversionCode="$VERSION_CODE"
APK_PATH="app/build/outputs/apk/release/app-release.apk"
else
./gradlew assembleDebug -PversionName="$VERSION" -PversionCode="$VERSION_CODE"
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
fi
if [ ! -f "$APK_PATH" ]; then
echo "Error: APK not found at $APK_PATH"
exit 1
fi
APK_SIZE=$(du -h "$APK_PATH" | cut -f1)
echo "Build complete: $APK_PATH ($APK_SIZE)"
if [ -n "$FIREBASE_APP_ID" ]; then
if ! command -v firebase &> /dev/null; then
echo "Error: firebase CLI not found. Install with: npm install -g firebase-tools"
exit 1
fi
RELEASE_NOTES="${2:-}"
CMD=(firebase appdistribution:distribute "$APK_PATH" --app "$FIREBASE_APP_ID" --groups testers)
if [ -n "$RELEASE_NOTES" ]; then
CMD+=(--release-notes "$RELEASE_NOTES")
fi
echo "Uploading to Firebase App Distribution..."
"${CMD[@]}"
echo "Upload complete"
else
echo "Set FIREBASE_WEAR_APP_ID to upload to Firebase App Distribution"
echo " export FIREBASE_WEAR_APP_ID=1:123456789:android:abc123"
fi
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -e
SOURCE="${1:?Usage: ./scripts/generate-wearos-icon.sh <source-image>}"
RES_DIR="targets/wearos/app/src/main/res"
if [ ! -f "$SOURCE" ]; then
echo "Error: Source image not found: $SOURCE"
exit 1
fi
SIZES=("48:mdpi" "72:hdpi" "96:xhdpi" "144:xxhdpi" "192:xxxhdpi")
for entry in "${SIZES[@]}"; do
SIZE="${entry%%:*}"
DENSITY="${entry##*:}"
DIR="$RES_DIR/mipmap-$DENSITY"
mkdir -p "$DIR"
sips -z "$SIZE" "$SIZE" --out "$DIR/ic_launcher.png" "$SOURCE" > /dev/null
echo "Created mipmap-$DENSITY/ic_launcher.png (${SIZE}x${SIZE})"
done
echo "Done"
+97
View File
@@ -0,0 +1,97 @@
import { useRef } from "react";
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
import { PanGestureHandler, State, PanGestureHandlerGestureEvent, PanGestureHandlerStateChangeEvent } from "react-native-gesture-handler";
type SwipeableRowProps = {
children: React.ReactNode;
onDelete: () => void;
deleteLabel?: string;
};
const MAX_LEFT = -80;
export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: SwipeableRowProps) {
const translateX = useRef(new Animated.Value(0)).current;
const offset = useRef(0);
const onGestureEvent = (event: PanGestureHandlerGestureEvent) => {
const raw = event.nativeEvent.translationX;
const clamped = Math.max(MAX_LEFT, Math.min(0, offset.current + raw));
translateX.setValue(clamped);
};
const onHandlerStateChange = (event: PanGestureHandlerStateChangeEvent) => {
if (event.nativeEvent.oldState === State.ACTIVE) {
const { translationX } = event.nativeEvent;
const final = Math.max(MAX_LEFT, Math.min(0, offset.current + translationX));
Animated.spring(translateX, {
toValue: final,
useNativeDriver: true,
}).start();
offset.current = final;
}
};
const handleDelete = () => {
offset.current = -400;
Animated.timing(translateX, {
toValue: -400,
duration: 200,
useNativeDriver: true,
}).start(() => {
onDelete();
});
};
return (
<View style={styles.container}>
<View style={styles.deleteBackground}>
<Pressable onPress={handleDelete} style={styles.deleteButton}>
<Text style={styles.deleteText}>{deleteLabel}</Text>
</Pressable>
</View>
<PanGestureHandler
onGestureEvent={onGestureEvent}
onHandlerStateChange={onHandlerStateChange}
failOffsetY={[-5, 5]}
>
<Animated.View style={[styles.foreground, { transform: [{ translateX }] }]}>
{children}
</Animated.View>
</PanGestureHandler>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 12,
},
deleteBackground: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: 80,
borderRadius: 16,
backgroundColor: "#ef4444",
justifyContent: "center",
alignItems: "center",
},
deleteButton: {
flex: 1,
justifyContent: "center",
alignItems: "center",
width: "100%",
},
deleteText: {
color: "white",
fontWeight: "600",
fontSize: 13,
},
foreground: {
backgroundColor: "white",
borderRadius: 16,
},
});
+191 -47
View File
@@ -1,8 +1,9 @@
import { db } from "./client";
import { generateUUID } from "../utils/uuid";
import { workouts, sets, laps, templates } from "./schema";
import { workouts, workoutSessions, sets, laps, templates } from "./schema";
import { eq, desc, sql } from "drizzle-orm";
import type { WorkoutSet, LapData } from "@types/workout";
import type { WorkoutSet, LapData } from "@/types/workout";
import WatchConnectivity from "../modules/watch-connectivity";
// ─── Templates ───────────────────────────────────────────────
@@ -30,7 +31,7 @@ export async function toggleTemplateFavorite(id: string, isFavorite: boolean) {
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
}
// ─── Workouts ────────────────────────────────────────────────
// ─── Workout Plans ───────────────────────────────────────────
export async function saveWorkout(
name: string | null,
@@ -39,16 +40,13 @@ export async function saveWorkout(
templateId?: string
) {
const workoutId = generateUUID();
const totalDistance = setsData.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
await db.insert(workouts).values({
id: workoutId,
name,
templateId: templateId ?? null,
poolLength,
startedAt: new Date().toISOString(),
totalDistance,
source: "manual",
createdAt: new Date().toISOString(),
});
for (let i = 0; i < setsData.length; i++) {
@@ -69,52 +67,163 @@ export async function saveWorkout(
return workoutId;
}
export async function finishWorkout(
workoutId: string,
duration: number,
calories: number,
avgHeartRate: number
) {
await db
.update(workouts)
.set({
finishedAt: new Date().toISOString(),
totalDuration: duration,
calories,
avgHeartRate,
})
.where(eq(workouts.id, workoutId));
}
export async function getWorkoutWithSets(workoutId: string) {
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
if (!workout) return null;
const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId));
const workoutLaps = await db.select().from(laps).where(eq(laps.workoutId, workoutId));
return { ...workout, sets: workoutSets, laps: workoutLaps };
return { ...workout, sets: workoutSets };
}
export async function getAllWorkouts() {
return db.select().from(workouts).orderBy(desc(workouts.startedAt));
return db.select().from(workouts).orderBy(desc(workouts.createdAt));
}
export async function deleteWorkout(id: string) {
await db.delete(laps).where(eq(laps.workoutId, id));
await db.delete(sets).where(eq(sets.workoutId, id));
await db.delete(workouts).where(eq(workouts.id, id));
// ─── Workout Sessions ────────────────────────────────────────
export async function finishWorkout(
workoutId: string,
duration: number,
calories: number,
avgHeartRate: number,
totalDistance: number
) {
const sessionId = generateUUID();
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
await db.insert(workoutSessions).values({
id: sessionId,
workoutId,
name: workout?.name ?? null,
poolLength: workout?.poolLength ?? 25,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
totalDistance,
totalDuration: duration,
calories,
avgHeartRate,
source: "phone",
});
return sessionId;
}
export async function saveWatchCompletedWorkout(data: {
workoutId: string;
sessionId?: string;
duration: number;
totalDistance: number;
calories: number;
laps: Array<{
setPosition: number;
repNumber: number;
distance: number;
duration: number;
heartRate: number;
strokeCount?: number;
}>;
}) {
const avgHeartRate =
data.laps.length > 0
? Math.round(data.laps.reduce((sum, l) => sum + (l.heartRate || 0), 0) / data.laps.length)
: 0;
// Haal plan op als het bestaat
const plan = await db
.select()
.from(workouts)
.where(eq(workouts.id, data.workoutId))
.get();
// Gebruik sessionId van de watch, of genereer een nieuwe
const sessionId = data.sessionId ?? generateUUID();
// Check of er al een sessie bestaat voor dit sessionId
const existingSession = await db
.select({ id: workoutSessions.id })
.from(workoutSessions)
.where(eq(workoutSessions.id, sessionId))
.limit(1);
if (existingSession.length === 0) {
await db.insert(workoutSessions).values({
id: sessionId,
workoutId: data.workoutId,
name: plan?.name ?? "Watch Workout",
poolLength: plan?.poolLength ?? 25,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
totalDistance: data.totalDistance || 0,
totalDuration: data.duration,
calories: data.calories || 0,
avgHeartRate,
source: "watch",
});
} else {
await db
.update(workoutSessions)
.set({
finishedAt: new Date().toISOString(),
totalDuration: data.duration,
totalDistance: data.totalDistance || 0,
calories: data.calories || 0,
avgHeartRate,
})
.where(eq(workoutSessions.id, sessionId));
}
for (const lap of data.laps) {
const strokeCount = lap.strokeCount ?? null;
const swolf = strokeCount != null ? strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: generateUUID(),
sessionId,
setPosition: lap.setPosition,
repNumber: lap.repNumber,
distance: lap.distance,
duration: lap.duration,
heartRate: lap.heartRate || null,
strokeCount,
swolf,
});
}
}
export async function getAllSessions() {
return db
.select()
.from(workoutSessions)
.orderBy(desc(workoutSessions.startedAt));
}
export async function getSessionWithDetails(sessionId: string) {
const session = await db
.select()
.from(workoutSessions)
.where(eq(workoutSessions.id, sessionId))
.get();
if (!session) return null;
const sessionLaps = await db.select().from(laps).where(eq(laps.sessionId, sessionId));
let sessionSets: any[] = [];
if (session.workoutId) {
sessionSets = await db.select().from(sets).where(eq(sets.workoutId, session.workoutId));
}
return { ...session, laps: sessionLaps, sets: sessionSets };
}
// ─── Laps ────────────────────────────────────────────────────
export async function saveLap(workoutId: string, lap: LapData) {
export async function saveLap(sessionId: string, lap: LapData) {
const swolf =
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: generateUUID(),
workoutId,
sessionId,
setPosition: lap.setPosition,
repNumber: lap.repNumber,
distance: lap.distance,
@@ -125,14 +234,49 @@ export async function saveLap(workoutId: string, lap: LapData) {
});
}
export async function saveLaps(workoutId: string, lapsData: LapData[]) {
export async function saveLaps(sessionId: string, lapsData: LapData[]) {
for (const lap of lapsData) {
await saveLap(workoutId, lap);
await saveLap(sessionId, lap);
}
}
export async function getWorkoutLaps(workoutId: string) {
return db.select().from(laps).where(eq(laps.workoutId, workoutId));
export async function getSessionLaps(sessionId: string) {
return db.select().from(laps).where(eq(laps.sessionId, sessionId));
}
// ─── Delete ──────────────────────────────────────────────────
export async function deleteSession(sessionId: string) {
await db.delete(laps).where(eq(laps.sessionId, sessionId));
await db.delete(workoutSessions).where(eq(workoutSessions.id, sessionId));
}
export async function deleteSessionAndSync(sessionId: string) {
await deleteSession(sessionId);
WatchConnectivity.sendWorkoutDeleted(sessionId).catch((err: Error) =>
console.warn("[ops] sync failed:", err)
);
}
export async function deleteWorkout(id: string) {
// Verwijder alle sessies die aan dit plan gelinkt zijn
const sessions = await db
.select({ id: workoutSessions.id })
.from(workoutSessions)
.where(eq(workoutSessions.workoutId, id));
for (const session of sessions) {
await db.delete(laps).where(eq(laps.sessionId, session.id));
}
await db.delete(workoutSessions).where(eq(workoutSessions.workoutId, id));
await db.delete(sets).where(eq(sets.workoutId, id));
await db.delete(workouts).where(eq(workouts.id, id));
}
export async function deleteWorkoutAndSync(id: string) {
await deleteWorkout(id);
WatchConnectivity.sendWorkoutDeleted(id).catch((err: Error) =>
console.warn("[ops] sync failed:", err)
);
}
// ─── Stats ───────────────────────────────────────────────────
@@ -144,12 +288,12 @@ export async function getWeeklyStats() {
return db
.select({
count: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
avgCalories: sql<number>`coalesce(avg(${workouts.calories}), 0)`,
totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
avgCalories: sql<number>`coalesce(avg(${workoutSessions.calories}), 0)`,
})
.from(workouts)
.where(sql`${workouts.startedAt} >= ${weekAgo.toISOString()}`)
.from(workoutSessions)
.where(sql`${workoutSessions.startedAt} >= ${weekAgo.toISOString()}`)
.get();
}
@@ -169,10 +313,10 @@ export async function getTotalStats() {
return db
.select({
totalWorkouts: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
totalDistance: sql<number>`coalesce(sum(${workoutSessions.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workoutSessions.totalDuration}), 0)`,
})
.from(workouts)
.where(sql`${workouts.finishedAt} IS NOT NULL`)
.from(workoutSessions)
.where(sql`${workoutSessions.finishedAt} IS NOT NULL`)
.get();
}
+12 -4
View File
@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { workouts, sets, laps, templates } from "./schema";
import { templates, workouts, workoutSessions, sets, laps } from "./schema";
export const templatesRelations = relations(templates, ({ many }) => ({
workouts: many(workouts),
@@ -11,6 +11,14 @@ export const workoutsRelations = relations(workouts, ({ one, many }) => ({
references: [templates.id],
}),
sets: many(sets),
sessions: many(workoutSessions),
}));
export const workoutSessionsRelations = relations(workoutSessions, ({ one, many }) => ({
workout: one(workouts, {
fields: [workoutSessions.workoutId],
references: [workouts.id],
}),
laps: many(laps),
}));
@@ -22,8 +30,8 @@ export const setsRelations = relations(sets, ({ one }) => ({
}));
export const lapsRelations = relations(laps, ({ one }) => ({
workout: one(workouts, {
fields: [laps.workoutId],
references: [workouts.id],
session: one(workoutSessions, {
fields: [laps.sessionId],
references: [workoutSessions.id],
}),
}));
+10 -2
View File
@@ -15,6 +15,14 @@ export const workouts = sqliteTable("workouts", {
name: text("name"),
templateId: text("template_id"),
poolLength: real("pool_length").notNull().default(25),
createdAt: text("created_at").notNull(),
});
export const workoutSessions = sqliteTable("workout_sessions", {
id: text("id").primaryKey(),
workoutId: text("workout_id"),
name: text("name"),
poolLength: real("pool_length").notNull().default(25),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
totalDistance: integer("total_distance"),
@@ -40,9 +48,9 @@ export const sets = sqliteTable("sets", {
export const laps = sqliteTable("laps", {
id: text("id").primaryKey(),
workoutId: text("workout_id")
sessionId: text("session_id")
.notNull()
.references(() => workouts.id),
.references(() => workoutSessions.id),
setPosition: integer("set_position").notNull(),
repNumber: integer("rep_number").notNull(),
distance: integer("distance").notNull(),
@@ -0,0 +1,221 @@
import { View, Text, ScrollView, Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import { formatTime, formatPace } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
function formatPoolPace(seconds: number, poolLength: number): string {
if (poolLength === 0) return "--:--";
const pace = seconds / poolLength;
const mins = Math.floor(pace / 60);
const secs = Math.floor(pace % 60);
return `${mins}:${String(secs).padStart(2, "0")}`;
}
type Set = {
stroke: string;
distancePerRep: number;
repCount: number;
position: number;
poolPace?: number | null;
};
type Lap = {
setPosition: number;
repNumber: number;
distance: number;
duration: number;
heartRate: number | null;
swolf: number | null;
};
type Props = {
name?: string | null;
date?: string;
totalDistance: number;
totalDuration: number;
totalLengths?: number | null;
calories?: number | null;
avgHeartRate?: number | null;
avgPace?: number | null;
poolLength: number;
sets: Set[];
laps: Lap[];
avgSwolf: number | null;
onDone?: () => void;
};
export function WorkoutSummary({
name,
date,
totalDistance,
totalDuration,
totalLengths,
calories,
avgHeartRate,
avgPace,
poolLength,
sets,
laps,
avgSwolf,
onDone,
}: Props) {
const { t } = useTranslation("active-workout");
const { t: tc } = useTranslation("common");
return (
<ScrollView className="flex-1 bg-white">
<View className="p-4">
{/* Header */}
<View className="flex-row justify-between items-start mb-6">
<View className="flex-1">
<Text className="text-2xl font-bold text-slate-900 mb-1">
{name || tc("swimSession")}
</Text>
<Text className="text-slate-500 text-sm">
{date || tc("pool", { length: poolLength })}
</Text>
</View>
</View>
{/* Stats grid */}
<View className="flex-row gap-3 mb-4">
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{formatDistance(totalDistance)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{tc("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(totalDuration)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{tc("duration")}</Text>
</View>
</View>
<View className="flex-row gap-3 mb-4">
{calories != null && calories > 0 && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{Math.round(calories)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{t("calories")}</Text>
</View>
)}
{avgHeartRate != null && avgHeartRate > 0 && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">{avgHeartRate}</Text>
<Text className="text-xs text-slate-500 mt-1">{t("avgHeartRate")}</Text>
</View>
)}
{totalLengths != null && totalLengths > 0 && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">{totalLengths}</Text>
<Text className="text-xs text-slate-500 mt-1">{t("lengths")}</Text>
</View>
)}
</View>
{avgPace != null && avgPace > 0 && (
<View className="flex-row gap-3 mb-4">
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{formatPace(totalDistance, totalDuration)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{t("avgPace")}</Text>
</View>
</View>
)}
{/* SWOLF */}
{avgSwolf != null && avgSwolf > 0 && (
<View className="bg-slate-50 rounded-2xl p-4 mb-4 flex-row justify-between items-center">
<Text className="text-sm font-semibold text-slate-700">{tc("avgSwolf")}</Text>
<Text className="text-xl font-bold text-slate-900">{avgSwolf}</Text>
</View>
)}
{/* Sets */}
{sets.length > 0 && (
<>
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-2">
{t("sets")}
</Text>
{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">
{tc(`stroke.${set.stroke.toLowerCase()}`)}
</Text>
</View>
<View className="items-end">
<Text className="text-slate-600">
{set.repCount}×{set.distancePerRep}m
</Text>
{set.poolPace != null && set.poolPace > 0 && (
<Text className="text-slate-400 text-xs">
{formatPoolPace(set.poolPace, poolLength)}/pool
</Text>
)}
</View>
</View>
))}
</>
)}
{/* Laps */}
{laps.length > 0 && (
<>
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mt-6 mb-2">
{t("lapsDetail")}
</Text>
{laps.map((lap, i) => (
<View
key={i}
className="flex-row items-center justify-between py-2.5 border-b border-slate-100"
>
<View className="flex-1">
<Text className="text-slate-700 text-sm">
{t("setRep", { set: lap.setPosition, rep: lap.repNumber })}
</Text>
<Text className="text-slate-400 text-xs">{lap.distance}m</Text>
</View>
<View className="items-end">
<Text className="text-slate-900 font-medium">{formatTime(lap.duration)}</Text>
<Text className="text-slate-400 text-xs">
{formatPace(lap.distance, lap.duration)}
</Text>
</View>
{lap.heartRate != null && lap.heartRate > 0 && (
<View className="ml-3 items-end">
<Text className="text-red-500 text-xs">♥ {lap.heartRate}</Text>
</View>
)}
</View>
))}
</>
)}
</View>
{/* Done button */}
{onDone && (
<View className="p-4 pb-8">
<Pressable
onPress={onDone}
className="bg-slate-900 rounded-2xl py-4 items-center"
>
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
</Pressable>
</View>
)}
</ScrollView>
);
}
@@ -1,21 +1,45 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
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";
import type { RootStackParamList } from "@navigation/types";
import { getSessionWithDetails } from "@db/operations";
import { WorkoutSummary } from "../components/WorkoutSummary";
export function PostSwimScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PostSwim">>();
const [summary, setSummary] = useState<{
type Set = {
stroke: string;
distancePerRep: number;
repCount: number;
position: number;
};
type Lap = {
setPosition: number;
repNumber: number;
distance: number;
duration: number;
laps: number;
heartRate: number | null;
swolf: number | null;
};
export function PostSwimScreen() {
const { t } = useTranslation("active-workout");
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PostSwim">>();
const [data, setData] = useState<{
name: string | null;
poolLength: number;
totalDistance: number;
totalDuration: number;
calories: number | null;
avgHeartRate: number | null;
sets: Set[];
laps: Lap[];
avgSwolf: number | null;
totalLengths: number | null;
avgPace: number | null;
} | null>(null);
useEffect(() => {
@@ -23,70 +47,70 @@ export function PostSwimScreen() {
}, []);
const loadSummary = async () => {
const workout = await getWorkoutWithSets(route.params.workoutId);
const laps = await getWorkoutLaps(route.params.workoutId);
const session = await getSessionWithDetails(route.params.sessionId);
if (!session) return;
if (!workout) return;
const totalDuration = workout.laps?.reduce((acc: number, l: any) => acc + l.duration, 0) ?? 0;
const totalDuration = session.totalDuration ?? 0;
const avgSwolf =
laps.length > 0
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
session.laps.length > 0
? Math.round(session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length)
: null;
setSummary({
distance: workout.totalDistance ?? 0,
duration: totalDuration,
laps: laps.length,
const totalLengths =
session.totalDistance && session.poolLength
? Math.round(session.totalDistance / session.poolLength)
: null;
const avgPace =
session.totalDistance && totalDuration
? session.totalDistance / totalDuration
: null;
const setsWithPace = (session.sets ?? []).map((set: any) => {
const setLaps = session.laps.filter((l: any) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc: number, l: any) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc: number, l: any) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
return { ...set, poolPace };
});
setData({
name: session.name,
poolLength: session.poolLength,
totalDistance: session.totalDistance ?? 0,
totalDuration,
calories: session.calories,
avgHeartRate: session.avgHeartRate,
sets: setsWithPace,
laps: session.laps,
avgSwolf,
totalLengths,
avgPace,
});
};
if (!data) {
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 className="flex-1 bg-white items-center justify-center">
<Text className="text-slate-400">{t("loadingSummary")}</Text>
</View>
);
}
return (
<WorkoutSummary
name={data.name}
totalDistance={data.totalDistance}
totalDuration={data.totalDuration}
totalLengths={data.totalLengths}
calories={data.calories}
avgHeartRate={data.avgHeartRate}
avgPace={data.avgPace}
poolLength={data.poolLength}
sets={data.sets}
laps={data.laps}
avgSwolf={data.avgSwolf}
onDone={() => navigation.popToTop()}
/>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation, useRoute } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "@navigation/types";
@@ -9,7 +10,7 @@ import { useSyncWorkout } from "../../watch-connectivity/hooks/useSyncWorkout";
import { useWatchConnection } from "../../watch-connectivity/hooks/useWatchConnection";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
import type { WorkoutSet } from "@types/workout";
import type { WorkoutSet } from "@/types/workout";
type WorkoutWithSets = {
id: string;
@@ -20,11 +21,14 @@ type WorkoutWithSets = {
};
export function PreSwimScreen() {
const { t } = useTranslation("active-workout");
const { t: tc } = useTranslation("common");
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const [syncResult, setSyncResult] = useState<"sent" | "error" | null>(null);
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
const { status, isPaired, isInstalled } = useWatchConnection();
const { status, isPaired, isInstalled, isReady } = useWatchConnection();
useEffect(() => {
loadWorkout();
@@ -36,12 +40,7 @@ export function PreSwimScreen() {
};
const handleStartSwim = async () => {
if (!workout) return;
if (status !== "connected") {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
return;
}
if (!workout || !isReady) return;
const result = await sendWorkoutToWatch(
workout.id,
@@ -51,11 +50,11 @@ export function PreSwimScreen() {
);
if ("error" in result) {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
setSyncResult("error");
return;
}
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
setSyncResult("sent");
};
if (!workout) {
@@ -66,20 +65,60 @@ export function PreSwimScreen() {
);
}
if (syncResult === "sent") {
return (
<View className="flex-1 bg-white p-4 items-center justify-center">
<View className="bg-green-50 rounded-2xl p-6 items-center mb-6 w-full">
<Text className="text-4xl mb-3">⌚</Text>
<Text className="text-xl font-bold text-green-800 mb-2">{t("workoutSentToWatch")}</Text>
<Text className="text-green-600 text-center">
{t("openWatchApp")}
</Text>
</View>
<Pressable
onPress={() => navigation.popToTop()}
className="bg-slate-900 rounded-2xl py-4 items-center w-full"
>
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
</Pressable>
</View>
);
}
if (syncResult === "error") {
return (
<View className="flex-1 bg-white p-4 items-center justify-center">
<View className="bg-red-50 rounded-2xl p-6 items-center mb-6 w-full">
<Text className="text-4xl mb-3">⚠️</Text>
<Text className="text-xl font-bold text-red-800 mb-2">{t("failedToSend")}</Text>
<Text className="text-red-600 text-center">
{t("couldNotSend")}
</Text>
</View>
<Pressable
onPress={() => setSyncResult(null)}
className="bg-sky-500 rounded-2xl py-4 items-center w-full"
>
<Text className="text-white font-bold text-lg">{tc("retry")}</Text>
</Pressable>
</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"}
{workout.name || tc("workout")}
</Text>
<Text className="text-slate-500">
{formatDistance(workout.totalDistance ?? 0)} · {workout.poolLength}m pool
{formatDistance(workout.totalDistance ?? 0)} · {tc("pool", { length: workout.poolLength })}
</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
{tc("sets")}
</Text>
{workout.sets.map((set, i) => (
<View
@@ -91,7 +130,7 @@ export function PreSwimScreen() {
className="w-3 h-3 rounded-full"
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
/>
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
<Text className="text-slate-700 font-medium">{tc(`stroke.${set.stroke.toLowerCase()}`)}</Text>
</View>
<Text className="text-slate-600">
{set.repCount}×{set.distancePerRep}m
@@ -102,9 +141,9 @@ export function PreSwimScreen() {
<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-500">{t("rest")}</Text>
<Text className="text-slate-900 font-medium">
{workout.sets[0]?.restDuration ?? 0}s between sets
{t("betweenSets", { duration: workout.sets[0]?.restDuration ?? 0 })}
</Text>
</View>
</View>
@@ -112,39 +151,41 @@ export function PreSwimScreen() {
<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"
isReady ? "bg-green-500" : isPaired ? "bg-yellow-500" : "bg-slate-300"
}`}
/>
<Text className="text-sm text-slate-600">
{status === "connected"
? "Apple Watch connected"
{isReady
? t("watchStatus.ready")
: isPaired
? "Watch paired but not connected"
: "No Watch paired"}
? isInstalled
? t("watchStatus.pairedNotInRange")
: t("watchStatus.pairedNotInstalled")
: t("watchStatus.noWatch")}
</Text>
</View>
<Pressable
onPress={handleStartSwim}
disabled={isSyncing}
disabled={isSyncing || !isReady}
className={`rounded-2xl py-4 items-center ${
isSyncing ? "bg-sky-300" : "bg-sky-500"
isSyncing || !isReady ? "bg-slate-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 className={`font-bold text-lg ${!isReady ? "text-slate-500" : "text-white"}`}>
{t("sendToWatchAndStart")}
</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"}
{!isReady && (
<Text className="text-red-500 text-xs text-center mt-3">
{t("watchRequired")}
</Text>
)}
</View>
);
}
+54 -32
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, FlatList } from "react-native";
import { useCallback, useState } from "react";
import { View, Text, Pressable, FlatList, Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { HistoryStackParamList } from "@navigation/types";
import { getAllWorkouts } from "@db/operations";
import { getAllSessions, deleteSessionAndSync } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = {
type SessionRow = {
id: string;
name: string | null;
totalDistance: number | null;
@@ -18,22 +21,47 @@ type WorkoutRow = {
};
export function HistoryScreen() {
const { t } = useTranslation("history");
const { t: tc } = useTranslation("common");
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
const [sessions, setSessions] = useState<SessionRow[]>([]);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadWorkouts();
const loadSessions = useCallback(async () => {
const data = await getAllSessions();
setSessions(data as SessionRow[]);
}, []);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
useFocusEffect(
useCallback(() => {
loadSessions();
}, [loadSessions])
);
const onRefresh = async () => {
setRefreshing(true);
await loadSessions();
setRefreshing(false);
};
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
const date = new Date(w.startedAt).toLocaleDateString();
const onDelete = (id: string) => {
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
{ text: tc("cancel"), style: "cancel" },
{
text: tc("delete"),
style: "destructive",
onPress: async () => {
await deleteSessionAndSync(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
},
},
]);
};
const grouped = sessions.reduce<Record<string, SessionRow[]>>((acc, s) => {
const date = new Date(s.startedAt).toLocaleDateString();
if (!acc[date]) acc[date] = [];
acc[date].push(w);
acc[date].push(s);
return acc;
}, {});
@@ -41,22 +69,15 @@ export function HistoryScreen() {
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"
refreshing={refreshing}
onRefresh={onRefresh}
ListEmptyComponent={
<View className="py-12 items-center">
<Text className="text-slate-400 text-lg">No completed workouts yet</Text>
<Text className="text-slate-400 text-lg">{t("noCompletedYet")}</Text>
</View>
}
renderItem={({ item: [date, rows] }) => (
@@ -64,31 +85,32 @@ export function HistoryScreen() {
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
{date}
</Text>
{rows.map((w) => (
{rows.map((s) => (
<SwipeableRow key={s.id} onDelete={() => onDelete(s.id)} deleteLabel={tc("delete")}>
<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"
onPress={() => navigation.navigate("SessionDetail", { sessionId: s.id })}
className="p-4"
>
<View className="flex-row justify-between items-center">
<View>
<Text className="font-semibold text-slate-900">
{w.name || "Swim Session"}
{s.name || tc("swimSession")}
</Text>
<Text className="text-slate-500 text-sm">
{formatDistance(w.totalDistance ?? 0)}
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
{w.calories ? ` · ${Math.round(w.calories)} kcal` : ""}
{formatDistance(s.totalDistance ?? 0)}
{s.totalDuration ? ` · ${formatTime(s.totalDuration)}` : ""}
{s.calories ? ` · ${tc("kcal", { count: Math.round(s.calories) })}` : ""}
</Text>
</View>
<Text className="text-slate-400 text-xs">
{new Date(w.startedAt).toLocaleTimeString([], {
{new Date(s.startedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
</Pressable>
</SwipeableRow>
))}
</View>
)}
@@ -1,12 +1,12 @@
import { useEffect, useState } from "react";
import { View, Text, ScrollView } from "react-native";
import { View, Text, Pressable, Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
import type { HistoryStackProps } from "@navigation/types";
import { getWorkoutWithSets } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
import { getSessionWithDetails, deleteSessionAndSync } from "@db/operations";
import { WorkoutSummary } from "../../active-workout/components/WorkoutSummary";
type WorkoutDetail = {
type SessionDetail = {
id: string;
name: string | null;
poolLength: number;
@@ -16,6 +16,7 @@ type WorkoutDetail = {
avgHeartRate: number | null;
startedAt: string;
finishedAt: string | null;
source: string;
sets: {
stroke: string;
distancePerRep: number;
@@ -27,121 +28,98 @@ type WorkoutDetail = {
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);
const { t: tc } = useTranslation("common");
const navigation = useNavigation();
const [session, setSession] = useState<SessionDetail | null>(null);
useEffect(() => {
loadWorkout();
loadSession();
}, []);
const loadWorkout = async () => {
const data = await getWorkoutWithSets(route.params.sessionId);
setWorkout(data as WorkoutDetail | null);
const loadSession = async () => {
const data = await getSessionWithDetails(route.params.sessionId);
setSession(data as SessionDetail | null);
};
if (!workout) {
const handleDelete = () => {
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
{ text: tc("cancel"), style: "cancel" },
{
text: tc("delete"),
style: "destructive",
onPress: async () => {
await deleteSessionAndSync(route.params.sessionId);
navigation.goBack();
},
},
]);
};
useEffect(() => {
if (session) {
navigation.setOptions({
headerRight: () => (
<Pressable onPress={handleDelete} hitSlop={8}>
<Text style={{ color: "#fff", fontSize: 18 }}>🗑</Text>
</Pressable>
),
});
}
}, [session]);
if (!session) {
return (
<View className="flex-1 bg-white items-center justify-center">
<Text className="text-slate-400">Loading...</Text>
<Text className="text-slate-400">{tc("loading")}</Text>
</View>
);
}
const avgSwolf =
workout.laps.length > 0
session.laps.length > 0
? Math.round(
workout.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / workout.laps.length
session.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / session.laps.length
)
: null;
const totalLengths =
session.totalDistance && session.poolLength
? Math.round(session.totalDistance / session.poolLength)
: null;
const avgPace =
session.totalDistance && session.totalDuration
? session.totalDistance / session.totalDuration
: null;
const setsWithPace = session.sets.map((set) => {
const setLaps = session.laps.filter((l) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
return { ...set, poolPace };
});
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" }}
<WorkoutSummary
name={session.name}
date={new Date(session.startedAt).toLocaleDateString()}
totalDistance={session.totalDistance ?? 0}
totalDuration={session.totalDuration ?? 0}
totalLengths={totalLengths}
calories={session.calories}
avgHeartRate={session.avgHeartRate}
avgPace={avgPace}
poolLength={session.poolLength}
sets={setsWithPace}
laps={session.laps}
avgSwolf={avgSwolf}
/>
<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>
);
}
+17 -14
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { View, Text, ScrollView } from "react-native";
import { useTranslation } from "react-i18next";
import { getWeeklyStats, getTotalStats, getPersonalBests } from "@db/operations";
import { formatDistance } from "@utils/formatDistance";
import { formatTime } from "@utils/formatTime";
@@ -24,6 +25,8 @@ type PersonalBest = {
};
export function StatsScreen() {
const { t } = useTranslation("history");
const { t: tc } = useTranslation("common");
const [weekly, setWeekly] = useState<WeeklyStats | null>(null);
const [total, setTotal] = useState<TotalStats | null>(null);
const [bests, setBests] = useState<PersonalBest[]>([]);
@@ -45,79 +48,79 @@ export function StatsScreen() {
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-2xl font-bold text-slate-900 mb-6">{t("statistics")}</Text>
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
This Week
{t("thisWeek")}
</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>
<Text className="text-xs text-slate-500">{tc("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>
<Text className="text-xs text-slate-500">{tc("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>
<Text className="text-xs text-slate-500">{tc("duration")}</Text>
</View>
</View>
) : (
<Text className="text-slate-400 text-center py-4">No data yet</Text>
<Text className="text-slate-400 text-center py-4">{t("noDataYet")}</Text>
)}
</View>
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
All Time
{t("allTime")}
</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>
<Text className="text-xs text-slate-500">{tc("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>
<Text className="text-xs text-slate-500">{tc("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>
<Text className="text-xs text-slate-500">{tc("duration")}</Text>
</View>
</View>
) : (
<Text className="text-slate-400 text-center py-4">No data yet</Text>
<Text className="text-slate-400 text-center py-4">{t("noDataYet")}</Text>
)}
</View>
{bests.length > 0 && (
<>
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Personal Bests
{t("personalBests")}
</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>
<Text className="text-green-700 font-medium">{tc("meter", { count: best.distance })}</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>
<Text className="text-green-600 text-xs">{t("swolf", { value: best.bestSwolf })}</Text>
)}
</View>
</View>
@@ -0,0 +1,48 @@
import { View, Text, Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import { useSettings } from "../../../providers/WatchProvider";
import { LANGUAGES } from "@i18n/index";
export function LanguagePicker() {
const { t } = useTranslation("settings");
const { language, setLanguage } = useSettings();
return (
<View className="mb-6">
<Text className="text-sm font-medium text-slate-500 mb-2">{t("language")}</Text>
<View className="flex-row flex-wrap gap-2">
<Pressable
onPress={() => setLanguage("system")}
className={`px-3 py-2 rounded-xl ${
language === "system" ? "bg-sky-500" : "bg-slate-100"
}`}
>
<Text
className={`text-sm font-medium ${
language === "system" ? "text-white" : "text-slate-600"
}`}
>
{t("systemLanguage")}
</Text>
</Pressable>
{LANGUAGES.map((lang) => (
<Pressable
key={lang.code}
onPress={() => setLanguage(lang.code)}
className={`px-3 py-2 rounded-xl ${
language === lang.code ? "bg-sky-500" : "bg-slate-100"
}`}
>
<Text
className={`text-sm font-medium ${
language === lang.code ? "text-white" : "text-slate-600"
}`}
>
{lang.label}
</Text>
</Pressable>
))}
</View>
</View>
);
}
@@ -1,15 +1,21 @@
import { View, Text, Pressable } from "react-native";
import { useTranslation } from "react-i18next";
import * as Application from "expo-application";
import { useSettings } from "../../../providers/WatchProvider";
import { LanguagePicker } from "../components/LanguagePicker";
export function SettingsScreen() {
const { t } = useTranslation("settings");
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>
<Text className="text-2xl font-bold text-slate-900 mb-6">{t("settings")}</Text>
<LanguagePicker />
<View className="mb-6">
<Text className="text-sm font-medium text-slate-500 mb-2">Default Pool Length</Text>
<Text className="text-sm font-medium text-slate-500 mb-2">{t("defaultPoolLength")}</Text>
<View className="flex-row gap-3">
{[25, 50].map((len) => (
<Pressable
@@ -32,7 +38,7 @@ export function SettingsScreen() {
</View>
<View className="border-t border-slate-200 pt-4 mt-2">
<Text className="text-sm text-slate-400">SwimBuddy v1.0.0</Text>
<Text className="text-sm text-slate-400">SwimBuddy v{Application.nativeApplicationVersion}</Text>
</View>
</View>
);
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from "react";
import { getTemplates, deleteTemplate, toggleTemplateFavorite } from "@db/operations";
import type { WorkoutSet } from "@types/workout";
import type { WorkoutSet } from "@/types/workout";
type Template = {
id: string;
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { View, Text, Pressable, FlatList, Alert } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
@@ -5,13 +6,14 @@ import type { WorkoutStackParamList } from "@navigation/types";
import { useTemplates } from "../hooks/useTemplates";
export function TemplatesScreen() {
const { t } = useTranslation("common");
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) },
Alert.alert(t("alert.deleteTemplate"), t("alert.deleteConfirm", { name }), [
{ text: t("cancel"), style: "cancel" },
{ text: t("delete"), style: "destructive", onPress: () => remove(id) },
]);
};
@@ -23,9 +25,9 @@ export function TemplatesScreen() {
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-lg">{t("alert.deleteTemplate")}</Text>
<Text className="text-slate-400 text-sm mt-1">
Save a workout as a template to reuse it
{t("noTemplatesYet")}
</Text>
</View>
}
@@ -38,7 +40,7 @@ export function TemplatesScreen() {
<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
{t("pool", { length: item.poolLength })}
</Text>
</View>
<View className="flex-row gap-2">
@@ -1,6 +1,8 @@
import { useState } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity";
import type { WorkoutSet } from "@types/workout";
import WearConnectivity from "../../../modules/wear-connectivity";
import type { WorkoutSet } from "@/types/workout";
type SyncResult = { status: string } | { error: string };
@@ -25,12 +27,36 @@ export function useSyncWorkout() {
repCount: s.repCount,
restType: s.restType,
restDuration: s.restDuration,
targetTime: s.targetTime ?? null,
...(s.targetTime != null ? { targetTime: s.targetTime } : {}),
})),
};
if (Platform.OS === "ios") {
const supported = await WatchConnectivity.isSupported();
const paired = await WatchConnectivity.isPaired();
const installed = await WatchConnectivity.isWatchAppInstalled();
const reachable = await WatchConnectivity.isReachable();
if (!supported || !paired || !installed) {
return { error: "Apple Watch not available" };
}
const result = await WatchConnectivity.sendWorkoutPlan(plan);
return result as SyncResult;
} else if (Platform.OS === "android") {
const supported = await WearConnectivity.isSupported();
const paired = await WearConnectivity.isPaired();
const reachable = await WearConnectivity.isReachable();
if (!supported || !paired) {
return { error: "WearOS watch not available" };
}
const result = await WearConnectivity.sendWorkoutPlan(plan);
return result as SyncResult;
}
return { error: "Platform not supported" };
} catch (error: any) {
return { error: error.message ?? "Failed to sync" };
} finally {
@@ -1,5 +1,7 @@
import { useState, useEffect } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity";
type WatchConnectionStatus = "connected" | "disconnected" | "pending";
@@ -16,6 +18,7 @@ export function useWatchConnection() {
const checkStatus = async () => {
try {
if (Platform.OS === "ios") {
const supported = await WatchConnectivity.isSupported();
if (!supported) {
setStatus("disconnected");
@@ -28,11 +31,29 @@ export function useWatchConnection() {
setIsPaired(paired);
setIsInstalled(installed);
setStatus(reachable ? "connected" : "disconnected");
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
} else if (Platform.OS === "android") {
const supported = await WearConnectivity.isSupported();
if (!supported) {
setStatus("disconnected");
return;
}
const paired = await WearConnectivity.isPaired();
const reachable = await WearConnectivity.isReachable();
setIsPaired(paired);
setIsInstalled(paired); // WearOS doesn't have a separate install check
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
} else {
setStatus("disconnected");
}
} catch {
setStatus("disconnected");
}
};
return { status, isPaired, isInstalled };
const isReady = isPaired && isInstalled;
return { status, isPaired, isInstalled, isReady };
}
@@ -0,0 +1,46 @@
import { useEffect, useRef } from "react";
import { Platform } from "react-native";
import WatchConnectivity from "../../../modules/watch-connectivity";
import WearConnectivity from "../../../modules/wear-connectivity";
import { saveWatchCompletedWorkout, deleteSession } from "@db/operations";
export function useWatchWorkoutSync() {
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
pollingRef.current = setInterval(async () => {
try {
let events: any[] = [];
if (Platform.OS === "ios") {
events = (await WatchConnectivity.pollEvents()) as any[];
} else if (Platform.OS === "android") {
events = (await WearConnectivity.pollEvents()) as any[];
}
for (const event of events) {
if (event.type === "workoutCompleted") {
await saveWatchCompletedWorkout({
workoutId: event.workoutId,
sessionId: event.sessionId,
duration: event.duration ?? 0,
totalDistance: event.totalDistance ?? 0,
calories: event.calories ?? 0,
laps: event.laps ?? [],
});
} else if (event.type === "workoutDeleted") {
await deleteSession(event.workoutId);
}
}
} catch (error) {
// Silently ignore polling errors
}
}, 2000);
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
}
};
}, []);
}
@@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import type { WorkoutSet, StrokeType, RestType } from "@types/workout";
import type { WorkoutSet, StrokeType, RestType } from "@/types/workout";
import { saveWorkout, saveTemplate } from "@db/operations";
import i18n from "@i18n/index";
const DEFAULT_SET: WorkoutSet = {
stroke: "Freestyle",
@@ -33,7 +34,7 @@ export function useWorkoutBuilder(initialSets?: WorkoutSet[]) {
async (poolLength: number, templateId?: string) => {
setIsSaving(true);
try {
const name = workoutName.trim() || `Workout ${new Date().toLocaleDateString()}`;
const name = workoutName.trim() || i18n.t("workout:untitledWorkout");
const id = await saveWorkout(name, poolLength, sets, templateId);
return id;
} finally {
@@ -1,17 +1,23 @@
import { useState } from "react";
import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
import { View, Text, TextInput, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from "react-native";
import { useTranslation } from "react-i18next";
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";
import type { StrokeType } from "@/types/workout";
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
export function BuilderScreen() {
const { t } = useTranslation("workout");
const { t: tc } = useTranslation("common");
const { t: tb } = useTranslation("navigation");
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { poolLength } = useSettings();
const { poolLength: defaultPoolLength } = useSettings();
const [poolLength, setPoolLength] = useState(defaultPoolLength);
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
const {
sets,
workoutName,
@@ -30,42 +36,70 @@ export function BuilderScreen() {
navigation.navigate("PreSwim", { workoutId });
} catch (e) {
console.error("Failed to save workout:", e);
Alert.alert("Error", `Failed to save workout: ${e}`);
Alert.alert(tc("validation.error"), tc("validation.failedToSave", { error: String(e) }));
}
};
return (
<ScrollView className="flex-1 bg-white">
<KeyboardAvoidingView
className="flex-1"
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
<ScrollView className="flex-1 bg-white" contentContainerStyle={{ paddingBottom: 40 }}>
<View className="p-4">
<Text className="text-2xl font-bold text-slate-900 mb-1">Build Workout</Text>
<Text className="text-2xl font-bold text-slate-900 mb-1">{tb("buildWorkout")}</Text>
<Text className="text-slate-500 mb-4">
{totalDistance}m total · {poolLength}m pool
{totalDistance}m total · {tc("pool", { length: poolLength })}
</Text>
<View className="mb-4">
<Text className="text-sm font-medium text-slate-500 mb-1">Workout Name</Text>
<Text className="text-sm font-medium text-slate-500 mb-1">{t("workoutName")}</Text>
<TextInput
value={workoutName}
onChangeText={setWorkoutName}
placeholder="Morning Swim"
placeholder={t("placeholder.workoutName")}
placeholderTextColor="#94a3b8"
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-slate-900"
/>
</View>
<View className="mb-4">
<Text className="text-sm font-medium text-slate-500 mb-1">{t("poolLength")}</Text>
<View className="flex-row gap-2">
{[25, 50].map((length) => (
<Pressable
key={length}
onPress={() => setPoolLength(length)}
className={`flex-1 py-2.5 rounded-xl items-center ${
poolLength === length ? "bg-sky-500" : "bg-slate-100"
}`}
>
<Text
className={`font-semibold ${
poolLength === length ? "text-white" : "text-slate-600"
}`}
>
{length}m
</Text>
</Pressable>
))}
</View>
</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>
<Text className="font-semibold text-slate-700">{t("setNumber", { number: index + 1 })}</Text>
{sets.length > 1 && (
<Pressable onPress={() => removeSet(index)}>
<Text className="text-red-500 text-sm font-medium">Remove</Text>
<Text className="text-red-500 text-sm font-medium">{t("remove")}</Text>
</Pressable>
)}
</View>
<Text className="text-xs font-medium text-slate-500 mb-1.5 uppercase tracking-wide">
Stroke
{t("stroke")}
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-3">
{STROKES.map((stroke) => (
@@ -81,7 +115,7 @@ export function BuilderScreen() {
set.stroke === stroke ? "text-white" : "text-slate-600"
}`}
>
{stroke}
{tc(`stroke.${stroke.toLowerCase()}`)}
</Text>
</Pressable>
))}
@@ -89,29 +123,62 @@ export function BuilderScreen() {
<View className="flex-row gap-2">
<View className="flex-1">
<Text className="text-xs font-medium text-slate-500 mb-1">Distance (m)</Text>
<Text className="text-xs font-medium text-slate-500 mb-1">{t("distanceM")}</Text>
<TextInput
keyboardType="numeric"
value={String(set.distancePerRep)}
onChangeText={(t) => updateSet(index, { distancePerRep: Number(t) || 0 })}
value={fieldValues[`${index}-distance`] ?? String(set.distancePerRep)}
onChangeText={(val) => setFieldValues((prev) => ({ ...prev, [`${index}-distance`]: val }))}
onBlur={() => {
const num = Number(fieldValues[`${index}-distance`] ?? set.distancePerRep);
updateSet(index, { distancePerRep: isNaN(num) || num < poolLength ? poolLength : num });
setFieldValues((prev) => {
const next = { ...prev };
delete next[`${index}-distance`];
return next;
});
}}
placeholder={String(poolLength)}
placeholderTextColor="#94a3b8"
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
</View>
<View className="flex-1">
<Text className="text-xs font-medium text-slate-500 mb-1">Reps</Text>
<Text className="text-xs font-medium text-slate-500 mb-1">{t("reps")}</Text>
<TextInput
keyboardType="numeric"
value={String(set.repCount)}
onChangeText={(t) => updateSet(index, { repCount: Number(t) || 1 })}
value={fieldValues[`${index}-rep`] ?? String(set.repCount)}
onChangeText={(val) => setFieldValues((prev) => ({ ...prev, [`${index}-rep`]: val }))}
onBlur={() => {
const num = Number(fieldValues[`${index}-rep`] ?? set.repCount);
updateSet(index, { repCount: isNaN(num) || num < 1 ? 1 : num });
setFieldValues((prev) => {
const next = { ...prev };
delete next[`${index}-rep`];
return next;
});
}}
placeholder="1"
placeholderTextColor="#94a3b8"
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
</View>
<View className="flex-1">
<Text className="text-xs font-medium text-slate-500 mb-1">Rest (s)</Text>
<Text className="text-xs font-medium text-slate-500 mb-1">{t("restS")}</Text>
<TextInput
keyboardType="numeric"
value={String(set.restDuration)}
onChangeText={(t) => updateSet(index, { restDuration: Number(t) || 0 })}
value={fieldValues[`${index}-rest`] ?? String(set.restDuration)}
onChangeText={(val) => setFieldValues((prev) => ({ ...prev, [`${index}-rest`]: val }))}
onBlur={() => {
const num = Number(fieldValues[`${index}-rest`] ?? set.restDuration);
updateSet(index, { restDuration: isNaN(num) || num < 0 ? 0 : num });
setFieldValues((prev) => {
const next = { ...prev };
delete next[`${index}-rest`];
return next;
});
}}
placeholder="0"
placeholderTextColor="#94a3b8"
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
/>
</View>
@@ -123,7 +190,7 @@ export function BuilderScreen() {
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>
<Text className="text-sky-600 font-semibold">{t("addSet")}</Text>
</Pressable>
<Pressable
@@ -132,10 +199,11 @@ export function BuilderScreen() {
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"}
{isSaving ? tc("saving") : t("startSwim")}
</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
@@ -1,32 +1,73 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, FlatList } from "react-native";
import { useCallback, useState } from "react";
import { View, Text, Pressable, FlatList, Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { WorkoutStackParamList } from "@navigation/types";
import { getAllWorkouts, deleteWorkout } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { getAllWorkouts, getWorkoutWithSets, deleteWorkoutAndSync } from "@db/operations";
import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = {
id: string;
name: string | null;
totalDistance: number | null;
totalDuration: number | null;
startedAt: string;
finishedAt: string | null;
poolLength: number;
createdAt: string;
totalDistance?: number;
};
export function WorkoutListScreen() {
const { t } = useTranslation("workout");
const { t: tc } = useTranslation("common");
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadWorkouts();
const loadWorkouts = useCallback(async () => {
const data = await getAllWorkouts();
const rows = data as WorkoutRow[];
// Bereken totalDistance per workout op basis van sets
const withDistance = await Promise.all(
rows.map(async (w) => {
const detail = await getWorkoutWithSets(w.id);
if (!detail) return w;
const totalDistance = (detail.sets ?? []).reduce(
(acc: number, s: any) => acc + s.distancePerRep * s.repCount,
0
);
return { ...w, totalDistance };
})
);
setWorkouts(withDistance);
}, []);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts(data as WorkoutRow[]);
useFocusEffect(
useCallback(() => {
loadWorkouts();
}, [loadWorkouts])
);
const onRefresh = async () => {
setRefreshing(true);
await loadWorkouts();
setRefreshing(false);
};
const onDelete = (id: string) => {
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
{ text: tc("cancel"), style: "cancel" },
{
text: tc("delete"),
style: "destructive",
onPress: async () => {
await deleteWorkoutAndSync(id);
setWorkouts((prev) => prev.filter((w) => w.id !== id));
},
},
]);
};
return (
@@ -34,15 +75,9 @@ export function WorkoutListScreen() {
<View className="p-4 pb-2">
<Pressable
onPress={() => navigation.navigate("Builder")}
className="bg-sky-500 rounded-2xl p-4 items-center mb-3"
className="bg-sky-500 rounded-2xl p-4 items-center mb-4"
>
<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>
<Text className="text-white font-bold text-lg">{t("newWorkout")}</Text>
</Pressable>
</View>
@@ -50,37 +85,36 @@ export function WorkoutListScreen() {
data={workouts}
keyExtractor={(item) => item.id}
contentContainerClassName="px-4 pb-8"
refreshing={refreshing}
onRefresh={onRefresh}
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>
<Text className="text-slate-400 text-lg">{t("noWorkoutsYet")}</Text>
<Text className="text-slate-400 text-sm mt-1">{t("tapNewWorkout")}</Text>
</View>
}
renderItem={({ item }) => (
<SwipeableRow onDelete={() => onDelete(item.id)} deleteLabel={tc("delete")}>
<Pressable
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200"
className="p-4"
>
<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"}
{item.name || tc("untitledWorkout")}
</Text>
<Text className="text-slate-500 text-sm mt-1">
{formatDistance(item.totalDistance ?? 0)}
{item.totalDuration ? ` · ${formatTime(item.totalDuration)}` : ""}
{item.totalDistance ? formatDistance(item.totalDistance) : ""}
{item.poolLength ? ` · ${tc("pool", { length: item.poolLength })}` : ""}
</Text>
<Text className="text-slate-400 text-xs mt-1">
{new Date(item.startedAt).toLocaleDateString()}
{new Date(item.createdAt).toLocaleDateString()}
</Text>
</View>
{item.finishedAt && (
<View className="bg-green-100 rounded-full px-2 py-0.5">
<Text className="text-green-700 text-xs font-medium">Done</Text>
</View>
)}
</View>
</Pressable>
</SwipeableRow>
)}
/>
</View>
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Workout an Watch gesendet!",
"openWatchApp": "Öffne die SwimBuddy-App auf deiner Watch, um mit der Verfolgung zu beginnen.",
"failedToSend": "Senden fehlgeschlagen",
"couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.",
"watchRequired": "Eine gekoppelte Watch wird benötigt, um ein Workout zu starten",
"retry": "Erneut",
"sendToWatchAndStart": "An Watch senden & starten",
"syncToWatch": "Workout wird mit deiner Watch synchronisiert",
"rest": "Pause",
"betweenSets": "{{duration}}s Pause zwischen Sets",
"swimComplete": "Schwimmen abgeschlossen!",
"greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!",
"loadingSummary": "Zusammenfassung laden...",
"sets": "Sets",
"lapsDetail": "Bahnen",
"avgHeartRate": "Durchschn. Herzfrequenz",
"calories": "Kalorien",
"pace": "Tempo",
"lengths": "Bahnen",
"avgPace": "Ø Tempo",
"setRep": "Set {{set}} · Wiederholung {{rep}}",
"watchStatus": {
"ready": "Watch bereit",
"pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite",
"pairedNotInstalled": "Watch gekoppelt, App nicht installiert",
"noWatch": "Keine Watch gekoppelt"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Fertig",
"cancel": "Abbrechen",
"delete": "Löschen",
"start": "Starten",
"back": "Zurück",
"loading": "Laden...",
"error": "Fehler",
"save": "Speichern",
"saving": "Speichern...",
"yes": "Ja",
"no": "Nein",
"confirm": "Bestätigen",
"ok": "OK",
"distance": "Distanz",
"duration": "Dauer",
"workouts": "Workouts",
"sets": "Sets",
"laps": "Bahnen",
"avgSwolf": "Durchschn. SWOLF",
"pool": "{{length}}m Becken",
"untitledWorkout": "Unbenannter Workout",
"swimSession": "Schwimmsitzung",
"workout": "Workout",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Freistil",
"backstroke": "Rücken",
"breaststroke": "Brust",
"butterfly": "Schmetterling",
"mixed": "Gemischt"
},
"alert": {
"deleteWorkout": "Workout löschen",
"deleteTemplate": "Vorlage löschen",
"areYouSure": "Bist du sicher?",
"deleteConfirm": "\"{{name}}\" löschen?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Noch keine abgeschlossenen Workouts",
"lap": "Bahn {{number}}",
"statistics": "Statistiken",
"thisWeek": "Diese Woche",
"allTime": "Gesamtzeit",
"personalBests": "Persönliche Bestleistungen",
"noDataYet": "Noch keine Daten",
"swolf": "SWOLF: {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Workouts",
"history": "Verlauf",
"settings": "Einstellungen",
"newWorkout": "Neues Workout",
"buildWorkout": "Workout erstellen",
"startSwim": "Schwimmen starten",
"readyToSwim": "Bereit zum Schwimmen",
"swimComplete": "Schwimmen abgeschlossen",
"session": "Sitzung",
"sessionDetail": "Sitzungsdetails"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Einstellungen",
"defaultPoolLength": "Standard-Beckenlänge",
"language": "Sprache",
"systemLanguage": "Systemsprache"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Workout-Name",
"poolLength": "Beckenlänge",
"stroke": "Lage",
"distanceM": "Distanz (m)",
"reps": "Wiederholungen",
"restS": "Pause (s)",
"addSet": "+ Set hinzufügen",
"remove": "Entfernen",
"setNumber": "Set {{number}}",
"startSwim": "Schwimmen starten",
"newWorkout": "+ Neues Workout",
"noWorkoutsYet": "Noch keine Workouts",
"tapNewWorkout": "Tippe auf \"Neues Workout\" um zu beginnen",
"morningSwim": "Morgenschwimmen",
"placeholder": {
"workoutName": "Morgenschwimmen"
},
"validation": {
"error": "Fehler",
"failedToSave": "Workout konnte nicht gespeichert werden: {{error}}"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Træning sendt til Watch!",
"openWatchApp": "Åbn SwimBuddy-appen på din Watch for at begynde sporing.",
"failedToSend": "Sendning mislykkedes",
"couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.",
"watchRequired": "En parret Watch er påkrævet for at starte en træning",
"retry": "Prøv igen",
"sendToWatchAndStart": "Send til Watch & start",
"syncToWatch": "Træningen synkroniseres med din Watch",
"rest": "Hvile",
"betweenSets": "{{duration}}s hvile mellem sæt",
"swimComplete": "Svømning fuldført!",
"greatJob": "Godt gået! Du var i vandet i dag!",
"loadingSummary": "Indlæser opsummering...",
"sets": "Sæt",
"lapsDetail": "Baner",
"avgHeartRate": "Gns. puls",
"calories": "Kalorier",
"pace": "Tempo",
"lengths": "Baner",
"avgPace": "Snit-tempo",
"setRep": "Sæt {{set}} · Gentagelse {{rep}}",
"watchStatus": {
"ready": "Watch klar",
"pairedNotInRange": "Watch parret, men ikke inden for rækkevidde",
"pairedNotInstalled": "Watch parret, app ikke installeret",
"noWatch": "Ingen Watch parret"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Færdig",
"cancel": "Annuller",
"delete": "Slet",
"start": "Start",
"back": "Tilbage",
"loading": "Indlæser...",
"error": "Fejl",
"save": "Gem",
"saving": "Gemmer...",
"yes": "Ja",
"no": "Nej",
"confirm": "Bekræft",
"ok": "OK",
"distance": "Distance",
"duration": "Varighed",
"workouts": "Træninger",
"sets": "Sæt",
"laps": "Baner",
"avgSwolf": "Gns. SWOLF",
"pool": "{{length}}m pool",
"untitledWorkout": "Unavngiven træning",
"swimSession": "Svømmesession",
"workout": "Træning",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Fri crawl",
"backstroke": "Rygcrawl",
"breaststroke": "Brystsvømning",
"butterfly": "Butterfly",
"mixed": "Blandet"
},
"alert": {
"deleteWorkout": "Slet træning",
"deleteTemplate": "Slet skabelon",
"areYouSure": "Er du sikker?",
"deleteConfirm": "Slet \"{{name}}\"?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Ingen fuldførte træninger endnu",
"lap": "Bane {{number}}",
"statistics": "Statistikker",
"thisWeek": "Denne uge",
"allTime": "Alt tid",
"personalBests": "Personlige rekorder",
"noDataYet": "Ingen data endnu",
"swolf": "SWOLF: {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Træninger",
"history": "Historik",
"settings": "Indstillinger",
"newWorkout": "Ny træning",
"buildWorkout": "Opret træning",
"startSwim": "Start svømning",
"readyToSwim": "Klar til at svømme",
"swimComplete": "Svømning fuldført",
"session": "Session",
"sessionDetail": "Sessionsdetaljer"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Indstillinger",
"defaultPoolLength": "Standard poolængde",
"language": "Sprog",
"systemLanguage": "Systemssprog"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Træningsnavn",
"poolLength": "Poolængde",
"stroke": "Svømmearm",
"distanceM": "Distance (m)",
"reps": "Gentagelser",
"restS": "Hvile (s)",
"addSet": "+ Tilføj sæt",
"remove": "Fjern",
"setNumber": "Sæt {{number}}",
"startSwim": "Start svømning",
"newWorkout": "+ Ny træning",
"noWorkoutsYet": "Ingen træninger endnu",
"tapNewWorkout": "Tryk på \"Ny træning\" for at komme i gang",
"morningSwim": "Morgensvømning",
"placeholder": {
"workoutName": "Morgensvømning"
},
"validation": {
"error": "Fejl",
"failedToSave": "Kunne ikke gemme træningen: {{error}}"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Workout sent to Watch!",
"openWatchApp": "Open the SwimBuddy app on your watch to start tracking.",
"failedToSend": "Failed to send",
"couldNotSend": "Could not send workout to Watch. Starting without Watch instead.",
"watchRequired": "A paired Watch is required to start a workout",
"retry": "Retry",
"sendToWatchAndStart": "Send to Watch & Start",
"syncToWatch": "Workout will be synced to your watch",
"rest": "Rest",
"betweenSets": "{{duration}}s between sets",
"swimComplete": "Swim Complete!",
"greatJob": "Great job getting in the water today!",
"loadingSummary": "Loading summary...",
"sets": "Sets",
"lapsDetail": "Laps",
"avgHeartRate": "Avg Heart Rate",
"calories": "Calories",
"pace": "Pace",
"lengths": "Lengths",
"avgPace": "Avg pace",
"setRep": "Set {{set}} · Rep {{rep}}",
"watchStatus": {
"ready": "Watch ready",
"pairedNotInRange": "Watch paired but not in range",
"pairedNotInstalled": "Watch paired, app not installed",
"noWatch": "No Watch paired"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Done",
"cancel": "Cancel",
"delete": "Delete",
"start": "Start",
"back": "Back",
"loading": "Loading...",
"error": "Error",
"save": "Save",
"saving": "Saving...",
"yes": "Yes",
"no": "No",
"confirm": "Confirm",
"ok": "OK",
"distance": "Distance",
"duration": "Duration",
"workouts": "Workouts",
"sets": "Sets",
"laps": "Laps",
"avgSwolf": "Avg SWOLF",
"pool": "{{length}}m pool",
"untitledWorkout": "Untitled Workout",
"swimSession": "Swim Session",
"workout": "Workout",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Freestyle",
"backstroke": "Backstroke",
"breaststroke": "Breaststroke",
"butterfly": "Butterfly",
"mixed": "Mixed"
},
"alert": {
"deleteWorkout": "Delete Workout",
"deleteTemplate": "Delete Template",
"areYouSure": "Are you sure?",
"deleteConfirm": "Delete \"{{name}}\"?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "No completed workouts yet",
"lap": "Lap {{number}}",
"statistics": "Statistics",
"thisWeek": "This Week",
"allTime": "All Time",
"personalBests": "Personal Bests",
"noDataYet": "No data yet",
"swolf": "SWOLF: {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Workouts",
"history": "History",
"settings": "Settings",
"newWorkout": "New Workout",
"buildWorkout": "Build Workout",
"startSwim": "Start Swim",
"readyToSwim": "Ready to Swim",
"swimComplete": "Swim Complete",
"session": "Session",
"sessionDetail": "Session Detail"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Settings",
"defaultPoolLength": "Default Pool Length",
"language": "Language",
"systemLanguage": "System Language"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Workout Name",
"poolLength": "Pool Length",
"stroke": "Stroke",
"distanceM": "Distance (m)",
"reps": "Reps",
"restS": "Rest (s)",
"addSet": "+ Add Set",
"remove": "Remove",
"setNumber": "Set {{number}}",
"startSwim": "Start Swim",
"newWorkout": "+ New Workout",
"noWorkoutsYet": "No workouts yet",
"tapNewWorkout": "Tap \"New Workout\" to get started",
"morningSwim": "Morning Swim",
"placeholder": {
"workoutName": "Morning Swim"
},
"validation": {
"error": "Error",
"failedToSave": "Failed to save workout: {{error}}"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "¡Entrenamiento enviado al Watch!",
"openWatchApp": "Abre la app SwimBuddy en tu Watch para empezar el seguimiento.",
"failedToSend": "Error al enviar",
"couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.",
"watchRequired": "Se necesita una Watch emparejada para iniciar un entrenamiento",
"retry": "Reintentar",
"sendToWatchAndStart": "Enviar al Watch e iniciar",
"syncToWatch": "El entrenamiento se sincronizará con tu Watch",
"rest": "Descanso",
"betweenSets": "{{duration}}s de descanso entre series",
"swimComplete": "¡Natación completada!",
"greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!",
"loadingSummary": "Cargando resumen...",
"sets": "Series",
"lapsDetail": "Largos",
"avgHeartRate": "Freq. card. media",
"calories": "Calorías",
"pace": "Ritmo",
"lengths": "Tiradas",
"avgPace": "Ritmo medio",
"setRep": "Serie {{set}} · Repetición {{rep}}",
"watchStatus": {
"ready": "Watch lista",
"pairedNotInRange": "Watch emparejada pero fuera de alcance",
"pairedNotInstalled": "Watch emparejada, app no instalada",
"noWatch": "Ninguna Watch emparejada"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Listo",
"cancel": "Cancelar",
"delete": "Eliminar",
"start": "Iniciar",
"back": "Volver",
"loading": "Cargando...",
"error": "Error",
"save": "Guardar",
"saving": "Guardando...",
"yes": "Sí",
"no": "No",
"confirm": "Confirmar",
"ok": "OK",
"distance": "Distancia",
"duration": "Duración",
"workouts": "Entrenamientos",
"sets": "Series",
"laps": "Largos",
"avgSwolf": "SWOLF prom.",
"pool": "Piscina de {{length}}m",
"untitledWorkout": "Entrenamiento sin nombre",
"swimSession": "Sesión de natación",
"workout": "Entrenamiento",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Libre",
"backstroke": "Espalda",
"breaststroke": "Pecho",
"butterfly": "Mariposa",
"mixed": "Mixto"
},
"alert": {
"deleteWorkout": "Eliminar entrenamiento",
"deleteTemplate": "Eliminar plantilla",
"areYouSure": "¿Estás seguro?",
"deleteConfirm": "¿Eliminar \"{{name}}\"?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Aún no hay entrenamientos completados",
"lap": "Largo {{number}}",
"statistics": "Estadísticas",
"thisWeek": "Esta semana",
"allTime": "Todos los tiempos",
"personalBests": "Récords personales",
"noDataYet": "Aún no hay datos",
"swolf": "SWOLF: {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Entrenamientos",
"history": "Historial",
"settings": "Ajustes",
"newWorkout": "Nuevo entrenamiento",
"buildWorkout": "Crear entrenamiento",
"startSwim": "Iniciar natación",
"readyToSwim": "Listo para nadar",
"swimComplete": "Natación completada",
"session": "Sesión",
"sessionDetail": "Detalle de sesión"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Ajustes",
"defaultPoolLength": "Longitud de piscina predeterminada",
"language": "Idioma",
"systemLanguage": "Idioma del sistema"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Nombre del entrenamiento",
"poolLength": "Longitud de la piscina",
"stroke": "Estilo",
"distanceM": "Distancia (m)",
"reps": "Repeticiones",
"restS": "Descanso (s)",
"addSet": "+ Añadir serie",
"remove": "Eliminar",
"setNumber": "Serie {{number}}",
"startSwim": "Iniciar natación",
"newWorkout": "+ Nuevo entrenamiento",
"noWorkoutsYet": "Aún no hay entrenamientos",
"tapNewWorkout": "Toca \"Nuevo entrenamiento\" para empezar",
"morningSwim": "Natación matutina",
"placeholder": {
"workoutName": "Natación matutina"
},
"validation": {
"error": "Error",
"failedToSave": "No se pudo guardar el entrenamiento: {{error}}"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Entraînement envoyé à la Watch !",
"openWatchApp": "Ouvrez l'application SwimBuddy sur votre Watch pour commencer le suivi.",
"failedToSend": "Échec de l'envoi",
"couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.",
"watchRequired": "Une Watch appariée est nécessaire pour commencer un entraînement",
"retry": "Réessayez",
"sendToWatchAndStart": "Envoyer à la Watch et commencer",
"syncToWatch": "L'entraînement sera synchronisé avec votre Watch",
"rest": "Repos",
"betweenSets": "{{duration}}s de repos entre les séries",
"swimComplete": "Nage terminée !",
"greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !",
"loadingSummary": "Chargement du résumé...",
"sets": "Séries",
"lapsDetail": "Longueurs",
"avgHeartRate": "Fréq. cardiaque moy.",
"calories": "Calories",
"pace": "Rythme",
"lengths": "Longueurs",
"avgPace": "Rythme moy.",
"setRep": "Série {{set}} · Répétition {{rep}}",
"watchStatus": {
"ready": "Watch prête",
"pairedNotInRange": "Watch appariée mais hors de portée",
"pairedNotInstalled": "Watch appariée, application non installée",
"noWatch": "Aucune Watch appariée"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Terminé",
"cancel": "Annuler",
"delete": "Supprimer",
"start": "Commencer",
"back": "Retour",
"loading": "Chargement...",
"error": "Erreur",
"save": "Enregistrer",
"saving": "Enregistrement...",
"yes": "Oui",
"no": "Non",
"confirm": "Confirmer",
"ok": "OK",
"distance": "Distance",
"duration": "Durée",
"workouts": "Entraînements",
"sets": "Séries",
"laps": "Longueurs",
"avgSwolf": "SWOLF moy.",
"pool": "Bassin de {{length}}m",
"untitledWorkout": "Entraînement sans titre",
"swimSession": "Séance de natation",
"workout": "Entraînement",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Nage libre",
"backstroke": "Dos",
"breaststroke": "Brasse",
"butterfly": "Papillon",
"mixed": "Mixte"
},
"alert": {
"deleteWorkout": "Supprimer l'entraînement",
"deleteTemplate": "Supprimer le modèle",
"areYouSure": "Êtes-vous sûr ?",
"deleteConfirm": "Supprimer \"{{name}}\" ?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Aucun entraînement terminé pour le moment",
"lap": "Longueur {{number}}",
"statistics": "Statistiques",
"thisWeek": "Cette semaine",
"allTime": "Tout le temps",
"personalBests": "Records personnels",
"noDataYet": "Pas encore de données",
"swolf": "SWOLF : {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Entraînements",
"history": "Historique",
"settings": "Paramètres",
"newWorkout": "Nouvel entraînement",
"buildWorkout": "Créer un entraînement",
"startSwim": "Commencer la nage",
"readyToSwim": "Prêt à nager",
"swimComplete": "Nage terminée",
"session": "Séance",
"sessionDetail": "Détails de la séance"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Paramètres",
"defaultPoolLength": "Longueur du bassin par défaut",
"language": "Langue",
"systemLanguage": "Langue du système"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Nom de l'entraînement",
"poolLength": "Longueur du bassin",
"stroke": "Style",
"distanceM": "Distance (m)",
"reps": "Répétitions",
"restS": "Repos (s)",
"addSet": "+ Ajouter une série",
"remove": "Retirer",
"setNumber": "Série {{number}}",
"startSwim": "Commencer la nage",
"newWorkout": "+ Nouvel entraînement",
"noWorkoutsYet": "Aucun entraînement pour le moment",
"tapNewWorkout": "Appuyez sur \"Nouvel entraînement\" pour commencer",
"morningSwim": "Matinée de natation",
"placeholder": {
"workoutName": "Matinée de natation"
},
"validation": {
"error": "Erreur",
"failedToSave": "Échec de l'enregistrement : {{error}}"
}
}
+231
View File
@@ -0,0 +1,231 @@
import { useRef, useEffect } from "react";
import { AppState, type AppStateStatus } from "react-native";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { getLocales } from "expo-localization";
import { createMMKV } from "react-native-mmkv";
import enCommon from "./en/common.json";
import enNavigation from "./en/navigation.json";
import enWorkout from "./en/workout.json";
import enActiveWorkout from "./en/active-workout.json";
import enHistory from "./en/history.json";
import enSettings from "./en/settings.json";
import nlCommon from "./nl/common.json";
import nlNavigation from "./nl/navigation.json";
import nlWorkout from "./nl/workout.json";
import nlActiveWorkout from "./nl/active-workout.json";
import nlHistory from "./nl/history.json";
import nlSettings from "./nl/settings.json";
import deCommon from "./de/common.json";
import deNavigation from "./de/navigation.json";
import deWorkout from "./de/workout.json";
import deActiveWorkout from "./de/active-workout.json";
import deHistory from "./de/history.json";
import deSettings from "./de/settings.json";
import frCommon from "./fr/common.json";
import frNavigation from "./fr/navigation.json";
import frWorkout from "./fr/workout.json";
import frActiveWorkout from "./fr/active-workout.json";
import frHistory from "./fr/history.json";
import frSettings from "./fr/settings.json";
import itCommon from "./it/common.json";
import itNavigation from "./it/navigation.json";
import itWorkout from "./it/workout.json";
import itActiveWorkout from "./it/active-workout.json";
import itHistory from "./it/history.json";
import itSettings from "./it/settings.json";
import esCommon from "./es/common.json";
import esNavigation from "./es/navigation.json";
import esWorkout from "./es/workout.json";
import esActiveWorkout from "./es/active-workout.json";
import esHistory from "./es/history.json";
import esSettings from "./es/settings.json";
import plCommon from "./pl/common.json";
import plNavigation from "./pl/navigation.json";
import plWorkout from "./pl/workout.json";
import plActiveWorkout from "./pl/active-workout.json";
import plHistory from "./pl/history.json";
import plSettings from "./pl/settings.json";
import ptCommon from "./pt/common.json";
import ptNavigation from "./pt/navigation.json";
import ptWorkout from "./pt/workout.json";
import ptActiveWorkout from "./pt/active-workout.json";
import ptHistory from "./pt/history.json";
import ptSettings from "./pt/settings.json";
import dkCommon from "./dk/common.json";
import dkNavigation from "./dk/navigation.json";
import dkWorkout from "./dk/workout.json";
import dkActiveWorkout from "./dk/active-workout.json";
import dkHistory from "./dk/history.json";
import dkSettings from "./dk/settings.json";
export const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "nl", label: "Nederlands" },
{ code: "de", label: "Deutsch" },
{ code: "fr", label: "Français" },
{ code: "it", label: "Italiano" },
{ code: "es", label: "Español" },
{ code: "pl", label: "Polski" },
{ code: "pt", label: "Português" },
{ code: "dk", label: "Dansk" },
] as const;
export type LanguageCode = (typeof LANGUAGES)[number]["code"];
export type StoredLanguage = LanguageCode | "system";
const namespaces = [
"common",
"navigation",
"workout",
"active-workout",
"history",
"settings",
] as const;
export type Namespace = (typeof namespaces)[number];
const storage = createMMKV({ id: "i18n-settings" });
export function getStoredLanguage(): StoredLanguage | undefined {
const stored = storage.getString("language");
if (stored === "system") return "system";
if (stored && LANGUAGES.some((l) => l.code === stored)) {
return stored as LanguageCode;
}
return undefined;
}
export function setStoredLanguage(code: StoredLanguage) {
storage.set("language", code);
}
export function getDeviceLanguage(): LanguageCode {
const deviceLocale = getLocales()[0]?.languageCode ?? "en";
if (LANGUAGES.some((l) => l.code === deviceLocale)) {
return deviceLocale as LanguageCode;
}
return "en";
}
export function resolveLanguage(stored: StoredLanguage | undefined): LanguageCode {
if (stored === "system" || stored === undefined) return getDeviceLanguage();
return stored;
}
export function useSystemLanguageListener() {
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextState === "active") {
const stored = getStoredLanguage();
if (stored === "system") {
const deviceLang = getDeviceLanguage();
if (i18n.language !== deviceLang) {
i18n.changeLanguage(deviceLang);
}
}
}
appState.current = nextState;
});
return () => subscription.remove();
}, []);
}
i18n.use(initReactI18next).init({
resources: {
en: {
common: enCommon,
navigation: enNavigation,
workout: enWorkout,
"active-workout": enActiveWorkout,
history: enHistory,
settings: enSettings,
},
nl: {
common: nlCommon,
navigation: nlNavigation,
workout: nlWorkout,
"active-workout": nlActiveWorkout,
history: nlHistory,
settings: nlSettings,
},
de: {
common: deCommon,
navigation: deNavigation,
workout: deWorkout,
"active-workout": deActiveWorkout,
history: deHistory,
settings: deSettings,
},
fr: {
common: frCommon,
navigation: frNavigation,
workout: frWorkout,
"active-workout": frActiveWorkout,
history: frHistory,
settings: frSettings,
},
it: {
common: itCommon,
navigation: itNavigation,
workout: itWorkout,
"active-workout": itActiveWorkout,
history: itHistory,
settings: itSettings,
},
es: {
common: esCommon,
navigation: esNavigation,
workout: esWorkout,
"active-workout": esActiveWorkout,
history: esHistory,
settings: esSettings,
},
pl: {
common: plCommon,
navigation: plNavigation,
workout: plWorkout,
"active-workout": plActiveWorkout,
history: plHistory,
settings: plSettings,
},
pt: {
common: ptCommon,
navigation: ptNavigation,
workout: ptWorkout,
"active-workout": ptActiveWorkout,
history: ptHistory,
settings: ptSettings,
},
dk: {
common: dkCommon,
navigation: dkNavigation,
workout: dkWorkout,
"active-workout": dkActiveWorkout,
history: dkHistory,
settings: dkSettings,
},
},
lng: resolveLanguage(getStoredLanguage()),
fallbackLng: "en",
ns: [...namespaces],
defaultNS: "common",
interpolation: {
escapeValue: false,
},
});
export default i18n;
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Allenamento inviato alla Watch!",
"openWatchApp": "Apri l'app SwimBuddy sulla tua Watch per iniziare il monitoraggio.",
"failedToSend": "Invio fallito",
"couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.",
"watchRequired": "È necessaria una Watch associata per iniziare un allenamento",
"retry": "Riprova",
"sendToWatchAndStart": "Invia alla Watch e avvia",
"syncToWatch": "L'allenamento verrà sincronizzato con la tua Watch",
"rest": "Recupero",
"betweenSets": "{{duration}}s di recupero tra le serie",
"swimComplete": "Nuoto completato!",
"greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!",
"loadingSummary": "Caricamento riepilogo...",
"sets": "Serie",
"lapsDetail": "Lunghezze",
"avgHeartRate": "Freq. card. media",
"calories": "Calorie",
"pace": "Ritmo",
"lengths": "Vasche",
"avgPace": "Passo medio",
"setRep": "Serie {{set}} · Ripetizione {{rep}}",
"watchStatus": {
"ready": "Watch pronta",
"pairedNotInRange": "Watch associata ma fuori portata",
"pairedNotInstalled": "Watch associata, app non installata",
"noWatch": "Nessuna Watch associata"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Fatto",
"cancel": "Annulla",
"delete": "Elimina",
"start": "Inizia",
"back": "Indietro",
"loading": "Caricamento...",
"error": "Errore",
"save": "Salva",
"saving": "Salvataggio...",
"yes": "Sì",
"no": "No",
"confirm": "Conferma",
"ok": "OK",
"distance": "Distanza",
"duration": "Durata",
"workouts": "Allenamenti",
"sets": "Serie",
"laps": "Lunghezze",
"avgSwolf": "SWOLF media",
"pool": "Piscina di {{length}}m",
"untitledWorkout": "Allenamento senza nome",
"swimSession": "Sessione di nuoto",
"workout": "Allenamento",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Stile libero",
"backstroke": "Dorso",
"breaststroke": "Rana",
"butterfly": "Farfalla",
"mixed": "Misto"
},
"alert": {
"deleteWorkout": "Elimina allenamento",
"deleteTemplate": "Elimina modello",
"areYouSure": "Sei sicuro?",
"deleteConfirm": "Eliminare \"{{name}}\"?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Nessun allenamento completato ancora",
"lap": "Lunghezza {{number}}",
"statistics": "Statistiche",
"thisWeek": "Questa settimana",
"allTime": "Tutti i tempi",
"personalBests": "Record personali",
"noDataYet": "Nessun dato ancora",
"swolf": "SWOLF: {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Allenamenti",
"history": "Cronologia",
"settings": "Impostazioni",
"newWorkout": "Nuovo allenamento",
"buildWorkout": "Crea allenamento",
"startSwim": "Inizia nuoto",
"readyToSwim": "Pronto per nuotare",
"swimComplete": "Nuoto completato",
"session": "Sessione",
"sessionDetail": "Dettagli sessione"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Impostazioni",
"defaultPoolLength": "Lunghezza piscina predefinita",
"language": "Lingua",
"systemLanguage": "Lingua di sistema"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Nome allenamento",
"poolLength": "Lunghezza piscina",
"stroke": "Stile",
"distanceM": "Distanza (m)",
"reps": "Ripetizioni",
"restS": "Recupero (s)",
"addSet": "+ Aggiungi serie",
"remove": "Rimuovi",
"setNumber": "Serie {{number}}",
"startSwim": "Inizia nuoto",
"newWorkout": "+ Nuovo allenamento",
"noWorkoutsYet": "Nessun allenamento ancora",
"tapNewWorkout": "Tocca \"Nuovo allenamento\" per iniziare",
"morningSwim": "Nuoto mattutino",
"placeholder": {
"workoutName": "Nuoto mattutino"
},
"validation": {
"error": "Errore",
"failedToSave": "Impossibile salvare l'allenamento: {{error}}"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Workout verzonden naar Watch!",
"openWatchApp": "Open de SwimBuddy-app op je Watch om te beginnen met bijhouden.",
"failedToSend": "Verzenden mislukt",
"couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.",
"watchRequired": "Een gekoppelde Watch is vereist om een workout te starten",
"retry": "Opnieuw",
"sendToWatchAndStart": "Verzenden naar Watch & Start",
"syncToWatch": "Workout wordt gesynchroniseerd naar je Watch",
"rest": "Rust",
"betweenSets": "{{duration}}s rust tussen sets",
"swimComplete": "Zwemmen voltooid!",
"greatJob": "Goed gedaan! Je bent vandaag het water in geweest!",
"loadingSummary": "Samenvatting laden...",
"sets": "Sets",
"lapsDetail": "Banen",
"avgHeartRate": "Gem. hartslag",
"calories": "Calorieën",
"pace": "Tempo",
"lengths": "Banen",
"avgPace": "Gem. tempo",
"setRep": "Set {{set}} · Herhaling {{rep}}",
"watchStatus": {
"ready": "Watch gereed",
"pairedNotInRange": "Watch gekoppeld maar niet in bereik",
"pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd",
"noWatch": "Geen Watch gekoppeld"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Gereed",
"cancel": "Annuleren",
"delete": "Verwijderen",
"start": "Start",
"back": "Terug",
"loading": "Laden...",
"error": "Fout",
"save": "Opslaan",
"saving": "Opslaan...",
"yes": "Ja",
"no": "Nee",
"confirm": "Bevestigen",
"ok": "OK",
"distance": "Afstand",
"duration": "Duur",
"workouts": "Workouts",
"sets": "Sets",
"laps": "Banen",
"avgSwolf": "Gem. SWOLF",
"pool": "{{length}}m bad",
"untitledWorkout": "Naamloze Workout",
"swimSession": "Zwemsessie",
"workout": "Workout",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Vrije slag",
"backstroke": "Rugslag",
"breaststroke": "Schoolslag",
"butterfly": "Vlinderslag",
"mixed": "Gemengd"
},
"alert": {
"deleteWorkout": "Workout verwijderen",
"deleteTemplate": "Sjabloon verwijderen",
"areYouSure": "Weet je het zeker?",
"deleteConfirm": "\"{{name}}\" verwijderen?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Nog geen voltooide workouts",
"lap": "Baan {{number}}",
"statistics": "Statistieken",
"thisWeek": "Deze week",
"allTime": "Alle tijd",
"personalBests": "Persoonlijke records",
"noDataYet": "Nog geen gegevens",
"swolf": "SWOLF: {{value}}"
}
+12
View File
@@ -0,0 +1,12 @@
{
"workouts": "Workouts",
"history": "Geschiedenis",
"settings": "Instellingen",
"newWorkout": "Nieuwe Workout",
"buildWorkout": "Workout samenstellen",
"startSwim": "Start Zwemmen",
"readyToSwim": "Klaar om te zwemmen",
"swimComplete": "Zwemmen voltooid",
"session": "Sessie",
"sessionDetail": "Sessiedetails"
}
+6
View File
@@ -0,0 +1,6 @@
{
"settings": "Instellingen",
"defaultPoolLength": "Standaard badlengte",
"language": "Taal",
"systemLanguage": "Systeemtaal"
}
+23
View File
@@ -0,0 +1,23 @@
{
"workoutName": "Workoutnaam",
"poolLength": "Badlengte",
"stroke": "Slag",
"distanceM": "Afstand (m)",
"reps": "Herhalingen",
"restS": "Rust (s)",
"addSet": "+ Set toevoegen",
"remove": "Verwijderen",
"setNumber": "Set {{number}}",
"startSwim": "Start Zwemmen",
"newWorkout": "+ Nieuwe Workout",
"noWorkoutsYet": "Nog geen workouts",
"tapNewWorkout": "Tik op \"Nieuwe Workout\" om te beginnen",
"morningSwim": "Ochtendzwemmen",
"placeholder": {
"workoutName": "Ochtendzwemmen"
},
"validation": {
"error": "Fout",
"failedToSave": "Workout opslaan mislukt: {{error}}"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"workoutSentToWatch": "Trening wysłany do Watch!",
"openWatchApp": "Otwórz aplikację SwimBuddy na swojej Watch, aby rozpocząć śledzenie.",
"failedToSend": "Nie udało się wysłać",
"couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.",
"watchRequired": "Sparowana Watch jest wymagana do rozpoczęcia treningu",
"retry": "Ponów",
"sendToWatchAndStart": "Wyślij do Watch i rozpocznij",
"syncToWatch": "Trening zostanie zsynchronizowany z Watch",
"rest": "Odpoczynek",
"betweenSets": "{{duration}}s odpoczynku między seriami",
"swimComplete": "Pływanie zakończone!",
"greatJob": "Świetna robota! Dziś byłeś w wodzie!",
"loadingSummary": "Ładowanie podsumowania...",
"sets": "Serie",
"lapsDetail": "Długości",
"avgHeartRate": "Śr. tętno",
"calories": "Kalorie",
"pace": "Tempo",
"lengths": "Długości",
"avgPace": "Śr. tempo",
"setRep": "Serie {{set}} · Powtórzenie {{rep}}",
"watchStatus": {
"ready": "Watch gotowa",
"pairedNotInRange": "Watch sparowana, ale poza zasięgiem",
"pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana",
"noWatch": "Brak sparowanej Watch"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"done": "Gotowe",
"cancel": "Anuluj",
"delete": "Usuń",
"start": "Rozpocznij",
"back": "Wstecz",
"loading": "Ładowanie...",
"error": "Błąd",
"save": "Zapisz",
"saving": "Zapisywanie...",
"yes": "Tak",
"no": "Nie",
"confirm": "Potwierdź",
"ok": "OK",
"distance": "Dystans",
"duration": "Czas",
"workouts": "Treningi",
"sets": "Serie",
"laps": "Długości",
"avgSwolf": "Śr. SWOLF",
"pool": "Basen {{length}}m",
"untitledWorkout": "Trening bez nazwy",
"swimSession": "Sesja pływacka",
"workout": "Trening",
"kcal": "{{count}} kcal",
"meter": "{{count}}m",
"kilometer": "{{count}}km",
"pace": "{{pace}}/100m",
"noPace": "--:--",
"stroke": {
"freestyle": "Styl dowolny",
"backstroke": "Grzbietowy",
"breaststroke": "Klasyk",
"butterfly": "Delfin",
"mixed": "Mieszany"
},
"alert": {
"deleteWorkout": "Usuń trening",
"deleteTemplate": "Usuń szablon",
"areYouSure": "Na pewno?",
"deleteConfirm": "Usunąć \"{{name}}\"?"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"noCompletedYet": "Brak ukończonych treningów",
"lap": "Długość {{number}}",
"statistics": "Statystyki",
"thisWeek": "Ten tydzień",
"allTime": "Cały okres",
"personalBests": "Rekordy osobiste",
"noDataYet": "Brak danych",
"swolf": "SWOLF: {{value}}"
}

Some files were not shown because too many files have changed in this diff Show More