basic stracture and auth

in memory db :)
This commit is contained in:
dimitar committed 2025-11-07 20:59:56 +01:00
commit 3a554ba434
71 files changed
+28535

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
module.exports = {
root: true,
extends: [
'@react-native-community',
'@typescript-eslint/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'prefer-const': 'error',
'no-var': 'error',
},
ignorePatterns: ['node_modules', 'dist', 'expo'],
}
+44
View File
@@ -0,0 +1,44 @@
# Mobile app specific
.expo/
.expo-shared/
web-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
.metro-health-check*
# iOS
ios/Pods/
*.xcode.env
ios/Pods/Flipper*
ios/Pods/Flipper-Folly
ios/Pods/Flipper-Glog
ios/Pods/Flipper-PeerTalk
ios/Pods/Flipper-RSocket
# Android
android/app/build/
android/gradle/
android/gradlew
android/gradlew.bat
android/local.properties
android/*.iml
android/.gradle/
android/app/release/
android/app/debug/
android/app/build/generated/
# Fastlane
*/fastlane/report.xml
*/fastlane/Preview.html
*/fastlane/screenshots
*/fastlane/test_output
# Bundle artifact
*.jsbundle
# Environment
.env.local
.env.*.local
+34
View File
@@ -0,0 +1,34 @@
{
"expo": {
"name": "FitAI",
"slug": "fitai",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router"
],
"scheme": "fitai"
}
}
+1
View File
@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+1
View File
@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+1
View File
@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+1
View File
@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+6
View File
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
preset: 'react-native',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
},
transformIgnorePatterns: [
'node_modules/(?!(jest-)?react-native|@react-native|expo|@expo|@react-navigation)',
],
}
+11
View File
@@ -0,0 +1,11 @@
import 'react-native-gesture-handler/jestSetup'
jest.mock('react-native-reanimated', () => {
const Reanimated = require('react-native-reanimated/mock')
Reanimated.default.call = () => {}
return Reanimated
})
jest.mock('@expo/vector-icons', () => ({
Ionicons: 'Ionicons',
}))
+12361
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@fitai/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"private": true,
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"build": "expo build",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@hookform/resolvers": "^3.3.0",
"@tanstack/react-query": "^5.0.0",
"ajv": "^8.12.0",
"ajv-keywords": "^5.1.0",
"axios": "^1.6.0",
"expo": "~54.0.0",
"expo-camera": "~17.0.9",
"expo-linking": "~8.0.8",
"expo-notifications": "~0.32.12",
"expo-router": "~6.0.14",
"expo-secure-store": "~15.0.7",
"react": "19.1.0",
"react-hook-form": "^7.47.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"@types/react-native": "^0.73.0",
"typescript": "^5.1.3",
"eslint": "^8.45.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"jest": "^29.2.1",
"@testing-library/react-native": "^12.4.0",
"react-test-renderer": "19.1.0",
"babel-preset-expo": "~54.0.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="attendance"
options={{
title: 'Attendance',
tabBarIcon: ({ color, size }) => (
<Ionicons name="calendar" size={size} color={color} />
),
}}
/>
</Tabs>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { View, Text, StyleSheet } from 'react-native'
export default function AttendanceScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Attendance</Text>
<Text style={styles.subtitle}>Track your gym visits</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#666',
},
})
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import { useRequireAuth } from '@/hooks/useRequireAuth'
export default function HomeScreen() {
const { user } = useRequireAuth()
if (!user) {
return null
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome back!</Text>
<Text style={styles.subtitle}>
{user.firstName} {user.lastName}
</Text>
<Text style={styles.email}>{user.email}</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 24,
fontWeight: '600',
marginBottom: 4,
color: '#333',
},
email: {
fontSize: 16,
color: '#666',
},
})
+116
View File
@@ -0,0 +1,116 @@
import React from 'react'
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
export default function ProfileScreen() {
const { user, logout } = useAuth()
const router = useRouter()
const handleLogout = async () => {
Alert.alert(
'Logout',
'Are you sure you want to logout?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Logout',
style: 'destructive',
onPress: async () => {
try {
await logout()
router.replace('/login')
} catch (error) {
Alert.alert('Error', 'Failed to logout')
}
},
},
]
)
}
return (
<View style={styles.container}>
<View style={styles.profileCard}>
<Text style={styles.title}>Profile</Text>
<Text style={styles.name}>
{user?.firstName} {user?.lastName}
</Text>
<Text style={styles.email}>{user?.email}</Text>
{user?.phone && <Text style={styles.phone}>{user.phone}</Text>}
<View style={styles.roleBadge}>
<Text style={styles.roleText}>
{user?.role.charAt(0).toUpperCase() + user?.role.slice(1)}
</Text>
</View>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
backgroundColor: '#f5f5f5',
},
profileCard: {
backgroundColor: 'white',
borderRadius: 12,
padding: 24,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 16,
},
name: {
fontSize: 20,
fontWeight: '600',
marginBottom: 4,
},
email: {
fontSize: 16,
color: '#666',
marginBottom: 4,
},
phone: {
fontSize: 16,
color: '#666',
marginBottom: 16,
},
roleBadge: {
backgroundColor: '#3b82f6',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
},
roleText: {
color: 'white',
fontSize: 14,
fontWeight: '600',
},
logoutButton: {
backgroundColor: '#ef4444',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginTop: 24,
},
logoutText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
})
+15
View File
@@ -0,0 +1,15 @@
import { AuthProvider } from '@/contexts/AuthContext'
import { Stack } from 'expo-router'
import { View, Text } from 'react-native'
export default function RootLayout() {
return (
<AuthProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="register" options={{ headerShown: false }} />
</Stack>
</AuthProvider>
)
}
+139
View File
@@ -0,0 +1,139 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import * as SecureStore from 'expo-secure-store'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
export default function LoginScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleLogin = async () => {
if (!formData.email || !formData.password) {
Alert.alert('Error', 'Please fill in all fields')
return
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.LOGIN}`, formData)
if (response.data.user) {
await SecureStore.setItemAsync('user', JSON.stringify(response.data.user))
Alert.alert('Success', 'Login successful!', [
{ text: 'OK', onPress: () => router.replace('/(tabs)') }
])
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Login to your FitAI account</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Logging in...' : 'Login'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/register')}
>
<Text style={styles.linkText}>
Don't have an account? Register
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})
+164
View File
@@ -0,0 +1,164 @@
import React, { useState } from 'react'
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native'
import { useRouter } from 'expo-router'
import axios from 'axios'
import { API_BASE_URL, API_ENDPOINTS } from '../config/api'
export default function RegisterScreen() {
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
phone: '',
})
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleRegister = async () => {
if (!formData.email || !formData.password || !formData.firstName || !formData.lastName) {
Alert.alert('Error', 'Please fill in all required fields')
return
}
setLoading(true)
try {
const response = await axios.post(`${API_BASE_URL}${API_ENDPOINTS.AUTH.REGISTER}`, formData)
if (response.status === 201) {
Alert.alert('Success', 'Registration successful! Please login.', [
{ text: 'OK', onPress: () => router.push('/login') }
])
}
} catch (error: any) {
Alert.alert('Error', error.response?.data?.error || 'Registration failed')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join FitAI today</Text>
<View style={styles.form}>
<TextInput
style={styles.input}
placeholder="First Name"
value={formData.firstName}
onChangeText={(text) => setFormData({ ...formData, firstName: text })}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Last Name"
value={formData.lastName}
onChangeText={(text) => setFormData({ ...formData, lastName: text })}
autoCapitalize="words"
/>
<TextInput
style={styles.input}
placeholder="Email"
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="Phone (optional)"
value={formData.phone}
onChangeText={(text) => setFormData({ ...formData, phone: text })}
keyboardType="phone-pad"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={loading}
>
<Text style={styles.buttonText}>
{loading ? 'Creating Account...' : 'Create Account'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/login')}
>
<Text style={styles.linkText}>
Already have an account? Login
</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
title: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 32,
},
form: {
width: '100%',
maxWidth: 400,
},
input: {
backgroundColor: 'white',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
marginBottom: 16,
borderWidth: 1,
borderColor: '#ddd',
},
button: {
backgroundColor: '#3b82f6',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
marginBottom: 16,
},
buttonDisabled: {
backgroundColor: '#9ca3af',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
linkButton: {
alignItems: 'center',
},
linkText: {
color: '#3b82f6',
fontSize: 14,
},
})
+12
View File
@@ -0,0 +1,12 @@
export const API_BASE_URL = __DEV__
? 'http://192.168.0.100:3000'
: 'https://your-production-url.com'
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
},
CLIENTS: '/api/clients',
USERS: '/api/users',
}
+76
View File
@@ -0,0 +1,76 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import * as SecureStore from 'expo-secure-store'
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
phone?: string
}
interface AuthContextType {
user: User | null
login: (user: User) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadUser()
}, [])
const loadUser = async () => {
try {
const userData = await SecureStore.getItemAsync('user')
if (userData) {
setUser(JSON.parse(userData))
}
} catch (error) {
console.error('Failed to load user:', error)
} finally {
setIsLoading(false)
}
}
const login = async (userData: User) => {
try {
await SecureStore.setItemAsync('user', JSON.stringify(userData))
setUser(userData)
} catch (error) {
console.error('Failed to save user:', error)
throw error
}
}
const logout = async () => {
try {
await SecureStore.deleteItemAsync('user')
setUser(null)
} catch (error) {
console.error('Failed to logout:', error)
throw error
}
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
+16
View File
@@ -0,0 +1,16 @@
import { useAuth } from '@/contexts/AuthContext'
import { useRouter } from 'expo-router'
import { useEffect } from 'react'
export function useRequireAuth() {
const { user, isLoading } = useAuth()
const router = useRouter()
useEffect(() => {
if (!isLoading && !user) {
router.replace('/login')
}
}, [user, isLoading, router])
return { user, isLoading }
}
+38
View File
@@ -0,0 +1,38 @@
{
"compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"jsx": "react-native",
"lib": [
"es2017"
],
"moduleResolution": "node",
"noEmit": true,
"strict": true,
"target": "esnext",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/components/*": [
"./src/components/*"
],
"@/lib/*": [
"./src/lib/*"
],
"@/types/*": [
"./src/types/*"
]
}
},
"exclude": [
"node_modules",
"babel.config.js",
"metro.config.js",
"jest.config.js"
],
"extends": "expo/tsconfig.base"
}