basic login with input
This commit is contained in:
parent
c01eb5081a
commit
0435213d96
|
|
@ -0,0 +1,5 @@
|
|||
// https://docs.expo.dev/guides/using-eslint/
|
||||
module.exports = {
|
||||
extends: 'expo',
|
||||
ignorePatterns: ['/dist/*'],
|
||||
};
|
||||
8
app.json
8
app.json
|
|
@ -36,6 +36,14 @@
|
|||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"proxy": {
|
||||
"/api": {
|
||||
"target": "https://portainer.evansteele.net",
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { Tabs } from 'expo-router';
|
|||
import React from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { HapticTab } from '@/components/HapticTab';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { HapticTab } from '../components/HapticTab';
|
||||
import { IconSymbol } from '../components/ui/IconSymbol';
|
||||
import TabBarBackground from '../components/ui/TabBarBackground';
|
||||
import { Colors } from '..//constants/Colors';
|
||||
import { useColorScheme } from '../hooks/useColorScheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import { StyleSheet, Image, Platform } from 'react-native';
|
||||
|
||||
import { Collapsible } from '@/components/Collapsible';
|
||||
import { ExternalLink } from '@/components/ExternalLink';
|
||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Explore</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} />
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Custom fonts">
|
||||
<ThemedText>
|
||||
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '}
|
||||
<ThemedText style={{ fontFamily: 'SpaceMono' }}>
|
||||
custom fonts such as this one.
|
||||
</ThemedText>
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: '#808080',
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { Image, StyleSheet, Platform } from 'react-native';
|
||||
|
||||
import { HelloWave } from '@/components/HelloWave';
|
||||
import ParallaxScrollView from '@/components/ParallaxScrollView';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
||||
headerImage={
|
||||
<Image
|
||||
source={require('@/assets/images/partial-react-logo.png')}
|
||||
style={styles.reactLogo}
|
||||
/>
|
||||
}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{' '}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: 'cmd + d',
|
||||
android: 'cmd + m',
|
||||
web: 'F12'
|
||||
})}
|
||||
</ThemedText>{' '}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
<ThemedText>
|
||||
Tap the Explore tab to learn more about what's included in this starter app.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
When you're ready, run{' '}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<Link href="/" style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,39 +1,11 @@
|
|||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerTitle: "Portainer Manager"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Text, View, TextInput, StyleSheet, Animated, Pressable, ActivityIndicator } from "react-native";
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
/**
|
||||
* LoginForm component handles user authentication with a Portainer instance.
|
||||
* It includes a multi-step form that first validates the domain URL,
|
||||
* then allows entering username/password credentials.
|
||||
*/
|
||||
export default function LoginForm() {
|
||||
// Form state and animation values
|
||||
const [formData, setFormData] = useState({ username: '', password: '', domain: '' });
|
||||
const [isDomainValid, setIsDomainValid] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const buttonScale = useState(new Animated.Value(1))[0];
|
||||
const buttonRotation = useState(new Animated.Value(0))[0];
|
||||
const fadeAnim = useState(new Animated.Value(0))[0];
|
||||
const { setAuthState } = useAuth();
|
||||
|
||||
/**
|
||||
* Validates domain input and animates the login form appearance
|
||||
* when a valid domain is entered
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Basic FQDN validation
|
||||
const fqdnRegex = /^(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?::\d+)?(?:\/\S*)?$/;
|
||||
const isValid = fqdnRegex.test(formData.domain);
|
||||
setIsDomainValid(isValid);
|
||||
|
||||
// Fade animation
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: isValid ? 1 : 0,
|
||||
duration: 500,
|
||||
useNativeDriver: true
|
||||
}).start();
|
||||
}, [formData.domain]);
|
||||
|
||||
/**
|
||||
* Handles the login form submission and authentication process
|
||||
* Includes success/failure animations for visual feedback
|
||||
*/
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${formData.domain}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
credentials: 'include', // Include cookies if needed
|
||||
body: JSON.stringify({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Store the domain and auth data
|
||||
await setAuthState(formData.domain, formData.username, data);
|
||||
|
||||
// Success animation sequence: rotate -> scale up -> scale back
|
||||
Animated.sequence([
|
||||
Animated.timing(buttonRotation, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.spring(buttonScale, {
|
||||
toValue: 1.1,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.spring(buttonScale, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true
|
||||
})
|
||||
]).start();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Domain input section */}
|
||||
<Text style={styles.title}>Please enter your Portainer domain</Text>
|
||||
<Text style={styles.infoText}>Domain must be HTTPS</Text>
|
||||
<TextInput
|
||||
style={styles.domainInput}
|
||||
placeholder="https://your-portainer-domain.com"
|
||||
placeholderTextColor="#9EA0A4"
|
||||
onChangeText={text => setFormData({ ...formData, domain: text })}
|
||||
/>
|
||||
|
||||
{/* Login credentials section - appears after valid domain */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.loginSection,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{
|
||||
translateY: fadeAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [20, 0]
|
||||
})
|
||||
}]
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={styles.title}>Please enter your username and password</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Username"
|
||||
placeholderTextColor="#9EA0A4"
|
||||
onChangeText={text => setFormData({ ...formData, username: text })}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
secureTextEntry
|
||||
placeholder="Password"
|
||||
placeholderTextColor="#9EA0A4"
|
||||
onChangeText={text => setFormData({ ...formData, password: text })}
|
||||
/>
|
||||
|
||||
{/* Animated login button */}
|
||||
<Animated.View style={{
|
||||
transform: [
|
||||
{ scale: buttonScale },
|
||||
{
|
||||
rotate: buttonRotation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg']
|
||||
})
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!isLoading) {
|
||||
// Button press animation
|
||||
Animated.sequence([
|
||||
Animated.spring(buttonScale, {
|
||||
toValue: 0.95,
|
||||
useNativeDriver: true
|
||||
}),
|
||||
Animated.spring(buttonScale, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true
|
||||
})
|
||||
]).start();
|
||||
handleLogin();
|
||||
}
|
||||
}}
|
||||
style={styles.loginButton}
|
||||
>
|
||||
<View style={styles.buttonContent}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="log-in-outline" size={24} color="#fff" style={styles.buttonIcon} />
|
||||
<Text style={styles.buttonText}>Login</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component styles
|
||||
* Includes styling for container, form inputs, buttons, and animations
|
||||
*/
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
padding: 10,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 10,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
loginSection: {
|
||||
marginTop: 20,
|
||||
paddingTop: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E9ECEF'
|
||||
},
|
||||
domainInput: {
|
||||
backgroundColor: '#EDF2F7',
|
||||
borderRadius: 8,
|
||||
padding: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: '#CBD5E0',
|
||||
fontSize: 16,
|
||||
color: '#4A5568',
|
||||
fontWeight: '500'
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center'
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
padding: 15,
|
||||
marginBottom: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E9ECEF',
|
||||
fontSize: 16,
|
||||
color: '#2C3E50'
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: '#4299E1',
|
||||
borderRadius: 8,
|
||||
padding: 15,
|
||||
marginTop: 10,
|
||||
shadowColor: "#4299E1",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 4.65,
|
||||
elevation: 8,
|
||||
},
|
||||
buttonContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
marginLeft: 8,
|
||||
fontWeight: '600'
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#718096',
|
||||
marginBottom: 15,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic'
|
||||
},
|
||||
});
|
||||
|
|
@ -39,5 +39,5 @@ export function IconSymbol({
|
|||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { StyleSheet, View } from 'react-native';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function BlurTabBarBackground() {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ backgroundColor: 'rgba(255, 255, 255, 0.8)' }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBottomTabOverflow() {
|
||||
const tabHeight = useBottomTabBarHeight();
|
||||
const { bottom } = useSafeAreaInsets();
|
||||
return tabHeight - bottom;
|
||||
}
|
||||
|
|
@ -6,8 +6,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||
export default function BlurTabBarBackground() {
|
||||
return (
|
||||
<BlurView
|
||||
// System chrome material automatically adapts to the system's theme
|
||||
// and matches the native tab bar appearance on iOS.
|
||||
tint="systemChromeMaterial"
|
||||
intensity={100}
|
||||
style={StyleSheet.absoluteFill}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Platform } from 'react-native';
|
||||
|
||||
// Re-export the platform-specific implementation
|
||||
export { default } from './TabBarBackground.ios';
|
||||
export { useBottomTabOverflow } from './TabBarBackground.ios';
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
type AuthContextType = {
|
||||
isAuthenticated: boolean;
|
||||
domain: string | null;
|
||||
username: string | null;
|
||||
authData: any | null;
|
||||
setAuthState: (domain: string, username: string, authData: any) => Promise<void>;
|
||||
clearAuth: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [domain, setDomain] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [authData, setAuthData] = useState<any | null>(null);
|
||||
|
||||
// Load stored auth state on app start
|
||||
useEffect(() => {
|
||||
loadStoredAuth();
|
||||
}, []);
|
||||
|
||||
const loadStoredAuth = async () => {
|
||||
try {
|
||||
const [storedDomain, storedUsername, storedAuthData] = await Promise.all([
|
||||
AsyncStorage.getItem('domain'),
|
||||
AsyncStorage.getItem('username'),
|
||||
AsyncStorage.getItem('authData')
|
||||
]);
|
||||
|
||||
if (storedDomain && storedUsername && storedAuthData) {
|
||||
setDomain(storedDomain);
|
||||
setUsername(storedUsername);
|
||||
setAuthData(JSON.parse(storedAuthData));
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading auth state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const setAuthState = async (newDomain: string, newUsername: string, newAuthData: any) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem('domain', newDomain),
|
||||
AsyncStorage.setItem('username', newUsername),
|
||||
AsyncStorage.setItem('authData', JSON.stringify(newAuthData))
|
||||
]);
|
||||
|
||||
setDomain(newDomain);
|
||||
setUsername(newUsername);
|
||||
setAuthData(newAuthData);
|
||||
setIsAuthenticated(true);
|
||||
} catch (error) {
|
||||
console.error('Error saving auth state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuth = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
AsyncStorage.removeItem('domain'),
|
||||
AsyncStorage.removeItem('authData')
|
||||
]);
|
||||
|
||||
setDomain(null);
|
||||
setUsername(null);
|
||||
setAuthData(null);
|
||||
setIsAuthenticated(false);
|
||||
} catch (error) {
|
||||
console.error('Error clearing auth state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
isAuthenticated,
|
||||
domain,
|
||||
username,
|
||||
authData,
|
||||
setAuthState,
|
||||
clearAuth
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Colors } from '../constants/Colors';
|
||||
import { useColorScheme } from '../hooks/useColorScheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { Text, View } from "react-native";
|
||||
import LoginForm from "./components/LoginForm";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { useAuth } from "./context/AuthContext";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
|
||||
<AuthProvider>
|
||||
<View style={{ flex: 1, padding: 16, width: '100%' }}>
|
||||
<MainContent />
|
||||
</View>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MainContent() {
|
||||
const { isAuthenticated, domain, username, authData } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 16, width: '100%' }}>
|
||||
<LoginForm />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, padding: 16, width: '100%' }}>
|
||||
<Text>Connected to: {domain}</Text>
|
||||
<Text>User: {username}</Text>
|
||||
<Text>Debug authData: {JSON.stringify(authData, null, 2)}</Text>
|
||||
{/* Use authData as needed */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { Link } from 'expo-router';
|
||||
import { openBrowserAsync } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (Platform.OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
|
||||
export function HelloWave() {
|
||||
const rotationAnimation = useSharedValue(0);
|
||||
|
||||
rotationAnimation.value = withRepeat(
|
||||
withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
|
||||
4 // Run the animation 4 times
|
||||
);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${rotationAnimation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={animatedStyle}>
|
||||
<ThemedText style={styles.text}>👋</ThemedText>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollViewOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollViewOffset(scrollRef);
|
||||
const bottom = useBottomTabOverflow();
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
scrollIndicatorInsets={{ bottom }}
|
||||
contentContainerStyle={{ paddingBottom: bottom }}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { Text, type TextProps, StyleSheet } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { ThemedText } from '../ThemedText';
|
||||
|
||||
it(`renders correctly`, () => {
|
||||
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Text
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#11181C",
|
||||
},
|
||||
{
|
||||
"fontSize": 16,
|
||||
"lineHeight": 24,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
Snapshot test!
|
||||
</Text>
|
||||
`;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// This is a shim for web and Android where the tab bar is generally opaque.
|
||||
export default undefined;
|
||||
|
||||
export function useBottomTabOverflow() {
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -16,6 +16,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@react-native-async-storage/async-storage": "^2.1.0",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"expo": "~52.0.11",
|
||||
|
|
@ -45,6 +46,8 @@
|
|||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.3.12",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~8.0.1",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~52.0.2",
|
||||
"react-test-renderer": "18.3.1",
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const newDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const newDirPath = path.join(root, newDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const moveDirectories = async () => {
|
||||
try {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(newDirPath, { recursive: true });
|
||||
console.log(`📁 /${newDir} directory created.`);
|
||||
|
||||
// Move old directories to new app-example directory
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
const newDirPath = path.join(root, newDir, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${newDir}/${dir}.`);
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
"1. Run `npx expo start` to start a development server.\n2. Edit app/index.tsx to edit the main screen.\n3. Delete the /app-example directory when you're done referencing it."
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Error during script execution: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
moveDirectories();
|
||||
Loading…
Reference in New Issue