feat(workouts): add swipe-to-delete with gesture handler and pull-to-refresh
This commit is contained in:
@@ -25,7 +25,7 @@ src/
|
|||||||
navigation/ # React Navigation (RootNav, TabNav, feature stacks)
|
navigation/ # React Navigation (RootNav, TabNav, feature stacks)
|
||||||
db/ # Drizzle schema, client, relations, operations
|
db/ # Drizzle schema, client, relations, operations
|
||||||
providers/ # DatabaseProvider, SettingsProvider (MMKV)
|
providers/ # DatabaseProvider, SettingsProvider (MMKV)
|
||||||
utils/ # formatTime, calculateSwolf, etc.
|
utils/ # formatTime, calculateSwolf, uuid, etc.
|
||||||
constants/ # strokes, poolLengths, colors
|
constants/ # strokes, poolLengths, colors
|
||||||
types/ # workout.ts, common.ts
|
types/ # workout.ts, common.ts
|
||||||
modules/watch-connectivity/# Expo native module bridging WCSession
|
modules/watch-connectivity/# Expo native module bridging WCSession
|
||||||
@@ -45,7 +45,7 @@ targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives preb
|
|||||||
- Lives in `targets/watch/`, NOT in `ios/` — survives `expo prebuild --clean`
|
- Lives in `targets/watch/`, NOT in `ios/` — survives `expo prebuild --clean`
|
||||||
- Config: `targets/watch/expo-target.config.js`
|
- Config: `targets/watch/expo-target.config.js`
|
||||||
- Build: open `ios/SwimBuddy.xcworkspace` in Xcode, select Watch scheme
|
- 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
|
- WCSession for real-time messaging, `transferUserInfo` for reliable offline sync
|
||||||
- HealthKit for swim workout tracking (indoor swimming)
|
- HealthKit for swim workout tracking (indoor swimming)
|
||||||
- Swift types `StoredWorkout`, `StoredSet`, `CompletedLap` in `WorkoutManager.swift`
|
- Swift types `StoredWorkout`, `StoredSet`, `CompletedLap` in `WorkoutManager.swift`
|
||||||
@@ -53,12 +53,40 @@ targets/watch/ # Apple Watch app (Swift/SwiftUI) — survives preb
|
|||||||
## Database
|
## Database
|
||||||
|
|
||||||
- **expo-sqlite** + **Drizzle ORM** (SQLite dialect, expo driver)
|
- **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, sets, laps
|
||||||
- Relations: `src/db/relations.ts`
|
- Relations: `src/db/relations.ts`
|
||||||
- CRUD: `src/db/operations.ts`
|
- CRUD: `src/db/operations.ts`
|
||||||
- Client: `src/db/client.ts` — exports `db` and `DATABASE_NAME`
|
- Client: `src/db/client.ts` — exports `db` and `DATABASE_NAME`
|
||||||
- Migrations in `drizzle/` — generated by `npx drizzle-kit generate`
|
- 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`)
|
||||||
|
|
||||||
|
## NativeWind v5 TextInput Crash
|
||||||
|
|
||||||
|
`react-native-css` has a bug where the `textAlign` mapping on `TextInput` causes a crash (`path.split` on boolean `true`). The fix is in `src/utils/` — don't remove the workaround without testing TextInput rendering.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
|
- `metro.config.js` has `.sql` in `sourceExts` — required for Drizzle migration imports
|
||||||
|
|||||||
Generated
+59
-2
@@ -24,6 +24,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-css": "^3.0.7",
|
"react-native-css": "^3.0.7",
|
||||||
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
"react-native-mmkv": "^4.3.2",
|
"react-native-mmkv": "^4.3.2",
|
||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
@@ -1610,6 +1611,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@egjs/hammerjs": {
|
||||||
|
"version": "2.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
||||||
|
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hammerjs": "^2.0.36"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild-kit/core-utils": {
|
"node_modules/@esbuild-kit/core-utils": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
|
||||||
@@ -3774,6 +3787,12 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/hammerjs": {
|
||||||
|
"version": "2.0.46",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
||||||
|
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -3817,12 +3836,20 @@
|
|||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-test-renderer": {
|
||||||
|
"version": "19.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||||
|
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -4639,7 +4666,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -5992,6 +6018,21 @@
|
|||||||
"hermes-estree": "0.35.0"
|
"hermes-estree": "0.35.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||||
@@ -7938,6 +7979,22 @@
|
|||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-gesture-handler": {
|
||||||
|
"version": "2.32.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz",
|
||||||
|
"integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@egjs/hammerjs": "^2.0.17",
|
||||||
|
"@types/react-test-renderer": "^19.1.0",
|
||||||
|
"hoist-non-react-statics": "^3.3.0",
|
||||||
|
"invariant": "^2.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-is-edge-to-edge": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.86.2",
|
"react-native": "0.86.2",
|
||||||
"react-native-css": "^3.0.7",
|
"react-native-css": "^3.0.7",
|
||||||
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
"react-native-mmkv": "^4.3.2",
|
"react-native-mmkv": "^4.3.2",
|
||||||
"react-native-reanimated": "4.5.1",
|
"react-native-reanimated": "4.5.1",
|
||||||
"react-native-safe-area-context": "~5.7.0",
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
|
||||||
|
import { PanGestureHandler, State } from "react-native-gesture-handler";
|
||||||
|
|
||||||
|
type SwipeableRowProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SwipeableRow({ children, onDelete }: SwipeableRowProps) {
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const lastOffset = useRef(0);
|
||||||
|
|
||||||
|
const onGestureEvent = Animated.event([{ nativeEvent: { translationX: translateX } }], {
|
||||||
|
useNativeDriver: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onHandlerStateChange = (event: any) => {
|
||||||
|
if (event.oldState === State.ACTIVE) {
|
||||||
|
const { translationX } = event.nativeEvent;
|
||||||
|
const newOffset = lastOffset.current + translationX;
|
||||||
|
|
||||||
|
if (newOffset < -80) {
|
||||||
|
// Swiped left enough — show delete
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: -80,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
lastOffset.current = -80;
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
Animated.spring(translateX, {
|
||||||
|
toValue: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
lastOffset.current = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
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}>Delete</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<PanGestureHandler
|
||||||
|
onGestureEvent={onGestureEvent}
|
||||||
|
onHandlerStateChange={onHandlerStateChange}
|
||||||
|
>
|
||||||
|
<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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { View, Text, Pressable, FlatList } from "react-native";
|
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import type { HistoryStackParamList } from "@navigation/types";
|
import type { HistoryStackParamList } from "@navigation/types";
|
||||||
import { getAllWorkouts } from "@db/operations";
|
import { getAllWorkouts, deleteWorkout } from "@db/operations";
|
||||||
import { formatTime } from "@utils/formatTime";
|
import { formatTime } from "@utils/formatTime";
|
||||||
import { formatDistance } from "@utils/formatDistance";
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
import { SwipeableRow } from "@components/SwipeableRow";
|
||||||
|
|
||||||
type WorkoutRow = {
|
type WorkoutRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,16 +22,39 @@ type WorkoutRow = {
|
|||||||
export function HistoryScreen() {
|
export function HistoryScreen() {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
||||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
loadWorkouts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadWorkouts = async () => {
|
const loadWorkouts = async () => {
|
||||||
const data = await getAllWorkouts();
|
const data = await getAllWorkouts();
|
||||||
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
|
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadWorkouts();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadWorkouts();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = (id: string) => {
|
||||||
|
Alert.alert("Delete Workout", "Are you sure?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteWorkout(id);
|
||||||
|
await loadWorkouts();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
|
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
|
||||||
const date = new Date(w.startedAt).toLocaleDateString();
|
const date = new Date(w.startedAt).toLocaleDateString();
|
||||||
if (!acc[date]) acc[date] = [];
|
if (!acc[date]) acc[date] = [];
|
||||||
@@ -41,19 +66,12 @@ export function HistoryScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white">
|
<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
|
<FlatList
|
||||||
data={sections}
|
data={sections}
|
||||||
keyExtractor={([date]) => date}
|
keyExtractor={([date]) => date}
|
||||||
contentContainerClassName="px-4 pb-8"
|
contentContainerClassName="px-4 pb-8"
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="py-12 items-center">
|
<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">No completed workouts yet</Text>
|
||||||
@@ -65,10 +83,10 @@ export function HistoryScreen() {
|
|||||||
{date}
|
{date}
|
||||||
</Text>
|
</Text>
|
||||||
{rows.map((w) => (
|
{rows.map((w) => (
|
||||||
|
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
key={w.id}
|
|
||||||
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
|
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
|
||||||
className="bg-slate-50 rounded-2xl p-4 mb-2 border border-slate-200"
|
className="p-4"
|
||||||
>
|
>
|
||||||
<View className="flex-row justify-between items-center">
|
<View className="flex-row justify-between items-center">
|
||||||
<View>
|
<View>
|
||||||
@@ -89,6 +107,7 @@ export function HistoryScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</SwipeableRow>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { View, Text, ScrollView } from "react-native";
|
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import type { HistoryStackProps } from "@navigation/types";
|
import type { HistoryStackProps } from "@navigation/types";
|
||||||
import { getWorkoutWithSets } from "@db/operations";
|
import { getWorkoutWithSets, deleteWorkout } from "@db/operations";
|
||||||
import { formatTime } from "@utils/formatTime";
|
import { formatTime } from "@utils/formatTime";
|
||||||
import { formatDistance } from "@utils/formatDistance";
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
import { STROKE_COLORS } from "@constants/strokes";
|
import { STROKE_COLORS } from "@constants/strokes";
|
||||||
@@ -34,6 +35,7 @@ type WorkoutDetail = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
|
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
|
||||||
|
const navigation = useNavigation();
|
||||||
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
|
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -45,6 +47,20 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
setWorkout(data as WorkoutDetail | null);
|
setWorkout(data as WorkoutDetail | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
Alert.alert("Delete Workout", "Are you sure?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteWorkout(route.params.sessionId);
|
||||||
|
navigation.goBack();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
if (!workout) {
|
if (!workout) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white items-center justify-center">
|
<View className="flex-1 bg-white items-center justify-center">
|
||||||
@@ -62,7 +78,8 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView className="flex-1 bg-white p-4">
|
<ScrollView className="flex-1 bg-white p-4">
|
||||||
<View className="items-center mb-6">
|
<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">
|
<Text className="text-2xl font-bold text-slate-900 mb-1">
|
||||||
{workout.name || "Swim Session"}
|
{workout.name || "Swim Session"}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -70,6 +87,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
|||||||
{new Date(workout.startedAt).toLocaleDateString()}
|
{new Date(workout.startedAt).toLocaleDateString()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDelete}
|
||||||
|
className="bg-red-50 rounded-xl p-2.5"
|
||||||
|
>
|
||||||
|
<Text className="text-red-500 text-lg">🗑</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex-row gap-3 mb-6">
|
<View className="flex-row gap-3 mb-6">
|
||||||
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butte
|
|||||||
|
|
||||||
export function BuilderScreen() {
|
export function BuilderScreen() {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
const { poolLength } = useSettings();
|
const { poolLength: defaultPoolLength } = useSettings();
|
||||||
|
const [poolLength, setPoolLength] = useState(defaultPoolLength);
|
||||||
const {
|
const {
|
||||||
sets,
|
sets,
|
||||||
workoutName,
|
workoutName,
|
||||||
@@ -53,6 +54,29 @@ export function BuilderScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm font-medium text-slate-500 mb-1">Pool Length</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) => (
|
{sets.map((set, index) => (
|
||||||
<View key={index} className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200">
|
<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">
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { View, Text, Pressable, FlatList } from "react-native";
|
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { useFocusEffect } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
import type { WorkoutStackParamList } from "@navigation/types";
|
import type { WorkoutStackParamList } from "@navigation/types";
|
||||||
import { getAllWorkouts, deleteWorkout } from "@db/operations";
|
import { getAllWorkouts, deleteWorkout } from "@db/operations";
|
||||||
import { formatTime } from "@utils/formatTime";
|
import { formatTime } from "@utils/formatTime";
|
||||||
import { formatDistance } from "@utils/formatDistance";
|
import { formatDistance } from "@utils/formatDistance";
|
||||||
|
import { SwipeableRow } from "@components/SwipeableRow";
|
||||||
|
|
||||||
type WorkoutRow = {
|
type WorkoutRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,37 +21,56 @@ type WorkoutRow = {
|
|||||||
export function WorkoutListScreen() {
|
export function WorkoutListScreen() {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
useEffect(() => {
|
|
||||||
loadWorkouts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadWorkouts = async () => {
|
const loadWorkouts = async () => {
|
||||||
const data = await getAllWorkouts();
|
const data = await getAllWorkouts();
|
||||||
setWorkouts(data as WorkoutRow[]);
|
setWorkouts(data as WorkoutRow[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
loadWorkouts();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
await loadWorkouts();
|
||||||
|
setRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = (id: string) => {
|
||||||
|
Alert.alert("Delete Workout", "Are you sure?", [
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteWorkout(id);
|
||||||
|
await loadWorkouts();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 bg-white">
|
<View className="flex-1 bg-white">
|
||||||
<View className="p-4 pb-2">
|
<View className="p-4 pb-2">
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => navigation.navigate("Builder")}
|
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>
|
<Text className="text-white font-bold text-lg">+ New Workout</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
|
||||||
onPress={() => navigation.navigate("Templates")}
|
|
||||||
className="bg-slate-100 rounded-2xl p-3 items-center mb-4"
|
|
||||||
>
|
|
||||||
<Text className="text-sky-600 font-semibold">Browse Templates</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={workouts}
|
data={workouts}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
contentContainerClassName="px-4 pb-8"
|
contentContainerClassName="px-4 pb-8"
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="py-12 items-center">
|
<View className="py-12 items-center">
|
||||||
<Text className="text-slate-400 text-lg">No workouts yet</Text>
|
<Text className="text-slate-400 text-lg">No workouts yet</Text>
|
||||||
@@ -57,9 +78,10 @@ export function WorkoutListScreen() {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
|
<SwipeableRow onDelete={() => onDelete(item.id)}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
|
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-row justify-between items-start">
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
@@ -81,6 +103,7 @@ export function WorkoutListScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</SwipeableRow>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
|||||||
import { HistoryStackParamList } from "./types";
|
import { HistoryStackParamList } from "./types";
|
||||||
import { HistoryScreen } from "../features/history/screens/HistoryScreen";
|
import { HistoryScreen } from "../features/history/screens/HistoryScreen";
|
||||||
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
|
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
|
||||||
import { StatsScreen } from "../features/history/screens/StatsScreen";
|
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<HistoryStackParamList>();
|
const Stack = createNativeStackNavigator<HistoryStackParamList>();
|
||||||
|
|
||||||
@@ -25,11 +24,6 @@ export function HistoryNavigator() {
|
|||||||
component={SessionDetailScreen}
|
component={SessionDetailScreen}
|
||||||
options={{ title: "Session Detail" }}
|
options={{ title: "Session Detail" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="Stats"
|
|
||||||
component={StatsScreen}
|
|
||||||
options={{ title: "Statistics" }}
|
|
||||||
/>
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen
|
|||||||
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
|
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
|
||||||
import { PostSwimScreen } from "../features/active-workout/screens/PostSwimScreen";
|
import { PostSwimScreen } from "../features/active-workout/screens/PostSwimScreen";
|
||||||
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
|
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
|
||||||
import { StatsScreen } from "../features/history/screens/StatsScreen";
|
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
@@ -43,11 +42,6 @@ export function RootNavigator() {
|
|||||||
component={SessionDetailScreen}
|
component={SessionDetailScreen}
|
||||||
options={{ title: "Session" }}
|
options={{ title: "Session" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="Stats"
|
|
||||||
component={StatsScreen}
|
|
||||||
options={{ title: "Statistics" }}
|
|
||||||
/>
|
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
|||||||
import { WorkoutStackParamList } from "./types";
|
import { WorkoutStackParamList } from "./types";
|
||||||
import { WorkoutListScreen } from "../features/workout-builder/screens/WorkoutListScreen";
|
import { WorkoutListScreen } from "../features/workout-builder/screens/WorkoutListScreen";
|
||||||
import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen";
|
import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen";
|
||||||
import { TemplatesScreen } from "../features/templates/screens/TemplatesScreen";
|
|
||||||
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
|
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<WorkoutStackParamList>();
|
const Stack = createNativeStackNavigator<WorkoutStackParamList>();
|
||||||
@@ -26,11 +25,6 @@ export function WorkoutNavigator() {
|
|||||||
component={BuilderScreen}
|
component={BuilderScreen}
|
||||||
options={{ title: "Build Workout" }}
|
options={{ title: "Build Workout" }}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
|
||||||
name="Templates"
|
|
||||||
component={TemplatesScreen}
|
|
||||||
options={{ title: "Templates" }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="PreSwim"
|
name="PreSwim"
|
||||||
component={PreSwimScreen}
|
component={PreSwimScreen}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export type RootStackParamList = {
|
|||||||
PreSwim: { workoutId: string };
|
PreSwim: { workoutId: string };
|
||||||
PostSwim: { workoutId: string };
|
PostSwim: { workoutId: string };
|
||||||
SessionDetail: { sessionId: string };
|
SessionDetail: { sessionId: string };
|
||||||
Stats: undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MainTabParamList = {
|
export type MainTabParamList = {
|
||||||
@@ -19,14 +18,12 @@ export type MainTabParamList = {
|
|||||||
export type WorkoutStackParamList = {
|
export type WorkoutStackParamList = {
|
||||||
WorkoutList: undefined;
|
WorkoutList: undefined;
|
||||||
Builder: { templateId?: string } | undefined;
|
Builder: { templateId?: string } | undefined;
|
||||||
Templates: undefined;
|
|
||||||
PreSwim: { workoutId: string };
|
PreSwim: { workoutId: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistoryStackParamList = {
|
export type HistoryStackParamList = {
|
||||||
HistoryList: undefined;
|
HistoryList: undefined;
|
||||||
SessionDetail: { sessionId: string };
|
SessionDetail: { sessionId: string };
|
||||||
Stats: undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootStackProps<T extends keyof RootStackParamList> =
|
export type RootStackProps<T extends keyof RootStackParamList> =
|
||||||
|
|||||||
Reference in New Issue
Block a user