basic login with input

This commit is contained in:
evansteelepdx 2024-12-01 13:13:58 -08:00
parent c01eb5081a
commit 0435213d96
32 changed files with 2994 additions and 649 deletions

5
.eslintrc.js Normal file
View File

@ -0,0 +1,5 @@
// https://docs.expo.dev/guides/using-eslint/
module.exports = {
extends: 'expo',
ignorePatterns: ['/dist/*'],
};

View File

@ -36,6 +36,14 @@
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
},
"extra": {
"proxy": {
"/api": {
"target": "https://portainer.evansteele.net",
"changeOrigin": true
}
}
} }
} }
} }

View File

@ -2,11 +2,11 @@ import { Tabs } from 'expo-router';
import React from 'react'; import React from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { HapticTab } from '@/components/HapticTab'; import { HapticTab } from '../components/HapticTab';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '../components/ui/IconSymbol';
import TabBarBackground from '@/components/ui/TabBarBackground'; import TabBarBackground from '../components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors'; import { Colors } from '..//constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '../hooks/useColorScheme';
export default function TabLayout() { export default function TabLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();

View File

@ -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,
},
});

View File

@ -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',
},
});

View File

@ -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,
},
});

View File

@ -1,39 +1,11 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { Stack } from "expo-router";
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();
export default function RootLayout() { 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 ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <Stack
<Stack> screenOptions={{
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> headerTitle: "Portainer Manager"
<Stack.Screen name="+not-found" /> }}
</Stack> />
<StatusBar style="auto" />
</ThemeProvider>
); );
} }

View File

@ -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'
},
});

View File

@ -39,5 +39,5 @@ export function IconSymbol({
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
weight?: SymbolWeight; weight?: SymbolWeight;
}) { }) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />; return <MaterialIcons color={color} size={size} name={MAPPING[name]} />;
} }

View File

@ -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;
}

View File

@ -6,8 +6,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function BlurTabBarBackground() { export default function BlurTabBarBackground() {
return ( return (
<BlurView <BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial" tint="systemChromeMaterial"
intensity={100} intensity={100}
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}

View File

@ -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';

View File

@ -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;
};

View File

@ -3,8 +3,8 @@
* https://docs.expo.dev/guides/color-schemes/ * https://docs.expo.dev/guides/color-schemes/
*/ */
import { Colors } from '@/constants/Colors'; import { Colors } from '../constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '../hooks/useColorScheme';
export function useThemeColor( export function useThemeColor(
props: { light?: string; dark?: string }, props: { light?: string; dark?: string },

36
app/index.tsx Normal file
View File

@ -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>
);
}

View File

@ -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,
},
});

View File

@ -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);
}
}}
/>
);
}

View File

@ -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,
},
});

View File

@ -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',
},
});

View File

@ -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',
},
});

View File

@ -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} />;
}

View File

@ -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();
});

View File

@ -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>
`;

View File

@ -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;
}

2539
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"expo": "~52.0.11", "expo": "~52.0.11",
@ -45,6 +46,8 @@
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0", "@types/react-test-renderer": "^18.3.0",
"eslint": "^8.57.0",
"eslint-config-expo": "~8.0.1",
"jest": "^29.2.1", "jest": "^29.2.1",
"jest-expo": "~52.0.2", "jest-expo": "~52.0.2",
"react-test-renderer": "18.3.1", "react-test-renderer": "18.3.1",

View File

@ -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();