app framework

This commit is contained in:
blaisadmin
2026-03-27 00:38:12 -04:00
parent a395f9422c
commit bba670491e
39 changed files with 11781 additions and 1 deletions
+145
View File
@@ -0,0 +1,145 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SetupSection } from '@/components/common/SetupSection';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { StatCard } from '@/components/common/StatCard';
import { shootingStyleOptions } from '@/data/shootingStyles';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
export function LeagueModeScreen(): React.JSX.Element {
const archerProfile = useAppStore((state) => state.archerProfile);
const sessionSetup = useAppStore((state) => state.sessionSetups.league);
const setSessionStyle = useAppStore((state) => state.setSessionStyle);
const setSessionTargetFace = useAppStore((state) => state.setSessionTargetFace);
const applyProfileDefaultsToSessions = useAppStore(
(state) => state.applyProfileDefaultsToSessions
);
const activeSession = useAppStore((state) => state.activeSession);
const startSession = useAppStore((state) => state.startSession);
const clearActiveSession = useAppStore((state) => state.clearActiveSession);
const enabledStyles = shootingStyleOptions.filter((style) =>
archerProfile.stylePreferences.some(
(preference) => preference.styleId === style.id && preference.enabled
)
);
const activeStyleLabel =
shootingStyleOptions.find((style) => style.id === sessionSetup.styleId)?.label ?? 'Not set';
const liveLeagueStyle =
shootingStyleOptions.find((style) => style.id === activeSession?.styleId)?.label ?? 'Not set';
const leagueIsActive = activeSession?.mode === 'league';
return (
<ScreenContainer>
<Text style={styles.title}>League mode</Text>
<Text style={styles.subtitle}>
Build league session setup around the local profile first, then extend this flow with
squads, match assignments, lane management, and live sync.
</Text>
<SetupSection
mode="league"
title="League setup"
description="Prefill the session from your profile defaults and override the style or target face when a league night requires something different."
selectedStyleId={sessionSetup.styleId}
selectedTargetFace={sessionSetup.targetFace}
enabledStyles={enabledStyles}
canStart={Boolean(sessionSetup.styleId)}
startLabel="Start League"
onStyleSelect={(styleId) => setSessionStyle('league', styleId)}
onTargetFaceSelect={(targetFace) => setSessionTargetFace('league', targetFace)}
onApplyDefaults={applyProfileDefaultsToSessions}
onStart={() => startSession('league')}
/>
{leagueIsActive ? (
<View style={styles.activeSessionCard}>
<View style={styles.activeSessionHeader}>
<View style={styles.activeSessionText}>
<Text style={styles.activeSessionLabel}>League session live</Text>
<Text style={styles.activeSessionValue}>
{liveLeagueStyle} {' '}
{activeSession.targetFace === 'single-face' ? 'Single Face' : 'Multi Face'}
</Text>
</View>
<Pressable onPress={clearActiveSession} style={styles.endButton}>
<Text style={styles.endButtonText}>End Session</Text>
</Pressable>
</View>
<Text style={styles.activeSessionMeta}>
Started at {new Date(activeSession.startedAt).toLocaleTimeString()}
</Text>
</View>
) : null}
<View style={styles.statsRow}>
<StatCard label="League style" value={activeStyleLabel} />
<StatCard
label="Target face"
value={sessionSetup.targetFace === 'single-face' ? 'Single Face' : 'Multi Face'}
/>
<StatCard label="Active league" value="Spring Indoor" />
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
statsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
activeSessionCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.success,
gap: spacing.sm,
},
activeSessionHeader: {
flexDirection: 'row',
gap: spacing.md,
alignItems: 'flex-start',
},
activeSessionText: {
flex: 1,
gap: spacing.xs,
},
activeSessionLabel: {
color: colors.success,
fontSize: 13,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 1,
},
activeSessionValue: {
color: colors.text,
fontSize: 20,
fontWeight: '800',
},
activeSessionMeta: {
color: colors.muted,
},
endButton: {
borderRadius: 999,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
endButtonText: {
color: colors.text,
fontWeight: '700',
},
});
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ActionCard } from '@/components/common/ActionCard';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { shootingStyleOptions } from '@/data/shootingStyles';
import { ModesStackParamList } from '@/navigation/types';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type Props = NativeStackScreenProps<ModesStackParamList, 'ModeHub'>;
export function ModeHubScreen({ navigation }: Props): React.JSX.Element {
const archerProfile = useAppStore((state) => state.archerProfile);
const activePreference = archerProfile.stylePreferences.find(
(preference) => preference.styleId === archerProfile.activeStyleId
);
const activeStyle = shootingStyleOptions.find((style) => style.id === activePreference?.styleId);
return (
<ScreenContainer>
<Text style={styles.title}>Choose a mode</Text>
<Text style={styles.subtitle}>
Split free practice, league play, tournaments, and coaching drills into separate flows here.
</Text>
<View style={styles.defaultCard}>
<Text style={styles.defaultLabel}>Current profile default</Text>
<Text style={styles.defaultValue}>
{activePreference && activeStyle
? `${activeStyle.label}${activePreference.defaultTargetFace}`
: 'No active style has been selected in Profile yet'}
</Text>
</View>
<View style={styles.grid}>
<ActionCard
title="Practice"
description="Quick solo sessions, warm-ups, and custom drills."
cta="Open"
onPress={() => navigation.navigate('PracticeMode')}
/>
<ActionCard
title="League"
description="Shared scorecards, squads, matchplay brackets, and scheduled events."
cta="Open"
onPress={() => navigation.navigate('LeagueMode')}
/>
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
grid: {
gap: spacing.md,
},
defaultCard: {
gap: spacing.xs,
padding: spacing.md,
borderRadius: 20,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
},
defaultLabel: {
color: colors.muted,
fontSize: 13,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
},
defaultValue: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
},
});
+154
View File
@@ -0,0 +1,154 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { SetupSection } from '@/components/common/SetupSection';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { StatCard } from '@/components/common/StatCard';
import { shootingStyleOptions } from '@/data/shootingStyles';
import { ModesStackParamList } from '@/navigation/types';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type Props = NativeStackScreenProps<ModesStackParamList, 'PracticeMode'>;
export function PracticeModeScreen({ navigation }: Props): React.JSX.Element {
const archerProfile = useAppStore((state) => state.archerProfile);
const sessionSetup = useAppStore((state) => state.sessionSetups.practice);
const setSessionStyle = useAppStore((state) => state.setSessionStyle);
const setSessionTargetFace = useAppStore((state) => state.setSessionTargetFace);
const applyProfileDefaultsToSessions = useAppStore(
(state) => state.applyProfileDefaultsToSessions
);
const activeSession = useAppStore((state) => state.activeSession);
const startSession = useAppStore((state) => state.startSession);
const clearActiveSession = useAppStore((state) => state.clearActiveSession);
const enabledStyles = shootingStyleOptions.filter((style) =>
archerProfile.stylePreferences.some(
(preference) => preference.styleId === style.id && preference.enabled
)
);
const activeStyleLabel =
shootingStyleOptions.find((style) => style.id === sessionSetup.styleId)?.label ?? 'Not set';
const livePracticeStyle =
shootingStyleOptions.find((style) => style.id === activeSession?.styleId)?.label ?? 'Not set';
const practiceIsActive = activeSession?.mode === 'practice';
return (
<ScreenContainer>
<Text style={styles.title}>Practice mode</Text>
<Text style={styles.subtitle}>
Start by confirming the profile defaults for this practice session, then layer in drills,
timers, notes, and equipment tuning checkpoints.
</Text>
<SetupSection
mode="practice"
title="Practice setup"
description="Use your local profile as a starting point, then override style or target face for this session if needed."
selectedStyleId={sessionSetup.styleId}
selectedTargetFace={sessionSetup.targetFace}
enabledStyles={enabledStyles}
canStart={Boolean(sessionSetup.styleId)}
startLabel={practiceIsActive ? 'Resume Practice' : 'Start Practice'}
onStyleSelect={(styleId) => setSessionStyle('practice', styleId)}
onTargetFaceSelect={(targetFace) => setSessionTargetFace('practice', targetFace)}
onApplyDefaults={applyProfileDefaultsToSessions}
onStart={() => {
if (!practiceIsActive) {
startSession('practice');
}
navigation.navigate('PracticeSession');
}}
/>
{practiceIsActive ? (
<View style={styles.activeSessionCard}>
<View style={styles.activeSessionHeader}>
<View style={styles.activeSessionText}>
<Text style={styles.activeSessionLabel}>Practice session live</Text>
<Text style={styles.activeSessionValue}>
{livePracticeStyle} {' '}
{activeSession.targetFace === 'single-face' ? 'Single Face' : 'Multi Face'}
</Text>
</View>
<Pressable onPress={clearActiveSession} style={styles.endButton}>
<Text style={styles.endButtonText}>End Session</Text>
</Pressable>
</View>
<Text style={styles.activeSessionMeta}>
Started at {new Date(activeSession.startedAt).toLocaleTimeString()}
</Text>
</View>
) : null}
<View style={styles.statsRow}>
<StatCard label="Practice style" value={activeStyleLabel} />
<StatCard
label="Default face"
value={sessionSetup.targetFace === 'single-face' ? 'Single Face' : 'Multi Face'}
/>
<StatCard label="Current drill" value="Blank Bale" />
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
statsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
activeSessionCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.success,
gap: spacing.sm,
},
activeSessionHeader: {
flexDirection: 'row',
gap: spacing.md,
alignItems: 'flex-start',
},
activeSessionText: {
flex: 1,
gap: spacing.xs,
},
activeSessionLabel: {
color: colors.success,
fontSize: 13,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 1,
},
activeSessionValue: {
color: colors.text,
fontSize: 20,
fontWeight: '800',
},
activeSessionMeta: {
color: colors.muted,
},
endButton: {
borderRadius: 999,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
endButtonText: {
color: colors.text,
fontWeight: '700',
},
});
+346
View File
@@ -0,0 +1,346 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { StatCard } from '@/components/common/StatCard';
import { shootingStyleOptions } from '@/data/shootingStyles';
import { ModesStackParamList } from '@/navigation/types';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { ScoreEntry, TargetFacePreference } from '@/types';
type Props = NativeStackScreenProps<ModesStackParamList, 'PracticeSession'>;
function getScoreOptions(targetFace: TargetFacePreference): ScoreEntry[] {
if (targetFace === 'multi-face') {
return [
{ arrowNumber: 0, value: 10, isX: false },
{ arrowNumber: 0, value: 9 },
{ arrowNumber: 0, value: 8 },
{ arrowNumber: 0, value: 7 },
{ arrowNumber: 0, value: 6 },
{ arrowNumber: 0, value: 5 },
{ arrowNumber: 0, value: 0 },
];
}
return [
{ arrowNumber: 0, value: 10, isX: true },
{ arrowNumber: 0, value: 10, isX: false },
{ arrowNumber: 0, value: 9 },
{ arrowNumber: 0, value: 8 },
{ arrowNumber: 0, value: 7 },
{ arrowNumber: 0, value: 6 },
{ arrowNumber: 0, value: 5 },
{ arrowNumber: 0, value: 4 },
{ arrowNumber: 0, value: 3 },
{ arrowNumber: 0, value: 2 },
{ arrowNumber: 0, value: 1 },
{ arrowNumber: 0, value: 0 },
];
}
function getScoreLabel(entry: ScoreEntry): string {
if (entry.value === 0) {
return 'M';
}
return entry.isX ? 'X' : String(entry.value);
}
export function PracticeSessionScreen({ navigation }: Props): React.JSX.Element {
const activeSession = useAppStore((state) => state.activeSession);
const practiceSession = useAppStore((state) => state.practiceSession);
const selectedRound = useAppStore((state) => state.selectedRound);
const addPracticeArrow = useAppStore((state) => state.addPracticeArrow);
const removeLastPracticeArrow = useAppStore((state) => state.removeLastPracticeArrow);
const savePracticeEnd = useAppStore((state) => state.savePracticeEnd);
const finishPracticeSession = useAppStore((state) => state.finishPracticeSession);
if (activeSession?.mode !== 'practice' || !practiceSession) {
return (
<ScreenContainer>
<Text style={styles.title}>No active practice session</Text>
<Text style={styles.subtitle}>
Start a practice session from the practice setup screen before entering scoring.
</Text>
<Pressable style={styles.primaryButton} onPress={() => navigation.goBack()}>
<Text style={styles.primaryButtonText}>Back to Practice Setup</Text>
</Pressable>
</ScreenContainer>
);
}
const styleLabel =
shootingStyleOptions.find((style) => style.id === activeSession.styleId)?.label ?? 'Unknown';
const scoreOptions = getScoreOptions(activeSession.targetFace);
return (
<ScreenContainer>
<Text style={styles.title}>Practice scoring</Text>
<Text style={styles.subtitle}>
This is the local scoring flow for practice sessions. Later we can swap in target plotting,
notes, timers, and richer analytics without changing the core session lifecycle.
</Text>
<View style={styles.rulesCard}>
<Text style={styles.rulesTitle}>Available scores</Text>
<Text style={styles.rulesText}>
{activeSession.targetFace === 'single-face'
? 'Single-face scoring includes X, 10 through 1, and M.'
: 'Multi-face scoring is limited to 10 through 5 and M.'}
</Text>
</View>
<View style={styles.statsRow}>
<StatCard label="Style" value={styleLabel} />
<StatCard
label="Target Face"
value={activeSession.targetFace === 'single-face' ? 'Single Face' : 'Multi Face'}
/>
<StatCard label="Round" value={selectedRound?.name ?? 'Open Practice'} />
</View>
<View style={styles.summaryCard}>
<Text style={styles.summaryTitle}>End {practiceSession.currentEndNumber}</Text>
<Text style={styles.summaryText}>
Current arrows: {practiceSession.currentArrows.length}/{practiceSession.arrowsPerEnd}
</Text>
<Text style={styles.summaryText}>
Running total: {practiceSession.totalScore}
</Text>
</View>
<View style={styles.currentArrowsCard}>
<Text style={styles.sectionTitle}>Current arrows</Text>
<View style={styles.arrowRow}>
{Array.from({ length: practiceSession.arrowsPerEnd }).map((_, index) => (
<View key={index} style={styles.arrowSlot}>
<Text style={styles.arrowSlotText}>
{practiceSession.currentArrows[index]
? getScoreLabel(practiceSession.currentArrows[index])
: '-'}
</Text>
</View>
))}
</View>
</View>
<View style={styles.scoreGrid}>
{scoreOptions.map((entry, index) => (
<Pressable
key={`${getScoreLabel(entry)}-${index}`}
style={styles.scoreButton}
onPress={() =>
addPracticeArrow({
...entry,
arrowNumber: practiceSession.currentArrows.length + 1,
})
}
>
<Text style={styles.scoreButtonText}>{getScoreLabel(entry)}</Text>
</Pressable>
))}
</View>
<View style={styles.actionRow}>
<Pressable style={styles.secondaryButton} onPress={removeLastPracticeArrow}>
<Text style={styles.secondaryButtonText}>Undo Last</Text>
</Pressable>
<Pressable style={styles.secondaryButton} onPress={savePracticeEnd}>
<Text style={styles.secondaryButtonText}>Save End</Text>
</Pressable>
</View>
<View style={styles.completedCard}>
<Text style={styles.sectionTitle}>Completed ends</Text>
{practiceSession.completedEnds.length === 0 ? (
<Text style={styles.emptyText}>No ends saved yet.</Text>
) : (
practiceSession.completedEnds.map((end) => (
<View key={end.endNumber} style={styles.completedRow}>
<Text style={styles.completedLabel}>End {end.endNumber}</Text>
<Text style={styles.completedMeta}>
{end.arrows.map((arrow) => getScoreLabel(arrow)).join(' • ')}
</Text>
<Text style={styles.completedTotal}>{end.total}</Text>
</View>
))
)}
</View>
<Pressable
style={styles.primaryButton}
onPress={() => {
finishPracticeSession();
navigation.popToTop();
}}
>
<Text style={styles.primaryButtonText}>Finish Practice</Text>
</Pressable>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
statsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
rulesCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
rulesTitle: {
color: colors.text,
fontSize: 16,
fontWeight: '800',
},
rulesText: {
color: colors.muted,
lineHeight: 20,
},
summaryCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
summaryTitle: {
color: colors.text,
fontSize: 20,
fontWeight: '800',
},
summaryText: {
color: colors.muted,
},
currentArrowsCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.md,
},
sectionTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '800',
},
arrowRow: {
flexDirection: 'row',
gap: spacing.md,
},
arrowSlot: {
flex: 1,
minHeight: 64,
borderRadius: 16,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
arrowSlotText: {
color: colors.text,
fontSize: 24,
fontWeight: '800',
},
scoreGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
scoreButton: {
width: 64,
height: 64,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accent,
borderWidth: 1,
borderColor: colors.primarySoft,
},
scoreButtonText: {
color: colors.text,
fontSize: 20,
fontWeight: '800',
},
actionRow: {
flexDirection: 'row',
gap: spacing.md,
},
secondaryButton: {
flex: 1,
borderRadius: 16,
paddingVertical: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surfaceAlt,
},
secondaryButtonText: {
color: colors.text,
fontWeight: '700',
},
completedCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
emptyText: {
color: colors.muted,
},
completedRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
completedLabel: {
width: 56,
color: colors.text,
fontWeight: '700',
},
completedMeta: {
flex: 1,
color: colors.muted,
},
completedTotal: {
color: colors.primary,
fontWeight: '800',
},
primaryButton: {
backgroundColor: colors.primary,
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
},
primaryButtonText: {
color: '#04111F',
fontWeight: '800',
fontSize: 16,
},
});