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
+10
View File
@@ -0,0 +1,10 @@
node_modules
.expo
.expo-shared
dist
web-build
coverage
.DS_Store
*.log
.env
.env.local
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { RootNavigator } from './src/navigation/RootNavigator';
export default function App(): React.JSX.Element {
return (
<SafeAreaProvider>
<StatusBar style="light" />
<RootNavigator />
</SafeAreaProvider>
);
}
+54 -1
View File
@@ -1,2 +1,55 @@
# fletchIQ_ # FletchIQ
Cross-platform React Native + React Native Web skeleton for an archery scoring application.
## Stack
- Expo + React Native + React Native Web
- TypeScript
- React Navigation (native stack + bottom tabs)
- Zustand for global state
- Supabase placeholders for auth and storage
## Project Structure
```text
src/
assets/
components/
common/
data/
hooks/
logic/
scoring/
navigation/
screens/
auth/
history/
modes/
rounds/
scoreboard/
target/
services/
auth/
storage/
supabase/
store/
theme/
types/
```
## Getting Started
```bash
npm install
npm run web
```
You can also run `npm run android` or `npm run ios` once Expo dependencies are installed locally.
## Next Implementation Steps
- Replace the placeholder login/register actions with real Supabase authentication.
- Add target plotting and arrow-by-arrow scoring logic inside `src/logic/`.
- Persist round history and leaderboard data through Supabase.
- Add icons, branding assets, and platform-specific polish.
+23
View File
@@ -0,0 +1,23 @@
{
"expo": {
"name": "FletchIQ",
"slug": "fletchiq",
"version": "0.1.0",
"orientation": "portrait",
"platforms": [
"android",
"ios",
"web"
],
"assetBundlePatterns": [
"**/*"
],
"extra": {
"supabaseUrl": "https://your-project.supabase.co",
"supabaseAnonKey": "your-anon-key"
},
"web": {
"bundler": "metro"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
root: ['./'],
alias: {
'@': './src',
},
},
],
],
};
};
+6
View File
@@ -0,0 +1,6 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);
+9083
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "fletchiq",
"version": "0.1.0",
"private": true,
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/metro-runtime": "~5.0.5",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.5",
"@supabase/supabase-js": "^2.49.4",
"expo": "^53.0.0",
"expo-constants": "^17.1.0",
"expo-status-bar": "^2.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-native": "^0.79.0",
"react-native-safe-area-context": "^5.4.0",
"react-native-screens": "^4.10.0",
"react-native-web": "^0.20.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "^19.0.10",
"babel-plugin-module-resolver": "^5.0.2",
"typescript": "^5.8.2"
}
}
+1
View File
@@ -0,0 +1 @@
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type ActionCardProps = {
title: string;
description: string;
cta: string;
onPress?: () => void;
};
export function ActionCard({
title,
description,
cta,
onPress,
}: ActionCardProps): React.JSX.Element {
return (
<Pressable style={styles.card} onPress={onPress}>
<View style={styles.headerRow}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.cta}>{cta}</Text>
</View>
<Text style={styles.description}>{description}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 20,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surfaceAlt,
gap: spacing.sm,
},
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
title: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
cta: {
color: colors.primary,
fontWeight: '700',
},
description: {
color: colors.muted,
lineHeight: 20,
},
});
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type PreferencePillProps = {
label: string;
selected?: boolean;
onPress?: () => void;
};
export function PreferencePill({
label,
selected = false,
onPress,
}: PreferencePillProps): React.JSX.Element {
return (
<Pressable
onPress={onPress}
style={[styles.pill, selected && styles.pillSelected]}
>
<Text style={[styles.label, selected && styles.labelSelected]}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
pill: {
borderRadius: 999,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.surface,
},
pillSelected: {
backgroundColor: colors.accent,
borderColor: colors.primarySoft,
},
label: {
color: colors.muted,
fontWeight: '700',
},
labelSelected: {
color: colors.text,
},
});
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import {
ScrollView,
StyleSheet,
useWindowDimensions,
View,
ViewStyle,
} from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type ScreenContainerProps = {
children: React.ReactNode;
style?: ViewStyle;
};
export function ScreenContainer({
children,
style,
}: ScreenContainerProps): React.JSX.Element {
const { width } = useWindowDimensions();
const isWide = width >= 1024;
return (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.content}>
<View style={[styles.card, isWide && styles.cardWide, style]}>{children}</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
backgroundColor: colors.background,
},
content: {
flexGrow: 1,
padding: spacing.lg,
},
card: {
flex: 1,
width: '100%',
maxWidth: 960,
alignSelf: 'center',
backgroundColor: colors.surface,
borderRadius: 24,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.lg,
gap: spacing.md,
},
cardWide: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.xl,
},
});
+169
View File
@@ -0,0 +1,169 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { PreferencePill } from '@/components/common/PreferencePill';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { SessionMode, ShootingStyleOption, TargetFacePreference } from '@/types';
type SetupSectionProps = {
mode: SessionMode;
title: string;
description: string;
selectedStyleId: string | null;
selectedTargetFace: TargetFacePreference;
enabledStyles: ShootingStyleOption[];
canStart: boolean;
startLabel: string;
onStyleSelect: (styleId: ShootingStyleOption['id']) => void;
onTargetFaceSelect: (targetFace: TargetFacePreference) => void;
onApplyDefaults: () => void;
onStart: () => void;
};
export function SetupSection({
mode,
title,
description,
selectedStyleId,
selectedTargetFace,
enabledStyles,
canStart,
startLabel,
onStyleSelect,
onTargetFaceSelect,
onApplyDefaults,
onStart,
}: SetupSectionProps): React.JSX.Element {
return (
<View style={styles.card}>
<View style={styles.header}>
<View style={styles.headerText}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
</View>
<Pressable style={styles.syncButton} onPress={onApplyDefaults}>
<Text style={styles.syncButtonText}>Use Profile Default</Text>
</Pressable>
</View>
<View style={styles.section}>
<Text style={styles.sectionLabel}>Shooting style</Text>
<View style={styles.pillRow}>
{enabledStyles.length > 0 ? (
enabledStyles.map((style) => (
<PreferencePill
key={`${mode}-${style.id}`}
label={style.label}
selected={selectedStyleId === style.id}
onPress={() => onStyleSelect(style.id)}
/>
))
) : (
<Text style={styles.helperText}>
Enable at least one shooting style in Profile before starting a session.
</Text>
)}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionLabel}>Target face</Text>
<View style={styles.pillRow}>
<PreferencePill
label="Single Face"
selected={selectedTargetFace === 'single-face'}
onPress={() => onTargetFaceSelect('single-face')}
/>
<PreferencePill
label="Multi Face"
selected={selectedTargetFace === 'multi-face'}
onPress={() => onTargetFaceSelect('multi-face')}
/>
</View>
</View>
<Pressable
onPress={onStart}
disabled={!canStart}
style={[styles.startButton, !canStart && styles.startButtonDisabled]}
>
<Text style={[styles.startButtonText, !canStart && styles.startButtonTextDisabled]}>
{startLabel}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 22,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.md,
},
header: {
gap: spacing.md,
},
headerText: {
gap: spacing.xs,
},
title: {
color: colors.text,
fontSize: 22,
fontWeight: '800',
},
description: {
color: colors.muted,
lineHeight: 20,
},
syncButton: {
alignSelf: 'flex-start',
borderRadius: 999,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.primarySoft,
backgroundColor: colors.accent,
},
syncButtonText: {
color: colors.text,
fontWeight: '700',
},
section: {
gap: spacing.sm,
},
sectionLabel: {
color: colors.text,
fontWeight: '700',
},
pillRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
helperText: {
color: colors.muted,
lineHeight: 20,
},
startButton: {
borderRadius: 16,
paddingVertical: 16,
alignItems: 'center',
backgroundColor: colors.primary,
},
startButtonDisabled: {
backgroundColor: colors.border,
},
startButtonText: {
color: '#04111F',
fontSize: 16,
fontWeight: '800',
},
startButtonTextDisabled: {
color: colors.muted,
},
});
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type StatCardProps = {
label: string;
value: string;
};
export function StatCard({ label, value }: StatCardProps): React.JSX.Element {
return (
<View style={styles.card}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
card: {
flex: 1,
minWidth: 140,
borderRadius: 18,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
label: {
color: colors.muted,
fontSize: 14,
},
value: {
color: colors.text,
fontSize: 24,
fontWeight: '700',
},
});
+26
View File
@@ -0,0 +1,26 @@
import { RoundDefinition } from '@/types';
export const mockRounds: RoundDefinition[] = [
{
id: 'wa-720',
name: 'WA 720',
distanceMeters: 70,
arrowCount: 72,
targetFace: '122cm',
},
{
id: 'indoor-18m',
name: 'Indoor 18m',
distanceMeters: 18,
arrowCount: 60,
targetFace: '40cm',
},
{
id: 'vegas-300',
name: 'Vegas 300',
distanceMeters: 18,
arrowCount: 30,
targetFace: 'Vegas 3-spot',
},
];
+43
View File
@@ -0,0 +1,43 @@
import { ShootingStyleOption, StylePreference } from '@/types';
// These labels are scaffolded around common NFAA-style class names.
// If you want the app to mirror the current official rulebook exactly,
// we can replace this list with a verified source-of-truth config later.
export const shootingStyleOptions: ShootingStyleOption[] = [
{
id: 'barebow',
label: 'Barebow',
description: 'Stringwalking or instinctive setup with minimal accessories.',
},
{
id: 'recurve',
label: 'Recurve',
description: 'Olympic-style recurve profile with sight and stabilizer support.',
},
{
id: 'freestyle',
label: 'Freestyle',
description: 'Sighted compound setup for target and league scoring.',
},
{
id: 'bowhunter',
label: 'Bowhunter',
description: 'Hunting-focused compound profile with class-specific limitations.',
},
{
id: 'freestyle_limited',
label: 'Freestyle Limited',
description: 'Target-focused class with reduced accessory options.',
},
{
id: 'traditional',
label: 'Traditional',
description: 'Wood-style or instinctive profile for classic equipment setups.',
},
];
export const defaultStylePreferences: StylePreference[] = shootingStyleOptions.map((style) => ({
styleId: style.id,
enabled: false,
defaultTargetFace: 'single-face',
}));
+14
View File
@@ -0,0 +1,14 @@
import { useWindowDimensions } from 'react-native';
export function useResponsive(): {
isCompact: boolean;
columns: number;
} {
const { width } = useWindowDimensions();
return {
isCompact: width < 768,
columns: width < 768 ? 1 : 2,
};
}
+12
View File
@@ -0,0 +1,12 @@
export type EndScore = {
endNumber: number;
arrows: number[];
total: number;
};
export type RoundProgress = {
currentEnd: number;
totalEnds: number;
runningScore: number;
};
+125
View File
@@ -0,0 +1,125 @@
import React from 'react';
import {
NavigationContainer,
DefaultTheme,
} from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { LoginScreen } from '@/screens/auth/LoginScreen';
import { RegisterScreen } from '@/screens/auth/RegisterScreen';
import { HistoryScreen } from '@/screens/history/HistoryScreen';
import { LeagueModeScreen } from '@/screens/modes/LeagueModeScreen';
import { ModeHubScreen } from '@/screens/modes/ModeHubScreen';
import { PracticeModeScreen } from '@/screens/modes/PracticeModeScreen';
import { PracticeSessionScreen } from '@/screens/modes/PracticeSessionScreen';
import { ProfileScreen } from '@/screens/profile/ProfileScreen';
import { RoundSelectionScreen } from '@/screens/rounds/RoundSelectionScreen';
import { LeaderboardScreen } from '@/screens/scoreboard/LeaderboardScreen';
import { TargetDisplayScreen } from '@/screens/target/TargetDisplayScreen';
import {
AuthStackParamList,
MainTabParamList,
ModesStackParamList,
RootStackParamList,
} from '@/navigation/types';
import { colors } from '@/theme/colors';
import { useAppStore } from '@/store/useAppStore';
const RootStack = createNativeStackNavigator<RootStackParamList>();
const AuthStack = createNativeStackNavigator<AuthStackParamList>();
const Tab = createBottomTabNavigator<MainTabParamList>();
const ModesStack = createNativeStackNavigator<ModesStackParamList>();
const navigationTheme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: colors.background,
card: colors.surface,
primary: colors.primary,
text: colors.text,
border: colors.border,
},
};
function AuthNavigator(): React.JSX.Element {
return (
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
<AuthStack.Screen name="Login" component={LoginScreen} />
<AuthStack.Screen name="Register" component={RegisterScreen} />
</AuthStack.Navigator>
);
}
function ModesNavigator(): React.JSX.Element {
return (
<ModesStack.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.background },
}}
>
<ModesStack.Screen
name="ModeHub"
component={ModeHubScreen}
options={{ title: 'Practice & League' }}
/>
<ModesStack.Screen name="PracticeMode" component={PracticeModeScreen} />
<ModesStack.Screen
name="PracticeSession"
component={PracticeSessionScreen}
options={{ title: 'Practice Session' }}
/>
<ModesStack.Screen name="LeagueMode" component={LeagueModeScreen} />
</ModesStack.Navigator>
);
}
function MainTabs(): React.JSX.Element {
return (
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
headerTitleStyle: { fontWeight: '700' },
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.muted,
sceneStyle: { backgroundColor: colors.background },
}}
>
<Tab.Screen name="Profile" component={ProfileScreen} />
<Tab.Screen name="Rounds" component={RoundSelectionScreen} />
<Tab.Screen
name="Modes"
component={ModesNavigator}
options={{ headerShown: false }}
/>
<Tab.Screen name="Target" component={TargetDisplayScreen} />
<Tab.Screen
name="Scores"
component={LeaderboardScreen}
options={{ title: 'Scoreboard' }}
/>
<Tab.Screen name="History" component={HistoryScreen} />
</Tab.Navigator>
);
}
export function RootNavigator(): React.JSX.Element {
const isAuthenticated = useAppStore((state) => state.isAuthenticated);
return (
<NavigationContainer theme={navigationTheme}>
<RootStack.Navigator screenOptions={{ headerShown: false }}>
{isAuthenticated ? (
<RootStack.Screen name="Main" component={MainTabs} />
) : (
<RootStack.Screen name="Auth" component={AuthNavigator} />
)}
</RootStack.Navigator>
</NavigationContainer>
);
}
+25
View File
@@ -0,0 +1,25 @@
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
};
export type MainTabParamList = {
Profile: undefined;
Rounds: undefined;
Modes: undefined;
Target: undefined;
Scores: undefined;
History: undefined;
};
export type ModesStackParamList = {
ModeHub: undefined;
PracticeMode: undefined;
PracticeSession: undefined;
LeagueMode: undefined;
};
export type RootStackParamList = {
Auth: undefined;
Main: undefined;
};
+91
View File
@@ -0,0 +1,91 @@
import React from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { AuthStackParamList } from '@/navigation/types';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { useAppStore } from '@/store/useAppStore';
type Props = NativeStackScreenProps<AuthStackParamList, 'Login'>;
export function LoginScreen({ navigation }: Props): React.JSX.Element {
const setAuthenticatedUser = useAppStore((state) => state.setAuthenticatedUser);
return (
<ScreenContainer>
<Text style={styles.eyebrow}>FletchIQ</Text>
<Text style={styles.title}>Login to continue scoring</Text>
<Text style={styles.subtitle}>
Wire this form to Supabase auth and persist the session in global state.
</Text>
<TextInput style={styles.input} placeholder="Email" placeholderTextColor={colors.muted} />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor={colors.muted}
secureTextEntry
/>
<Pressable
style={styles.primaryButton}
onPress={() =>
setAuthenticatedUser({
id: 'demo-user',
email: 'archer@example.com',
displayName: 'Demo Archer',
})
}
>
<Text style={styles.primaryButtonText}>Login</Text>
</Pressable>
<Pressable onPress={() => navigation.navigate('Register')}>
<Text style={styles.link}>Need an account? Register</Text>
</Pressable>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
eyebrow: {
color: colors.primary,
fontSize: 14,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
},
title: {
color: colors.text,
fontSize: 32,
fontWeight: '800',
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 16,
paddingHorizontal: spacing.md,
paddingVertical: 14,
backgroundColor: colors.surface,
},
primaryButton: {
backgroundColor: colors.primary,
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
},
primaryButtonText: {
color: colors.surface,
fontWeight: '700',
fontSize: 16,
},
link: {
color: colors.primary,
textAlign: 'center',
fontWeight: '600',
},
});
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { Pressable, StyleSheet, Text, TextInput } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { AuthStackParamList } from '@/navigation/types';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type Props = NativeStackScreenProps<AuthStackParamList, 'Register'>;
export function RegisterScreen({ navigation }: Props): React.JSX.Element {
return (
<ScreenContainer>
<Text style={styles.title}>Create your archer profile</Text>
<Text style={styles.subtitle}>
Add registration, email verification, and optional club metadata here.
</Text>
<TextInput style={styles.input} placeholder="Display name" placeholderTextColor={colors.muted} />
<TextInput style={styles.input} placeholder="Email" placeholderTextColor={colors.muted} />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor={colors.muted}
secureTextEntry
/>
<Pressable style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>Register</Text>
</Pressable>
<Pressable onPress={() => navigation.goBack()}>
<Text style={styles.link}>Back to login</Text>
</Pressable>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
color: colors.text,
fontSize: 30,
fontWeight: '800',
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 16,
paddingHorizontal: spacing.md,
paddingVertical: 14,
backgroundColor: colors.surface,
},
primaryButton: {
backgroundColor: colors.primary,
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
},
primaryButtonText: {
color: colors.surface,
fontWeight: '700',
fontSize: 16,
},
link: {
color: colors.primary,
textAlign: 'center',
fontWeight: '600',
},
});
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
export function HistoryScreen(): React.JSX.Element {
const history = useAppStore((state) => state.history);
return (
<ScreenContainer>
<Text style={styles.title}>Completed rounds</Text>
<Text style={styles.subtitle}>
Store local round summaries here now, then replace with Supabase-backed history and filters later.
</Text>
<View style={styles.list}>
{history.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>No completed rounds yet</Text>
<Text style={styles.emptyText}>
Add scoring completion logic to persist round summaries and sync them to the cloud.
</Text>
</View>
) : (
history.map((item) => (
<View key={item.id} style={styles.row}>
<Text style={styles.rowTitle}>{item.roundName}</Text>
<Text style={styles.rowMeta}>
{item.mode} {item.completedAt} {item.totalScore}
</Text>
</View>
))
)}
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
list: {
gap: spacing.md,
},
emptyState: {
borderRadius: 20,
padding: spacing.lg,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
emptyTitle: {
color: colors.text,
fontWeight: '700',
fontSize: 18,
},
emptyText: {
color: colors.muted,
lineHeight: 20,
},
row: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
rowTitle: {
color: colors.text,
fontWeight: '700',
fontSize: 18,
},
rowMeta: {
color: colors.muted,
},
});
+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,
},
});
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { PreferencePill } from '@/components/common/PreferencePill';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { shootingStyleOptions } from '@/data/shootingStyles';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { TargetFacePreference } from '@/types';
function formatTargetFace(targetFace: TargetFacePreference): string {
return targetFace === 'single-face' ? 'Single Face' : 'Multi Face';
}
export function ProfileScreen(): React.JSX.Element {
const archerProfile = useAppStore((state) => state.archerProfile);
const toggleStyleEnabled = useAppStore((state) => state.toggleStyleEnabled);
const setStyleTargetFace = useAppStore((state) => state.setStyleTargetFace);
const setActiveStyle = useAppStore((state) => state.setActiveStyle);
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}>Archer profile</Text>
<Text style={styles.subtitle}>
Set up the shooting styles you use locally and choose the default target-face layout for
each one. Later, practice and league sessions can read these preferences automatically.
</Text>
<View style={styles.summaryCard}>
<Text style={styles.summaryLabel}>Current default setup</Text>
<Text style={styles.summaryValue}>
{activePreference && activeStyle
? `${activeStyle.label}${formatTargetFace(activePreference.defaultTargetFace)}`
: 'No default shooting style selected yet'}
</Text>
</View>
<View style={styles.list}>
{shootingStyleOptions.map((style) => {
const preference = archerProfile.stylePreferences.find(
(entry) => entry.styleId === style.id
);
const isEnabled = Boolean(preference?.enabled);
const isActive = archerProfile.activeStyleId === style.id;
return (
<View key={style.id} style={[styles.styleCard, isActive && styles.styleCardActive]}>
<View style={styles.styleHeader}>
<View style={styles.styleText}>
<Text style={styles.styleTitle}>{style.label}</Text>
<Text style={styles.styleDescription}>{style.description}</Text>
</View>
<Pressable
onPress={() => toggleStyleEnabled(style.id)}
style={[styles.enableButton, isEnabled && styles.enableButtonEnabled]}
>
<Text style={[styles.enableButtonText, isEnabled && styles.enableButtonTextEnabled]}>
{isEnabled ? 'Enabled' : 'Enable'}
</Text>
</Pressable>
</View>
<View style={styles.inlineRow}>
<PreferencePill
label="Set Default"
selected={isActive}
onPress={() => setActiveStyle(style.id)}
/>
<Text style={styles.helperText}>
{isEnabled
? 'Ready to be used as the default style for new sessions.'
: 'Enable this style before making it your default.'}
</Text>
</View>
<View style={styles.targetFaceSection}>
<Text style={styles.targetFaceLabel}>Default target face</Text>
<View style={styles.pillRow}>
<PreferencePill
label="Single Face"
selected={preference?.defaultTargetFace === 'single-face'}
onPress={() => setStyleTargetFace(style.id, 'single-face')}
/>
<PreferencePill
label="Multi Face"
selected={preference?.defaultTargetFace === 'multi-face'}
onPress={() => setStyleTargetFace(style.id, 'multi-face')}
/>
</View>
</View>
</View>
);
})}
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
summaryCard: {
borderRadius: 20,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
summaryLabel: {
color: colors.muted,
fontSize: 13,
textTransform: 'uppercase',
letterSpacing: 1,
fontWeight: '700',
},
summaryValue: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
},
list: {
gap: spacing.md,
},
styleCard: {
borderRadius: 22,
padding: spacing.md,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.md,
},
styleCardActive: {
borderColor: colors.primary,
shadowColor: colors.primary,
shadowOpacity: 0.18,
shadowRadius: 14,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
styleHeader: {
flexDirection: 'row',
gap: spacing.md,
alignItems: 'flex-start',
},
styleText: {
flex: 1,
gap: spacing.xs,
},
styleTitle: {
color: colors.text,
fontSize: 20,
fontWeight: '700',
},
styleDescription: {
color: colors.muted,
lineHeight: 20,
},
enableButton: {
borderRadius: 999,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
enableButtonEnabled: {
backgroundColor: colors.primary,
borderColor: colors.primarySoft,
},
enableButtonText: {
color: colors.text,
fontWeight: '700',
},
enableButtonTextEnabled: {
color: '#04111F',
},
inlineRow: {
gap: spacing.sm,
},
helperText: {
color: colors.muted,
lineHeight: 20,
},
targetFaceSection: {
gap: spacing.sm,
},
targetFaceLabel: {
color: colors.text,
fontWeight: '700',
},
pillRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
});
@@ -0,0 +1,83 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ActionCard } from '@/components/common/ActionCard';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { mockRounds } from '@/data/mockRounds';
import { shootingStyleOptions } from '@/data/shootingStyles';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
export function RoundSelectionScreen(): React.JSX.Element {
const selectedRound = useAppStore((state) => state.selectedRound);
const selectRound = useAppStore((state) => state.selectRound);
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}>Select a round</Text>
<Text style={styles.subtitle}>
Load official round definitions, user favorites, and custom bow classes here.
</Text>
<View style={styles.profileCard}>
<Text style={styles.profileLabel}>Profile default</Text>
<Text style={styles.profileValue}>
{activePreference && activeStyle
? `${activeStyle.label}${activePreference.defaultTargetFace}`
: 'Set a default style and target face in Profile first'}
</Text>
</View>
<View style={styles.list}>
{mockRounds.map((round) => (
<ActionCard
key={round.id}
title={round.name}
description={`${round.distanceMeters}m • ${round.arrowCount} arrows • ${round.targetFace}`}
cta={selectedRound?.id === round.id ? 'Selected' : 'Choose'}
onPress={() => selectRound(round)}
/>
))}
</View>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
title: {
fontSize: 30,
fontWeight: '800',
color: colors.text,
},
subtitle: {
color: colors.muted,
lineHeight: 22,
},
list: {
gap: spacing.md,
},
profileCard: {
gap: spacing.xs,
padding: spacing.md,
borderRadius: 20,
backgroundColor: colors.surfaceAlt,
borderWidth: 1,
borderColor: colors.border,
},
profileLabel: {
color: colors.muted,
fontSize: 13,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 1,
},
profileValue: {
color: colors.text,
fontSize: 18,
fontWeight: '700',
},
});
@@ -0,0 +1,85 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { StatCard } from '@/components/common/StatCard';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
const mockLeaders = [
{ name: 'Avery Stone', score: 288 },
{ name: 'Tess Walker', score: 281 },
{ name: 'Jordan Reed', score: 279 },
];
export function LeaderboardScreen(): React.JSX.Element {
return (
<ScreenContainer>
<Text style={styles.title}>Scoreboard & leaderboard</Text>
<Text style={styles.subtitle}>
Merge local scoring with cloud-backed rankings, tie-break logic, and live match updates here.
</Text>
<View style={styles.statsRow}>
<StatCard label="Field size" value="24" />
<StatCard label="Your rank" value="7th" />
</View>
<View style={styles.table}>
{mockLeaders.map((entry, index) => (
<View key={entry.name} style={styles.row}>
<Text style={styles.rank}>{index + 1}</Text>
<Text style={styles.name}>{entry.name}</Text>
<Text style={styles.score}>{entry.score}</Text>
</View>
))}
</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,
},
table: {
borderRadius: 20,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
backgroundColor: colors.surfaceAlt,
borderBottomWidth: 1,
borderBottomColor: colors.border,
gap: spacing.md,
},
rank: {
width: 24,
color: colors.primary,
fontWeight: '700',
},
name: {
flex: 1,
color: colors.text,
fontWeight: '600',
},
score: {
color: colors.text,
fontWeight: '700',
},
});
@@ -0,0 +1,59 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ScreenContainer } from '@/components/common/ScreenContainer';
import { StatCard } from '@/components/common/StatCard';
import { useAppStore } from '@/store/useAppStore';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
export function TargetDisplayScreen(): React.JSX.Element {
const selectedRound = useAppStore((state) => state.selectedRound);
return (
<ScreenContainer>
<Text style={styles.title}>Target display</Text>
<Text style={styles.subtitle}>
Render the actual target face, shot plotting UI, arrow grouping analytics, and end-by-end input here.
</Text>
<View style={styles.statsRow}>
<StatCard label="Round" value={selectedRound?.name ?? 'Not selected'} />
<StatCard label="Face" value={selectedRound?.targetFace ?? 'Choose a round'} />
</View>
<View style={styles.targetPlaceholder}>
<Text style={styles.targetLabel}>Target visualization placeholder</Text>
</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,
},
targetPlaceholder: {
minHeight: 280,
borderRadius: 999,
borderWidth: 12,
borderColor: colors.targetInner,
backgroundColor: colors.targetOuter,
alignItems: 'center',
justifyContent: 'center',
},
targetLabel: {
fontSize: 18,
fontWeight: '700',
color: colors.text,
},
});
+22
View File
@@ -0,0 +1,22 @@
import { supabase } from '@/services/supabase/client';
export const authService = {
async login(email: string, password: string): Promise<void> {
// Implement Supabase password authentication here.
// Example: await supabase.auth.signInWithPassword({ email, password });
void email;
void password;
void supabase;
},
async register(email: string, password: string): Promise<void> {
// Implement registration and optional profile creation here.
void email;
void password;
void supabase;
},
async logout(): Promise<void> {
// Implement sign-out and local state cleanup here.
void supabase;
},
};
+10
View File
@@ -0,0 +1,10 @@
import { supabase } from '@/services/supabase/client';
export const storageService = {
async uploadRoundPhoto(uri: string): Promise<void> {
// Implement cloud uploads for scorecards, bow setup photos, or target images here.
void uri;
void supabase;
},
};
+12
View File
@@ -0,0 +1,12 @@
import Constants from 'expo-constants';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl =
Constants.expoConfig?.extra?.supabaseUrl ?? 'https://your-project.supabase.co';
const supabaseAnonKey =
Constants.expoConfig?.extra?.supabaseAnonKey ?? 'your-anon-key';
// Replace the fallback values above with environment-backed Expo config
// when you wire the project to a real Supabase instance.
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
+330
View File
@@ -0,0 +1,330 @@
import { create } from 'zustand';
import { defaultStylePreferences } from '@/data/shootingStyles';
import {
ActiveSession,
ArcherProfile,
PracticeSessionState,
RoundDefinition,
RoundHistoryItem,
ScoreEntry,
SessionMode,
SessionSetup,
ShootingStyleId,
TargetFacePreference,
} from '@/types';
type UserProfile = {
id: string;
email: string;
displayName: string;
};
type AppState = {
isAuthenticated: boolean;
user: UserProfile | null;
archerProfile: ArcherProfile;
sessionSetups: Record<SessionMode, SessionSetup>;
activeSession: ActiveSession | null;
practiceSession: PracticeSessionState | null;
selectedRound: RoundDefinition | null;
history: RoundHistoryItem[];
setAuthenticatedUser: (user: UserProfile | null) => void;
toggleStyleEnabled: (styleId: ShootingStyleId) => void;
setStyleTargetFace: (
styleId: ShootingStyleId,
targetFace: TargetFacePreference
) => void;
setActiveStyle: (styleId: ShootingStyleId) => void;
applyProfileDefaultsToSessions: () => void;
setSessionStyle: (mode: SessionMode, styleId: ShootingStyleId) => void;
setSessionTargetFace: (
mode: SessionMode,
targetFace: TargetFacePreference
) => void;
startSession: (mode: SessionMode) => void;
addPracticeArrow: (entry: ScoreEntry) => void;
removeLastPracticeArrow: () => void;
savePracticeEnd: () => void;
finishPracticeSession: () => void;
clearActiveSession: () => void;
selectRound: (round: RoundDefinition) => void;
addHistoryItem: (item: RoundHistoryItem) => void;
signOut: () => void;
};
function buildDefaultSessionSetups(
profile: ArcherProfile
): Record<SessionMode, SessionSetup> {
const activePreference = profile.stylePreferences.find(
(preference) => preference.styleId === profile.activeStyleId
);
return {
practice: {
mode: 'practice',
styleId: profile.activeStyleId,
targetFace: activePreference?.defaultTargetFace ?? 'single-face',
},
league: {
mode: 'league',
styleId: profile.activeStyleId,
targetFace: activePreference?.defaultTargetFace ?? 'single-face',
},
};
}
function buildInitialPracticeSession(): PracticeSessionState {
return {
currentEndNumber: 1,
arrowsPerEnd: 3,
currentArrows: [],
completedEnds: [],
totalScore: 0,
};
}
export const useAppStore = create<AppState>((set) => ({
isAuthenticated: false,
user: null,
archerProfile: {
activeStyleId: null,
stylePreferences: defaultStylePreferences,
},
sessionSetups: buildDefaultSessionSetups({
activeStyleId: null,
stylePreferences: defaultStylePreferences,
}),
activeSession: null,
practiceSession: null,
selectedRound: null,
history: [],
setAuthenticatedUser: (user) =>
set({
user,
isAuthenticated: Boolean(user),
}),
toggleStyleEnabled: (styleId) =>
set((state) => {
const stylePreferences = state.archerProfile.stylePreferences.map((preference) =>
preference.styleId === styleId
? { ...preference, enabled: !preference.enabled }
: preference
);
const toggledStyle = stylePreferences.find((preference) => preference.styleId === styleId);
const enabledStyles = stylePreferences.filter((preference) => preference.enabled);
let activeStyleId = state.archerProfile.activeStyleId;
if (toggledStyle?.enabled && !activeStyleId) {
activeStyleId = styleId;
}
if (!toggledStyle?.enabled && activeStyleId === styleId) {
activeStyleId = enabledStyles[0]?.styleId ?? null;
}
return {
archerProfile: {
activeStyleId,
stylePreferences,
},
sessionSetups: buildDefaultSessionSetups({
activeStyleId,
stylePreferences,
}),
};
}),
setStyleTargetFace: (styleId, targetFace) =>
set((state) => {
const stylePreferences = state.archerProfile.stylePreferences.map((preference) =>
preference.styleId === styleId
? { ...preference, defaultTargetFace: targetFace }
: preference
);
const archerProfile = {
...state.archerProfile,
stylePreferences,
};
return {
archerProfile,
sessionSetups: buildDefaultSessionSetups(archerProfile),
};
}),
setActiveStyle: (styleId) =>
set((state) => {
const stylePreference = state.archerProfile.stylePreferences.find(
(preference) => preference.styleId === styleId
);
if (!stylePreference?.enabled) {
return state;
}
return {
archerProfile: {
...state.archerProfile,
activeStyleId: styleId,
},
sessionSetups: buildDefaultSessionSetups({
...state.archerProfile,
activeStyleId: styleId,
}),
};
}),
applyProfileDefaultsToSessions: () =>
set((state) => ({
sessionSetups: buildDefaultSessionSetups(state.archerProfile),
})),
setSessionStyle: (mode, styleId) =>
set((state) => {
const stylePreference = state.archerProfile.stylePreferences.find(
(preference) => preference.styleId === styleId
);
if (!stylePreference?.enabled) {
return state;
}
return {
sessionSetups: {
...state.sessionSetups,
[mode]: {
...state.sessionSetups[mode],
styleId,
targetFace: stylePreference.defaultTargetFace,
},
},
};
}),
setSessionTargetFace: (mode, targetFace) =>
set((state) => ({
sessionSetups: {
...state.sessionSetups,
[mode]: {
...state.sessionSetups[mode],
targetFace,
},
},
})),
startSession: (mode) =>
set((state) => {
const sessionSetup = state.sessionSetups[mode];
if (!sessionSetup.styleId) {
return state;
}
return {
activeSession: {
id: `${mode}-${Date.now()}`,
mode,
styleId: sessionSetup.styleId,
targetFace: sessionSetup.targetFace,
startedAt: new Date().toISOString(),
status: 'active',
},
practiceSession: mode === 'practice' ? buildInitialPracticeSession() : state.practiceSession,
};
}),
addPracticeArrow: (entry) =>
set((state) => {
if (
state.activeSession?.mode !== 'practice' ||
!state.practiceSession ||
state.practiceSession.currentArrows.length >= state.practiceSession.arrowsPerEnd
) {
return state;
}
return {
practiceSession: {
...state.practiceSession,
currentArrows: [...state.practiceSession.currentArrows, entry],
},
};
}),
removeLastPracticeArrow: () =>
set((state) => {
if (state.activeSession?.mode !== 'practice' || !state.practiceSession) {
return state;
}
return {
practiceSession: {
...state.practiceSession,
currentArrows: state.practiceSession.currentArrows.slice(0, -1),
},
};
}),
savePracticeEnd: () =>
set((state) => {
if (
state.activeSession?.mode !== 'practice' ||
!state.practiceSession ||
state.practiceSession.currentArrows.length === 0
) {
return state;
}
const endTotal = state.practiceSession.currentArrows.reduce(
(sum, entry) => sum + entry.value,
0
);
const completedEnd = {
endNumber: state.practiceSession.currentEndNumber,
arrows: state.practiceSession.currentArrows,
total: endTotal,
};
return {
practiceSession: {
...state.practiceSession,
currentEndNumber: state.practiceSession.currentEndNumber + 1,
currentArrows: [],
completedEnds: [...state.practiceSession.completedEnds, completedEnd],
totalScore: state.practiceSession.totalScore + endTotal,
},
};
}),
finishPracticeSession: () =>
set((state) => {
if (state.activeSession?.mode !== 'practice' || !state.practiceSession) {
return state;
}
const currentArrowsTotal = state.practiceSession.currentArrows.reduce(
(sum, entry) => sum + entry.value,
0
);
const totalScore = state.practiceSession.totalScore + currentArrowsTotal;
const roundName = state.selectedRound?.name ?? 'Open Practice';
return {
history: [
{
id: `history-${Date.now()}`,
roundName,
completedAt: new Date().toLocaleString(),
totalScore,
mode: 'practice',
},
...state.history,
],
activeSession: null,
practiceSession: null,
};
}),
clearActiveSession: () => set({ activeSession: null, practiceSession: null }),
selectRound: (selectedRound) => set({ selectedRound }),
addHistoryItem: (item) =>
set((state) => ({
history: [item, ...state.history],
})),
signOut: () =>
set({
isAuthenticated: false,
user: null,
}),
}));
+14
View File
@@ -0,0 +1,14 @@
export const colors = {
background: '#06111F',
surface: '#0D1B2E',
surfaceAlt: '#12253D',
primary: '#4DA3FF',
primarySoft: '#7CC4FF',
text: '#EAF3FF',
muted: '#8DA6C1',
border: '#1E3858',
success: '#43D6A6',
accent: '#1C6DD0',
targetOuter: '#123E73',
targetInner: '#0E6BCF',
};
+8
View File
@@ -0,0 +1,8 @@
export const spacing = {
xs: 6,
sm: 10,
md: 16,
lg: 24,
xl: 32,
};
+79
View File
@@ -0,0 +1,79 @@
export type RoundDefinition = {
id: string;
name: string;
distanceMeters: number;
arrowCount: number;
targetFace: string;
};
export type TargetFacePreference = 'single-face' | 'multi-face';
export type ShootingStyleId =
| 'barebow'
| 'recurve'
| 'freestyle'
| 'bowhunter'
| 'freestyle_limited'
| 'traditional';
export type ShootingStyleOption = {
id: ShootingStyleId;
label: string;
description: string;
};
export type StylePreference = {
styleId: ShootingStyleId;
enabled: boolean;
defaultTargetFace: TargetFacePreference;
};
export type ArcherProfile = {
activeStyleId: ShootingStyleId | null;
stylePreferences: StylePreference[];
};
export type SessionMode = 'practice' | 'league';
export type SessionSetup = {
mode: SessionMode;
styleId: ShootingStyleId | null;
targetFace: TargetFacePreference;
};
export type ActiveSession = {
id: string;
mode: SessionMode;
styleId: ShootingStyleId;
targetFace: TargetFacePreference;
startedAt: string;
status: 'active';
};
export type PracticeEnd = {
endNumber: number;
arrows: ScoreEntry[];
total: number;
};
export type PracticeSessionState = {
currentEndNumber: number;
arrowsPerEnd: number;
currentArrows: ScoreEntry[];
completedEnds: PracticeEnd[];
totalScore: number;
};
export type RoundHistoryItem = {
id: string;
roundName: string;
completedAt: string;
totalScore: number;
mode: 'practice' | 'league';
};
export type ScoreEntry = {
arrowNumber: number;
value: number;
isX?: boolean;
};
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"types": [
"react",
"react-native"
],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
}
}