app framework
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.expo
|
||||
.expo-shared
|
||||
dist
|
||||
web-build
|
||||
coverage
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
root: ['./'],
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
|
||||
Generated
+9083
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type EndScore = {
|
||||
endNumber: number;
|
||||
arrows: number[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type RoundProgress = {
|
||||
currentEnd: number;
|
||||
totalEnds: number;
|
||||
runningScore: number;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}));
|
||||
@@ -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',
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const spacing = {
|
||||
xs: 6,
|
||||
sm: 10,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": [
|
||||
"react",
|
||||
"react-native"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user