feat(ui): design polish pass — guide disc, button states, done screen
Some checks failed
Build & Release APK / build (push) Has been cancelled
Some checks failed
Build & Release APK / build (push) Has been cancelled
- GuideDisc breathes with work/rest inside the sweep ring; flicks pulse at flick rhythm since each 1s step re-triggers inflate - Button variants (primary/secondary/danger) with pressed feedback - Done screen: spring-in check ring + session stats card - Settings: round -/+ stepper, disabled at 5-30s bounds - Home hero glow, shared Card, drawn SVG gear replaces emoji Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,20 @@
|
|||||||
import { Link, Stack } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Pressable } from "react-native";
|
||||||
|
import Svg, { Circle, Path } from "react-native-svg";
|
||||||
import { hydrate } from "../lib/store";
|
import { hydrate } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import { C } from "../lib/theme";
|
||||||
|
|
||||||
|
function GearIcon() {
|
||||||
|
return (
|
||||||
|
<Svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke={C.muted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<Circle cx={12} cy={12} r={3} />
|
||||||
|
<Path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void hydrate();
|
void hydrate();
|
||||||
@@ -25,8 +36,10 @@ export default function RootLayout() {
|
|||||||
options={{
|
options={{
|
||||||
title: "Kegel",
|
title: "Kegel",
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<Link href="/settings" style={{ paddingHorizontal: 8, fontSize: 18 }}>
|
<Link href="/settings" asChild>
|
||||||
⚙️
|
<Pressable style={({ pressed }) => ({ paddingHorizontal: 8, paddingVertical: 4, opacity: pressed ? 0.6 : 1 })} hitSlop={8}>
|
||||||
|
<GearIcon />
|
||||||
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
import { ShapeIcon } from "../components/session-viz";
|
import { ShapeIcon } from "../components/session-viz";
|
||||||
import { Button, StatRow } from "../components/ui";
|
import { Button, Card, StatRow } from "../components/ui";
|
||||||
import { currentHold } from "../lib/store";
|
import { currentHold } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import { alpha, C } from "../lib/theme";
|
||||||
import { useStore } from "../lib/useStore";
|
import { useStore } from "../lib/useStore";
|
||||||
import { FLICK_REPS, HOLD_REPS, MAX_HOLD, PUSH_REPS, SESSIONS_PER_INCREMENT, sessionMinutes } from "../lib/workout";
|
import { FLICK_REPS, HOLD_REPS, MAX_HOLD, PUSH_REPS, SESSIONS_PER_INCREMENT, sessionMinutes } from "../lib/workout";
|
||||||
|
|
||||||
@@ -18,11 +18,13 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<View style={s.screen}>
|
<View style={s.screen}>
|
||||||
<View style={s.hero}>
|
<View style={s.hero}>
|
||||||
|
<View style={s.glowOuter} />
|
||||||
|
<View style={s.glowInner} />
|
||||||
<Text style={s.holdValue}>{hold}s</Text>
|
<Text style={s.holdValue}>{hold}s</Text>
|
||||||
<Text style={s.holdLabel}>current hold</Text>
|
<Text style={s.holdLabel}>current hold</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={s.card}>
|
<Card>
|
||||||
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||||
<StatRow
|
<StatRow
|
||||||
label="Next hold increase"
|
label="Next hold increase"
|
||||||
@@ -30,9 +32,9 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
<StatRow label="Session length" value={`~${sessionMinutes(hold)} min`} />
|
<StatRow label="Session length" value={`~${sessionMinutes(hold)} min`} />
|
||||||
<StatRow label="Last session" value={lastSessionAt ? new Date(lastSessionAt).toLocaleDateString() : "—"} />
|
<StatRow label="Last session" value={lastSessionAt ? new Date(lastSessionAt).toLocaleDateString() : "—"} />
|
||||||
</View>
|
</Card>
|
||||||
|
|
||||||
<View style={s.card}>
|
<Card>
|
||||||
<View style={s.legendRow}>
|
<View style={s.legendRow}>
|
||||||
<ShapeIcon shape="circle" size={12} color={C.accent} />
|
<ShapeIcon shape="circle" size={12} color={C.accent} />
|
||||||
<Text style={s.legendText}>Squeeze & hold — {HOLD_REPS} reps</Text>
|
<Text style={s.legendText}>Squeeze & hold — {HOLD_REPS} reps</Text>
|
||||||
@@ -45,7 +47,7 @@ export default function Home() {
|
|||||||
<ShapeIcon shape="x" size={12} color={C.accent} />
|
<ShapeIcon shape="x" size={12} color={C.accent} />
|
||||||
<Text style={s.legendText}>Quick squeezes & pushes — {FLICK_REPS} each</Text>
|
<Text style={s.legendText}>Quick squeezes & pushes — {FLICK_REPS} each</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Card>
|
||||||
|
|
||||||
<Button title="Start session" onPress={() => router.push("/session")} />
|
<Button title="Start session" onPress={() => router.push("/session")} />
|
||||||
|
|
||||||
@@ -59,23 +61,16 @@ export default function Home() {
|
|||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
||||||
hero: { alignItems: "center", paddingVertical: 28 },
|
hero: { alignItems: "center", justifyContent: "center", paddingVertical: 32 },
|
||||||
holdValue: { color: C.accent, fontSize: 72, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
glowOuter: { position: "absolute", width: 230, height: 230, borderRadius: 115, backgroundColor: alpha(C.accent, 0.05) },
|
||||||
holdLabel: { color: C.muted, fontSize: 14, textTransform: "uppercase", letterSpacing: 1 },
|
glowInner: { position: "absolute", width: 150, height: 150, borderRadius: 75, backgroundColor: alpha(C.accent, 0.08) },
|
||||||
card: {
|
holdValue: { color: C.accent, fontSize: 72, fontWeight: "800", fontVariant: ["tabular-nums"], letterSpacing: -1 },
|
||||||
backgroundColor: C.surface,
|
holdLabel: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.6, marginTop: 2 },
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: C.border,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 4,
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
legendRow: {
|
legendRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 11,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: C.border,
|
borderBottomColor: C.border,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import * as Haptics from "expo-haptics";
|
|||||||
import { useKeepAwake } from "expo-keep-awake";
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
import { Animated, StyleSheet, Text, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { BLOCK_SHAPE, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
|
import { BLOCK_SHAPE, DoneCheck, GuideDisc, ProgressRing, RoundDots, ShapeIcon, useStepProgress } from "../components/session-viz";
|
||||||
import { Button } from "../components/ui";
|
import { Button, Card, StatRow } from "../components/ui";
|
||||||
import { completeSession, currentHold, getState } from "../lib/store";
|
import { completeSession, currentHold, getState } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import { C } from "../lib/theme";
|
||||||
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
|
import { buildSession, SESSIONS_PER_INCREMENT, type StepKind } from "../lib/workout";
|
||||||
@@ -25,6 +25,8 @@ function stepHaptic(kind: StepKind) {
|
|||||||
void Haptics.impactAsync(work ? Haptics.ImpactFeedbackStyle.Heavy : Haptics.ImpactFeedbackStyle.Light);
|
void Haptics.impactAsync(work ? Haptics.ImpactFeedbackStyle.Heavy : Haptics.ImpactFeedbackStyle.Light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RING_SIZE = 300;
|
||||||
|
|
||||||
export default function Session() {
|
export default function Session() {
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -42,6 +44,14 @@ export default function Session() {
|
|||||||
|
|
||||||
const progress = useStepProgress(i, remaining, paused, done);
|
const progress = useStepProgress(i, remaining, paused, done);
|
||||||
|
|
||||||
|
// Small pop on the countdown when a new step starts, so the cue change
|
||||||
|
// registers even in peripheral vision.
|
||||||
|
const pop = useRef(new Animated.Value(1)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
pop.setValue(1.08);
|
||||||
|
Animated.spring(pop, { toValue: 1, friction: 5, tension: 120, useNativeDriver: true }).start();
|
||||||
|
}, [i, pop]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused || done) return;
|
if (paused || done) return;
|
||||||
const id = setInterval(() => setRemaining((r) => r - 1), 1000);
|
const id = setInterval(() => setRemaining((r) => r - 1), 1000);
|
||||||
@@ -69,15 +79,20 @@ export default function Session() {
|
|||||||
if (done) {
|
if (done) {
|
||||||
const { sessionsCompleted } = getState();
|
const { sessionsCompleted } = getState();
|
||||||
const leveledUp = sessionsCompleted % SESSIONS_PER_INCREMENT === 0;
|
const leveledUp = sessionsCompleted % SESSIONS_PER_INCREMENT === 0;
|
||||||
|
const untilNext = SESSIONS_PER_INCREMENT - (sessionsCompleted % SESSIONS_PER_INCREMENT);
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={s.screen}>
|
<SafeAreaView style={s.screen}>
|
||||||
<View style={s.center}>
|
<View style={s.center}>
|
||||||
<Text style={s.doneTitle}>Session complete 💪</Text>
|
<DoneCheck size={120} />
|
||||||
|
<Text style={s.doneTitle}>Session complete</Text>
|
||||||
<Text style={s.doneSub}>
|
<Text style={s.doneSub}>
|
||||||
{sessionsCompleted} session{sessionsCompleted === 1 ? "" : "s"} completed.
|
{leveledUp ? `Holds increase to ${currentHold()}s next time.` : `${untilNext} more session${untilNext === 1 ? "" : "s"} until holds increase.`}
|
||||||
{leveledUp ? ` Holds increase to ${currentHold()}s next time.` : ""}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Card>
|
||||||
|
<StatRow label="Sessions completed" value={String(sessionsCompleted)} />
|
||||||
|
<StatRow label="This session" value={`${Math.round(totalSeconds / 60)} min`} />
|
||||||
|
</Card>
|
||||||
<Button title="Done" onPress={() => router.back()} />
|
<Button title="Done" onPress={() => router.back()} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -95,11 +110,12 @@ export default function Session() {
|
|||||||
<RoundDots step={step} />
|
<RoundDots step={step} />
|
||||||
|
|
||||||
<View style={s.center}>
|
<View style={s.center}>
|
||||||
<ProgressRing size={300} strokeWidth={10} progress={progress} color={color}>
|
<ProgressRing size={RING_SIZE} strokeWidth={10} progress={progress} color={color}>
|
||||||
|
<GuideDisc size={RING_SIZE - 44} kind={step.kind} stepIndex={i} color={color} />
|
||||||
<View style={s.inner}>
|
<View style={s.inner}>
|
||||||
{shape ? <ShapeIcon shape={shape} size={22} color={color} /> : null}
|
{shape ? <ShapeIcon shape={shape} size={22} color={color} /> : null}
|
||||||
<Text style={[s.label, { color }]}>{step.label}</Text>
|
<Text style={[s.label, { color }]}>{step.label}</Text>
|
||||||
<Text style={s.count}>{remaining}</Text>
|
<Animated.Text style={[s.count, { transform: [{ scale: pop }] }]}>{remaining}</Animated.Text>
|
||||||
</View>
|
</View>
|
||||||
</ProgressRing>
|
</ProgressRing>
|
||||||
<Text style={s.next}>{next ? `Next: ${next.label} ${next.seconds}s` : "Last one!"}</Text>
|
<Text style={s.next}>{next ? `Next: ${next.label} ${next.seconds}s` : "Last one!"}</Text>
|
||||||
@@ -109,22 +125,22 @@ export default function Session() {
|
|||||||
<View style={[s.progressFill, { width: `${(elapsed / totalSeconds) * 100}%` }]} />
|
<View style={[s.progressFill, { width: `${(elapsed / totalSeconds) * 100}%` }]} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button title={paused ? "Resume" : "Pause"} onPress={() => setPaused((p) => !p)} />
|
<Button title={paused ? "Resume" : "Pause"} variant="secondary" onPress={() => setPaused((p) => !p)} />
|
||||||
<Button title="End session" danger onPress={() => router.back()} />
|
<Button title="End session" variant="danger" onPress={() => router.back()} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
||||||
block: { color: C.muted, fontSize: 15, textAlign: "center", textTransform: "uppercase", letterSpacing: 1, marginTop: 8, marginBottom: 12 },
|
block: { color: C.muted, fontSize: 14, textAlign: "center", textTransform: "uppercase", letterSpacing: 1.2, marginTop: 8, marginBottom: 12 },
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||||
inner: { alignItems: "center", justifyContent: "center", gap: 6 },
|
inner: { alignItems: "center", justifyContent: "center", gap: 6 },
|
||||||
label: { fontSize: 22, fontWeight: "600" },
|
label: { fontSize: 22, fontWeight: "600" },
|
||||||
count: { color: C.text, fontSize: 80, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
count: { color: C.text, fontSize: 80, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
||||||
next: { color: C.muted, fontSize: 15, marginTop: 24 },
|
next: { color: C.muted, fontSize: 15, marginTop: 24 },
|
||||||
doneTitle: { color: C.text, fontSize: 28, fontWeight: "700", marginBottom: 12 },
|
doneTitle: { color: C.text, fontSize: 28, fontWeight: "700", marginTop: 28, marginBottom: 10 },
|
||||||
doneSub: { color: C.muted, fontSize: 16, textAlign: "center", lineHeight: 23 },
|
doneSub: { color: C.muted, fontSize: 16, textAlign: "center", lineHeight: 23 },
|
||||||
progressTrack: { height: 6, borderRadius: 3, backgroundColor: C.surface2, marginBottom: 16 },
|
progressTrack: { height: 6, borderRadius: 3, backgroundColor: C.surface2, marginBottom: 16, overflow: "hidden" },
|
||||||
progressFill: { height: 6, borderRadius: 3, backgroundColor: C.accent },
|
progressFill: { height: 6, borderRadius: 3, backgroundColor: C.accent },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { Alert, StyleSheet, Text, View } from "react-native";
|
import { Alert, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { Button, StatRow } from "../components/ui";
|
import { Button, Card, StatRow } from "../components/ui";
|
||||||
import { adjustHold, currentHold, resetProgress } from "../lib/store";
|
import { adjustHold, currentHold, resetProgress } from "../lib/store";
|
||||||
import { C } from "../lib/theme";
|
import { C } from "../lib/theme";
|
||||||
import { useStore } from "../lib/useStore";
|
import { useStore } from "../lib/useStore";
|
||||||
import { MAX_HOLD, MIN_HOLD } from "../lib/workout";
|
import { MAX_HOLD, MIN_HOLD } from "../lib/workout";
|
||||||
|
|
||||||
|
function StepperButton({ label, disabled, onPress }: { label: string; disabled: boolean; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [s.step, disabled && s.stepDisabled, pressed && !disabled && s.stepPressed]}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Text style={[s.stepText, disabled && s.stepTextDisabled]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { sessionsCompleted, holdAdjust } = useStore();
|
const { sessionsCompleted, holdAdjust } = useStore();
|
||||||
const hold = currentHold();
|
const hold = currentHold();
|
||||||
@@ -12,27 +24,26 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<View style={s.screen}>
|
<View style={s.screen}>
|
||||||
<Text style={s.section}>Difficulty</Text>
|
<Text style={s.section}>Difficulty</Text>
|
||||||
<View style={s.card}>
|
<Card style={{ marginBottom: 12 }}>
|
||||||
<StatRow label="Current hold" value={`${hold}s`} />
|
<View style={s.stepperRow}>
|
||||||
|
<StepperButton label="−" disabled={hold <= MIN_HOLD} onPress={() => adjustHold(-1)} />
|
||||||
|
<View style={s.stepperValue}>
|
||||||
|
<Text style={s.holdValue}>{hold}s</Text>
|
||||||
|
<Text style={s.holdLabel}>hold length</Text>
|
||||||
|
</View>
|
||||||
|
<StepperButton label="+" disabled={hold >= MAX_HOLD} onPress={() => adjustHold(1)} />
|
||||||
|
</View>
|
||||||
<StatRow label="Manual adjustment" value={`${holdAdjust >= 0 ? "+" : ""}${holdAdjust}s`} />
|
<StatRow label="Manual adjustment" value={`${holdAdjust >= 0 ? "+" : ""}${holdAdjust}s`} />
|
||||||
</View>
|
</Card>
|
||||||
<View style={s.row}>
|
|
||||||
<View style={s.half}>
|
|
||||||
<Button title="Easier −1s" onPress={() => hold > MIN_HOLD && adjustHold(-1)} />
|
|
||||||
</View>
|
|
||||||
<View style={s.half}>
|
|
||||||
<Button title="Harder +1s" onPress={() => hold < MAX_HOLD && adjustHold(1)} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={s.hint}>
|
<Text style={s.hint}>
|
||||||
Holds grow automatically by 1s every 3 completed sessions ({MIN_HOLD}–{MAX_HOLD}s). Use these if a session
|
Holds grow automatically by 1s every 3 completed sessions ({MIN_HOLD}–{MAX_HOLD}s). Adjust here if a session
|
||||||
feels too easy or too hard — the adjustment stacks with the automatic progression.
|
feels too easy or too hard — the adjustment stacks with the automatic progression.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={s.section}>Progress</Text>
|
<Text style={s.section}>Progress</Text>
|
||||||
<Button
|
<Button
|
||||||
title="Reset progress"
|
title="Reset progress"
|
||||||
danger
|
variant="danger"
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
Alert.alert("Reset progress?", `This clears ${sessionsCompleted} completed sessions and starts over at ${MIN_HOLD}s holds.`, [
|
Alert.alert("Reset progress?", `This clears ${sessionsCompleted} completed sessions and starts over at ${MIN_HOLD}s holds.`, [
|
||||||
{ text: "Cancel", style: "cancel" },
|
{ text: "Cancel", style: "cancel" },
|
||||||
@@ -46,17 +57,24 @@ export default function Settings() {
|
|||||||
|
|
||||||
const s = StyleSheet.create({
|
const s = StyleSheet.create({
|
||||||
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
screen: { flex: 1, backgroundColor: C.bg, padding: 20 },
|
||||||
section: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 8, marginTop: 12 },
|
section: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 1.2, marginBottom: 8, marginTop: 12 },
|
||||||
card: {
|
stepperRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 14 },
|
||||||
backgroundColor: C.surface,
|
stepperValue: { alignItems: "center" },
|
||||||
borderRadius: 12,
|
holdValue: { color: C.accent, fontSize: 34, fontWeight: "700", fontVariant: ["tabular-nums"] },
|
||||||
|
holdLabel: { color: C.muted, fontSize: 12, textTransform: "uppercase", letterSpacing: 1.2, marginTop: 2 },
|
||||||
|
step: {
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
borderRadius: 26,
|
||||||
|
backgroundColor: C.surface2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: C.border,
|
borderColor: C.border,
|
||||||
paddingHorizontal: 16,
|
alignItems: "center",
|
||||||
paddingVertical: 4,
|
justifyContent: "center",
|
||||||
marginBottom: 12,
|
|
||||||
},
|
},
|
||||||
row: { flexDirection: "row", gap: 12 },
|
stepPressed: { opacity: 0.7, transform: [{ scale: 0.95 }] },
|
||||||
half: { flex: 1 },
|
stepDisabled: { opacity: 0.35 },
|
||||||
|
stepText: { color: C.text, fontSize: 26, fontWeight: "600", lineHeight: 30 },
|
||||||
|
stepTextDisabled: { color: C.muted },
|
||||||
hint: { color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 },
|
hint: { color: C.muted, fontSize: 13, lineHeight: 19, marginBottom: 12 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ReactNode, useEffect, useRef } from "react";
|
import { ReactNode, useEffect, useRef } from "react";
|
||||||
import { Animated, Easing, StyleSheet, View } from "react-native";
|
import { Animated, Easing, StyleSheet, View } from "react-native";
|
||||||
import Svg, { Circle } from "react-native-svg";
|
import Svg, { Circle, Path } from "react-native-svg";
|
||||||
import { C } from "../lib/theme";
|
import { alpha, C } from "../lib/theme";
|
||||||
import type { BlockId, Step } from "../lib/workout";
|
import type { BlockId, Step, StepKind } from "../lib/workout";
|
||||||
|
|
||||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||||
|
|
||||||
@@ -91,6 +91,60 @@ export function ProgressRing({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breathing-guide disc inside the ring: inflates on work (squeeze/push/flick),
|
||||||
|
* deflates on rest. Each 1s flick step re-triggers the inflate, so flick blocks
|
||||||
|
* pulse at the flick rhythm for free.
|
||||||
|
*/
|
||||||
|
export function GuideDisc({ size, kind, stepIndex, color }: { size: number; kind: StepKind; stepIndex: number; color: string }) {
|
||||||
|
const work = kind === "squeeze" || kind === "push" || kind === "flick-squeeze" || kind === "flick-push";
|
||||||
|
const scale = useRef(new Animated.Value(0.6)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const anim = Animated.timing(scale, {
|
||||||
|
toValue: work ? 1 : 0.6,
|
||||||
|
duration: work ? 650 : 450,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
});
|
||||||
|
anim.start();
|
||||||
|
return () => anim.stop();
|
||||||
|
}, [stepIndex, work, scale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: alpha(color, work ? 0.14 : 0.07),
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: alpha(color, work ? 0.4 : 0.18),
|
||||||
|
transform: [{ scale }],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session-complete mark: accent ring with a check, springing in once. */
|
||||||
|
export function DoneCheck({ size }: { size: number }) {
|
||||||
|
const pop = useRef(new Animated.Value(0)).current;
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.spring(pop, { toValue: 1, friction: 5, tension: 60, useNativeDriver: true }).start();
|
||||||
|
}, [pop]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={{ transform: [{ scale: pop }], opacity: pop }}>
|
||||||
|
<Svg width={size} height={size} viewBox="0 0 100 100">
|
||||||
|
<Circle cx={50} cy={50} r={46} stroke={C.accent} strokeWidth={5} fill={alpha(C.accent, 0.1)} />
|
||||||
|
<Path d="M31 52 L45 66 L70 37" stroke={C.text} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||||
|
</Svg>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Drives a ProgressRing: restarts on each step, freezes on pause. */
|
/** Drives a ProgressRing: restarts on each step, freezes on pause. */
|
||||||
export function useStepProgress(stepIndex: number, remaining: number, paused: boolean, done: boolean) {
|
export function useStepProgress(stepIndex: number, remaining: number, paused: boolean, done: boolean) {
|
||||||
const progress = useRef(new Animated.Value(0)).current;
|
const progress = useRef(new Animated.Value(0)).current;
|
||||||
|
|||||||
@@ -1,21 +1,36 @@
|
|||||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
import { Pressable, StyleSheet, Text, View, ViewStyle } from "react-native";
|
||||||
import { C } from "../lib/theme";
|
import { alpha, C } from "../lib/theme";
|
||||||
|
|
||||||
|
type Variant = "primary" | "secondary" | "danger";
|
||||||
|
|
||||||
|
const VARIANT_STYLE: Record<Variant, { box: ViewStyle; text: { color: string } }> = {
|
||||||
|
primary: { box: { backgroundColor: C.accent }, text: { color: C.text } },
|
||||||
|
secondary: {
|
||||||
|
box: { backgroundColor: C.surface, borderWidth: 1, borderColor: C.border },
|
||||||
|
text: { color: C.text },
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
box: { backgroundColor: "transparent", borderWidth: 1, borderColor: alpha(C.danger, 0.55) },
|
||||||
|
text: { color: C.danger },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
title,
|
title,
|
||||||
onPress,
|
onPress,
|
||||||
danger,
|
variant = "primary",
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
danger?: boolean;
|
variant?: Variant;
|
||||||
}) {
|
}) {
|
||||||
|
const v = VARIANT_STYLE[variant];
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[u.button, danger && { backgroundColor: "transparent", borderColor: C.danger, borderWidth: 1 }]}
|
style={({ pressed }) => [u.button, v.box, pressed && u.pressed]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
<Text style={[u.buttonText, danger && { color: C.danger }]}>{title}</Text>
|
<Text style={[u.buttonText, v.text]}>{title}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,23 +44,36 @@ export function StatRow({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Card({ children, style }: { children: React.ReactNode; style?: ViewStyle }) {
|
||||||
|
return <View style={[u.card, style]}>{children}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
const u = StyleSheet.create({
|
const u = StyleSheet.create({
|
||||||
button: {
|
button: {
|
||||||
backgroundColor: C.accent,
|
borderRadius: 12,
|
||||||
borderRadius: 10,
|
padding: 15,
|
||||||
padding: 14,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
buttonText: { color: C.text, fontSize: 16, fontWeight: "600" },
|
pressed: { opacity: 0.8, transform: [{ scale: 0.98 }] },
|
||||||
|
buttonText: { fontSize: 16, fontWeight: "600", letterSpacing: 0.2 },
|
||||||
statRow: {
|
statRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingVertical: 10,
|
paddingVertical: 11,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: C.border,
|
borderBottomColor: C.border,
|
||||||
},
|
},
|
||||||
statLabel: { color: C.muted, fontSize: 15 },
|
statLabel: { color: C.muted, fontSize: 15 },
|
||||||
statValue: { color: C.text, fontSize: 15, fontWeight: "600" },
|
statValue: { color: C.text, fontSize: 15, fontWeight: "600", fontVariant: ["tabular-nums"] },
|
||||||
|
card: {
|
||||||
|
backgroundColor: C.surface,
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: C.border,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 4,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ export const C = {
|
|||||||
rest: "#8B7573", // recovery — deliberately dull so work steps pop
|
rest: "#8B7573", // recovery — deliberately dull so work steps pop
|
||||||
danger: "#FF5D5D",
|
danger: "#FF5D5D",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Append an alpha byte to a #RRGGBB token — RN accepts #RRGGBBAA. */
|
||||||
|
export function alpha(color: string, a: number): string {
|
||||||
|
return (
|
||||||
|
color +
|
||||||
|
Math.round(a * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user