Skip to content
Merged
4 changes: 2 additions & 2 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default {
expo: {
name: IS_DEV ? "Muscle Quest (Dev)" : "Muscle Quest",
slug: "musclequest",
version: "0.21.05", // MM.mm.pp
version: "1.0.0", // MM.mm.pp
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "musclequest",
Expand All @@ -14,7 +14,7 @@ export default {
bundleIdentifier: "com.isotronic.musclequest",
},
android: {
versionCode: 2105, // MMmmpp
versionCode: 10000, // MMmmpp
googleServicesFile: "./google-services.json",
adaptiveIcon: {
foregroundImage: "./assets/images/ic_launcher_foreground.png",
Expand Down
192 changes: 189 additions & 3 deletions app/(app)/(workout)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, ScrollView, StyleSheet, Alert, TextInput } from "react-native";
import {
View,
ScrollView,
StyleSheet,
Alert,
TextInput,
TouchableOpacity,
} from "react-native";
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import Sortable from "react-native-sortables";
Expand All @@ -18,7 +25,7 @@ import {
import { useActiveWorkoutStore } from "@/store/activeWorkoutStore";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { router, Stack } from "expo-router";
import { router, Stack, useFocusEffect } from "expo-router";
import { Colors } from "@/constants/Colors";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useSaveCompletedWorkoutMutation } from "@/hooks/useSaveCompletedWorkoutMutation";
Expand All @@ -34,10 +41,27 @@ import {
createStandaloneWorkout,
linkCompletedWorkoutToWorkout,
} from "@/utils/database";
import { cancelRestNotifications } from "@/utils/restNotification";
import {
cancelRestNotifications,
scheduleRestNotificationWithCancellation,
} from "@/utils/restNotification";
import { convertTimeStrToSeconds } from "@/utils/utility";
import { useQueryClient } from "@tanstack/react-query";
import { UserExercise, Workout } from "@/store/workoutStore";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
import { useTimer } from "react-timer-hook";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useSoundStore } from "@/store/soundStore";

const AnimatedView = Animated.View as unknown as React.ComponentType<{
style?: any;
pointerEvents?: "auto" | "none" | "box-none" | "box-only";
children?: React.ReactNode;
}>;

type SingleItem = {
type: "single";
Expand Down Expand Up @@ -83,6 +107,10 @@ export default function WorkoutOverviewScreen() {
initializeWeightAndReps,
removeFromSuperset,
setDurations,
timerRunning,
timerExpiry,
stopTimer,
startTimer,
} = useActiveWorkoutStore();

const stableKeyMapRef = useRef(new WeakMap<UserExercise, string>());
Expand Down Expand Up @@ -125,6 +153,81 @@ export default function WorkoutOverviewScreen() {

useKeepScreenOn();

const parsedIncrement = parseInt(settings?.restTimerIncrement || "15", 10);
const restTimerIncrement =
Number.isFinite(parsedIncrement) && parsedIncrement > 0
? parsedIncrement
: 15;
const { playSound, triggerVibration } = useSoundStore();
const insets = useSafeAreaInsets();

const isFocusedRef = useRef(true);
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
return () => {
isFocusedRef.current = false;
};
}, []),
);

const expiryTimestampRef = useRef<Date | null>(null);
const timerTranslateY = useSharedValue(timerRunning ? 0 : 200);
const timerAnimStyle = useAnimatedStyle(() => ({
transform: [{ translateY: timerTranslateY.value }],
}));

const { seconds, minutes, restart } = useTimer({
expiryTimestamp: timerExpiry || new Date(),
autoStart: timerRunning,
onExpire: () => {
stopTimer();
if (isFocusedRef.current) {
if (settings?.restTimerSound === "true") void playSound();
if (settings?.restTimerVibration === "true") triggerVibration();
}
},
});

useEffect(() => {
timerTranslateY.value = withTiming(timerRunning ? 0 : 200, {
duration: 300,
});
}, [timerRunning, timerTranslateY]);

useEffect(() => {
if (timerRunning && timerExpiry) {
const time = new Date(timerExpiry);
expiryTimestampRef.current = time;
restart(time);
}
}, [timerRunning, timerExpiry, restart]);

const adjustTimerOverview = async (deltaSeconds: number) => {
const currentRemaining = expiryTimestampRef.current
? Math.max(
0,
Math.round(
(expiryTimestampRef.current.getTime() - Date.now()) / 1000,
),
)
: minutes * 60 + seconds;
const newRemaining = Math.max(0, currentRemaining + deltaSeconds);
const newExpiry = new Date();
newExpiry.setSeconds(newExpiry.getSeconds() + newRemaining);
expiryTimestampRef.current = newExpiry;
startTimer(newExpiry);
restart(newExpiry);
if (settings?.restTimerNotification === "true") {
await scheduleRestNotificationWithCancellation(
newRemaining,
"Rest Timer Finished!",
"Time to do your next set!",
"rest-timer1",
);
}
};

const [isSaving, setIsSaving] = useState(false);
const [loadingExerciseIndex, setLoadingExerciseIndex] = useState<
number | null
Expand Down Expand Up @@ -889,6 +992,39 @@ export default function WorkoutOverviewScreen() {
<Trans>Add Exercise</Trans>
</Button>
</ScrollView>
<AnimatedView
pointerEvents={timerRunning ? "auto" : "none"}
style={[
styles.timerContainer,
{ paddingBottom: insets.bottom },
timerAnimStyle,
]}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<ThemedText style={styles.timerLabel}>
<Trans>Rest Time Left:</Trans>
</ThemedText>
<View style={styles.timerRow}>
<TouchableOpacity
style={styles.timerAdjustButton}
onPress={() => void adjustTimerOverview(-restTimerIncrement)}
>
<ThemedText style={styles.timerAdjustText}>
<Trans>−{restTimerIncrement}s</Trans>
</ThemedText>
</TouchableOpacity>
<ThemedText style={styles.timerText}>
{minutes}:{seconds.toString().padStart(2, "0")}
</ThemedText>
<TouchableOpacity
style={styles.timerAdjustButton}
onPress={() => void adjustTimerOverview(restTimerIncrement)}
>
<ThemedText style={styles.timerAdjustText}>
<Trans>+{restTimerIncrement}s</Trans>
</ThemedText>
</TouchableOpacity>
</View>
</AnimatedView>
</ThemedView>
);
}
Expand Down Expand Up @@ -1051,4 +1187,54 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
},
timerContainer: {
position: "absolute",
bottom: 0,
right: 16,
left: 16,
paddingTop: 8,
paddingBottom: 8,
backgroundColor: Colors.dark.cardBackground,
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
shadowColor: "#000",
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
marginBottom: 0,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
timerLabel: {
fontSize: 14,
color: Colors.dark.text,
marginBottom: 4,
textAlign: "center",
},
timerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 16,
},
timerAdjustButton: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: Colors.dark.cardBackground2,
},
timerAdjustText: {
fontSize: 14,
fontWeight: "600",
color: Colors.dark.text,
},
timerText: {
fontSize: 32,
fontWeight: "bold",
color: Colors.dark.text,
textAlign: "center",
lineHeight: 32,
marginBottom: 8,
},
});
7 changes: 5 additions & 2 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
module.exports = function (api) {
api.cache(true);
// @lingui/babel-plugin-lingui-macro is a native ESM module that can't run
// synchronously in Jest's transform pipeline — skip it during tests.
const isTest = api.env("test");
api.cache.using(() => process.env.NODE_ENV);
return {
presets: ["babel-preset-expo"],
plugins: ["@lingui/babel-plugin-lingui-macro"],
plugins: isTest ? [] : ["@lingui/babel-plugin-lingui-macro"],
};
};
3 changes: 2 additions & 1 deletion hooks/useWorkoutDurationEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fetchSetDurationsForExercises } from "@/utils/database";
import {
computeWorkoutDurationEstimate,
type DurationEstimate,
type SetDurationSample,
} from "@/utils/estimateWorkoutDuration";
import type { UserExercise } from "@/store/workoutStore";
import Bugsnag from "@bugsnag/expo";
Expand All @@ -20,7 +21,7 @@ export function useWorkoutDurationEstimate(
[exercises],
);

const { data, isLoading } = useQuery<Record<number, number[]>>({
const { data, isLoading } = useQuery<Record<number, SetDurationSample[]>>({
queryKey: ["exerciseSetDurations", exerciseIds],
queryFn: async () => {
try {
Expand Down
Loading
Loading