|--app
|---_layout.tsx
|---ArticleListScreen.tsx
|---CompalyListScreen.tsx
|---CropListScreen.tsx
|---HomeScreen.tsx
|---index.tsx
|---LoginScreen.tsx
|---SignupScreen.tsx
|--utils
|---axios.ts
|---storage.ts
|--app
|---article/
|----[slug].tsx
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Image,
ActivityIndicator,
TouchableOpacity
} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import axios from '../../utils/axios';
import Ionicons from 'react-native-vector-icons/Ionicons';
interface ArticleImage {
id: number;
url: string;
formats?: {
thumbnail?: { url: string };
small?: { url: string };
medium?: { url: string };
};
}
interface Article {
id: number;
title: string;
slug: string;
publishedBy: string;
publishedDate: string;
mainImage?: ArticleImage;
content?: Array<{
__component: string;
id: number;
heading?: string;
paragraph?: any[];
image?: {
data?: ArticleImage;
};
}>;
}
const ArticleDetailScreen = () => {
const { slug } = useLocalSearchParams();
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchArticle = async () => {
try {
setLoading(true);
setError(null);
// Try with simplified population first
const response = await axios.get('/articles', {
params: {
filters: { slug: { $eq: slug } },
populate: ['mainImage', 'content.image']
}
});
console.log('API Response:', response.data);
if (!response.data?.data?.[0]) {
throw new Error('Article not found');
}
// Transform the API response
const apiArticle = response.data.data[0];
const transformedArticle: Article = {
id: apiArticle.id,
title: apiArticle.title,
slug: apiArticle.slug,
publishedBy: apiArticle.publishedBy,
publishedDate: apiArticle.publishedDate,
mainImage: apiArticle.mainImage,
content: apiArticle.content?.map(item => ({
...item,
image: item.image ? { data: item.image } : undefined
}))
};
setArticle(transformedArticle);
} catch (err) {
console.error('Error fetching article:', err);
setError(err.response?.data?.error?.message ||
err.message ||
'Failed to load article. Please try again.');
} finally {
setLoading(false);
}
};
fetchArticle();
}, [slug]);
const getImageUrl = (imageData?: ArticleImage) => {
if (!imageData?.url) return null;
const url = imageData.formats?.medium?.url ||
imageData.formats?.small?.url ||
imageData.url;
return url.startsWith('http') ? url : `${axios.defaults.baseURL?.replace('/api', '')}${url}`;
};
const renderContent = () => {
if (!article?.content) return null;
return article.content.map((component, index) => {
switch (component.__component) {
case 'heading.heading':
return (
<Text key={`heading-${index}`} style={styles.heading}>
{component.heading}
</Text>
);
case 'paragraph.paragraph':
return (
<View key={`paragraph-${index}`} style={styles.paragraph}>
{component.paragraph?.map((para, paraIndex) => {
if (para.type === 'paragraph') {
return (
<Text key={`para-${paraIndex}`} style={styles.paragraphText}>
{para.children?.[0]?.text}
</Text>
);
} else if (para.type === 'list') {
return (
<View key={`list-${paraIndex}`} style={styles.listContainer}>
{para.children?.map((item, itemIndex) => (
<View key={`item-${itemIndex}`} style={styles.listItem}>
<Text style={styles.bullet}>•</Text>
<Text style={styles.listText}>
{item.children?.[0]?.text}
</Text>
</View>
))}
</View>
);
}
return null;
})}
</View>
);
case 'image.image':
const imageUrl = getImageUrl(component.image?.data);
return imageUrl ? (
<Image
key={`image-${index}`}
source={{ uri: imageUrl }}
style={styles.contentImage}
resizeMode="contain"
defaultSource={require('../../assets/placeholder-image.png')}
/>
) : null;
default:
return null;
}
});
};
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#2E7D32" />
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<Ionicons name="warning-outline" size={40} color="#dc3545" />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => window.location.reload()}
>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
if (!article) {
return (
<View style={styles.centerContainer}>
<Ionicons name="document-text-outline" size={50} color="#6c757d" />
<Text style={styles.emptyText}>Article not found</Text>
</View>
);
}
const mainImageUrl = getImageUrl(article.mainImage);
return (
<ScrollView contentContainerStyle={styles.container}>
{mainImageUrl ? (
<Image
source={{ uri: mainImageUrl }}
style={styles.mainImage}
resizeMode="cover"
defaultSource={require('../../assets/placeholder-image.png')}
/>
) : (
<Image
source={require('../../assets/placeholder-image.png')}
style={styles.mainImage}
resizeMode="cover"
/>
)}
<View style={styles.contentContainer}>
<Text style={styles.title}>{article.title}</Text>
<View style={styles.metaContainer}>
<Text style={styles.author}>{article.publishedBy}</Text>
<Text style={styles.date}>
{new Date(article.publishedDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Text>
</View>
{renderContent()}
</View>
</ScrollView>
);
};
const styles = StyleSheet.create({
container: {
paddingBottom: 40,
backgroundColor: 'white',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
color: '#dc3545',
fontSize: 16,
marginVertical: 16,
textAlign: 'center',
},
retryButton: {
backgroundColor: '#2E7D32',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 6,
},
retryButtonText: {
color: 'white',
fontWeight: '600',
},
emptyText: {
fontSize: 18,
color: '#6c757d',
marginTop: 16,
},
mainImage: {
width: '100%',
height: 250,
backgroundColor: '#f5f5f5',
},
contentContainer: {
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 16,
color: '#212529',
lineHeight: 34,
},
metaContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 24,
borderBottomWidth: 1,
borderBottomColor: '#e9ecef',
paddingBottom: 16,
},
author: {
fontSize: 14,
color: '#495057',
fontWeight: '600',
},
date: {
fontSize: 14,
color: '#6c757d',
},
heading: {
fontSize: 22,
fontWeight: 'bold',
marginVertical: 16,
color: '#2E7D32',
},
paragraph: {
marginBottom: 16,
},
paragraphText: {
fontSize: 16,
lineHeight: 24,
color: '#212529',
},
listContainer: {
marginLeft: 16,
marginBottom: 16,
},
listItem: {
flexDirection: 'row',
marginBottom: 8,
},
bullet: {
marginRight: 8,
color: '#2E7D32',
},
listText: {
fontSize: 16,
lineHeight: 24,
color: '#212529',
flex: 1,
},
contentImage: {
width: '100%',
height: 200,
marginVertical: 16,
borderRadius: 8,
backgroundColor: '#f5f5f5',
},
});
export default ArticleDetailScreen;
|---_layout.tsx
import { DefaultTheme, DarkTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import Storage from '../utils/storage'; // Import Storage
import { useRouter } from 'expo-router';
import { Platform } from 'react-native';
export default function RootLayout() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [theme, setTheme] = useState('light'); // Default theme
const router = useRouter();
// Function to check for JWT in localStorage or AsyncStorage
const checkAuthentication = async () => {
const jwt = await Storage.getItem('jwt');
if (jwt) {
setIsAuthenticated(true);
return '/HomeScreen'; // Go to home screen if JWT exists
} else {
setIsAuthenticated(false);
return '/LoginScreen'; // Redirect to login if no JWT
}
};
// Check theme based on platform
useEffect(() => {
if (Platform.OS === 'web') {
// For web, use window.matchMedia to get the theme
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
setTheme(darkModeQuery.matches ? 'dark' : 'light');
// Listen for changes in theme preference
darkModeQuery.addEventListener('change', (e) => {
setTheme(e.matches ? 'dark' : 'light');
});
} else {
// For React Native, use useColorScheme
import('react-native').then(({ useColorScheme }) => {
const colorScheme = useColorScheme();
setTheme(colorScheme === 'dark' ? 'dark' : 'light');
});
}
checkAuthentication();
}, [router]);
// Select theme
const currentTheme = theme === 'dark' ? DarkTheme : DefaultTheme;
return (
<ThemeProvider value={currentTheme}>
<Stack>
{isAuthenticated ? (
<Stack.Screen name="HomeScreen" options={{ headerShown: false }} />
) : (
<Stack.Screen name="LoginScreen" options={{ headerShown: false }} />
)}
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
}
|---ArticleListScreen.tsx
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
Image,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
Platform
} from 'react-native';
import { Link } from 'expo-router';
import axios from '../utils/axios';
import Ionicons from 'react-native-vector-icons/Ionicons';
interface ArticleImage {
id: number;
attributes: {
url: string;
formats?: {
thumbnail?: {
url: string;
};
small?: {
url: string;
};
medium?: {
url: string;
};
};
};
}
interface Article {
id: number;
title: string;
slug: string;
publishedBy: string;
publishedDate: string;
mainImage?: {
id: number;
url: string;
formats?: {
thumbnail?: {
url: string;
};
small?: {
url: string;
};
};
};
}
const ArticleListScreen = () => {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const fetchArticles = async (isRefreshing = false) => {
try {
if (isRefreshing) {
setRefreshing(true);
setPage(1);
} else {
setLoading(true);
}
setError(null);
const response = await axios.get('/articles', {
params: {
populate: '*',
sort: 'publishedDate:desc',
'pagination[page]': isRefreshing ? 1 : page,
'pagination[pageSize]': 10,
publicationState: 'live'
}
});
console.log('API Response:', JSON.stringify(response.data, null, 2));
if (!response.data?.data || !Array.isArray(response.data.data)) {
throw new Error('Invalid API response structure');
}
const transformedArticles = response.data.data
.filter(apiArticle => apiArticle?.title) // Check for title at root level
.map((apiArticle) => ({
id: apiArticle.id,
title: apiArticle.title || 'Untitled Article',
slug: apiArticle.slug || '',
publishedBy: apiArticle.publishedBy || 'Unknown Author',
publishedDate: apiArticle.publishedDate || new Date().toISOString(),
mainImage: apiArticle.mainImage
}));
if (transformedArticles.length === 0) {
throw new Error('No articles found');
}
if (isRefreshing || page === 1) {
setArticles(transformedArticles);
} else {
setArticles(prev => [...prev, ...transformedArticles]);
}
setTotalPages(response.data.meta?.pagination?.pageCount || 1);
} catch (err) {
console.error('API Error:', err);
setError(err.response?.data?.error?.message ||
err.message ||
'Failed to load articles. Please try again.');
if (isRefreshing || page === 1) {
setArticles([]);
}
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchArticles();
}, [page]);
const onRefresh = () => {
fetchArticles(true);
};
const loadMoreArticles = () => {
if (page < totalPages && !loading) {
setPage(prev => prev + 1);
}
};
const renderArticleItem = ({ item }: { item: Article }) => {
if (!item) return null;
let imageUrl;
if (item.mainImage) {
imageUrl = item.mainImage.formats?.small?.url ||
item.mainImage.url;
}
return (
<Link href={`/articles/${item.slug || ''}`} asChild>
<TouchableOpacity style={styles.articleCard}>
{imageUrl ? (
<Image
source={{
uri: imageUrl.startsWith('http')
? imageUrl
: `${axios.defaults.baseURL?.replace('/api', '')}${imageUrl}`
}}
style={styles.articleImage}
resizeMode="cover"
defaultSource={require('../assets/placeholder-image.png')}
/>
) : (
<Image
source={require('../assets/placeholder-image.png')}
style={styles.articleImage}
resizeMode="cover"
/>
)}
<View style={styles.articleContent}>
<Text style={styles.articleTitle}>{item.title || 'Untitled Article'}</Text>
<Text style={styles.articleAuthor}>{item.publishedBy || 'Unknown Author'}</Text>
<Text style={styles.articleDate}>
{item.publishedDate ?
new Date(item.publishedDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : 'Date not available'}
</Text>
</View>
</TouchableOpacity>
</Link>
);
};
const renderFooter = () => {
if (!loading || page === 1) return null;
return (
<View style={styles.footer}>
<ActivityIndicator size="small" color="#2E7D32" />
</View>
);
};
if (loading && page === 1 && !refreshing) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color="#2E7D32" />
<Text style={styles.loadingText}>Loading articles...</Text>
</View>
);
}
if (error && articles.length === 0) {
return (
<View style={styles.centerContainer}>
<Ionicons name="warning-outline" size={40} color="#dc3545" />
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity
style={styles.retryButton}
onPress={() => fetchArticles(true)}
>
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
if (articles.length === 0 && !loading) {
return (
<View style={styles.centerContainer}>
<Ionicons name="newspaper-outline" size={50} color="#6c757d" />
<Text style={styles.emptyText}>No articles found</Text>
<TouchableOpacity
style={styles.refreshButton}
onPress={onRefresh}
>
<Text style={styles.refreshButtonText}>Refresh</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlatList
data={articles}
keyExtractor={(item) => item.id.toString()}
renderItem={renderArticleItem}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={['#2E7D32']}
tintColor="#2E7D32"
/>
}
contentContainerStyle={styles.listContainer}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListFooterComponent={renderFooter}
onEndReached={loadMoreArticles}
onEndReachedThreshold={0.5}
/>
);
};
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
loadingText: {
marginTop: 16,
color: '#2E7D32',
fontSize: 16,
},
errorText: {
color: '#dc3545',
fontSize: 16,
marginVertical: 16,
textAlign: 'center',
},
retryButton: {
backgroundColor: '#2E7D32',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 6,
},
retryButtonText: {
color: 'white',
fontWeight: '600',
},
emptyText: {
fontSize: 18,
color: '#6c757d',
marginTop: 16,
},
refreshButton: {
borderWidth: 1,
borderColor: '#2E7D32',
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 6,
marginTop: 16,
},
refreshButtonText: {
color: '#2E7D32',
fontWeight: '600',
},
listContainer: {
padding: 16,
paddingBottom: Platform.OS === 'ios' ? 80 : 60,
},
articleCard: {
backgroundColor: 'white',
borderRadius: 12,
overflow: 'hidden',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 6,
},
android: {
elevation: 2,
},
web: {
boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.1)',
}
}),
},
articleImage: {
width: '100%',
height: 200,
backgroundColor: '#f5f5f5',
},
articleContent: {
padding: 16,
},
articleTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 8,
color: '#212529',
},
articleAuthor: {
fontSize: 14,
color: '#495057',
marginBottom: 4,
},
articleDate: {
fontSize: 12,
color: '#6c757d',
},
separator: {
height: 16,
},
footer: {
padding: 20,
justifyContent: 'center',
alignItems: 'center',
},
});
export default ArticleListScreen;
|---CompalyListScreen.tsx
// app/CompanyListScreen.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
const CompanyListScreen = () => {
return (
<View style={styles.container}>
<Text style={styles.title}>Company List</Text>
{/* Add your company list content here */}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
backgroundColor: 'white',
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
});
export default CompanyListScreen;
|---CropListScreen.tsx
// app/CropListScreen.tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
const CropListScreen = () => {
return (
<View style={styles.container}>
<Text style={styles.title}>Crop List</Text>
{/* Add your crop list content here */}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
backgroundColor: 'white',
},
title: {
fontSize: 24,
fontWeight: 'bold',
},
});
export default CropListScreen;
|---HomeScreen.tsx
// app/HomeScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ImageBackground } from 'react-native';
import { useRouter } from 'expo-router';
import Storage from '../utils/storage';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Ionicons from 'react-native-vector-icons/Ionicons';
// Import screens for each tab
import CompanyListScreen from './CompanyListScreen';
import CropListScreen from './CropListScreen';
import ArticleListScreen from './ArticleListScreen';
const Tab = createBottomTabNavigator();
export default function HomeScreen() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userName, setUserName] = useState('');
const router = useRouter();
useEffect(() => {
const checkAuthentication = async () => {
const jwt = await Storage.getItem('jwt');
const user = await Storage.getItem('user');
if (jwt) {
setIsAuthenticated(true);
if (user) {
setUserName(JSON.parse(user).name || 'User');
}
} else {
router.replace('/LoginScreen');
}
};
checkAuthentication();
}, [router]);
const handleLogout = async () => {
await Storage.removeItem('jwt');
await Storage.removeItem('user');
router.replace('/LoginScreen');
};
return (
<ImageBackground
source={require('../assets/images/partial-react-logo.png')}
style={styles.background}
blurRadius={2}
>
<View style={styles.overlay}>
{isAuthenticated ? (
<>
<View style={styles.header}>
<View style={styles.headerContent}>
<Text style={styles.greeting}>Welcome back,</Text>
<Text style={styles.userName}>{userName}</Text>
</View>
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Ionicons name="log-out-outline" size={24} color="white" />
</TouchableOpacity>
</View>
{/* Bottom Tab Navigator */}
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName;
if (route.name === 'Vertical Farming') {
iconName = focused ? 'leaf' : 'leaf-outline';
} else if (route.name === 'Crop') {
iconName = focused ? 'shield-checkmark' : 'shield-checkmark-outline';
} else if (route.name === 'Companies') {
iconName = focused ? 'business' : 'business-outline';
}
return <Ionicons name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#2E7D32',
tabBarInactiveTintColor: '#757575',
tabBarStyle: {
backgroundColor: 'white',
borderTopWidth: 0,
elevation: 10,
shadowOpacity: 0.1,
shadowRadius: 10,
shadowOffset: { width: 0, height: -5 },
height: 70,
paddingBottom: 10,
},
tabBarLabelStyle: {
fontSize: 12,
marginBottom: 5,
},
headerShown: false,
})}
>
<Tab.Screen
name="Vertical Farming"
component={ArticleListScreen}
options={{
tabBarLabel: 'Articles',
}}
/>
<Tab.Screen
name="Crop"
component={CropListScreen}
options={{
tabBarLabel: 'Crops',
}}
/>
<Tab.Screen
name="Companies"
component={CompanyListScreen}
options={{
tabBarLabel: 'Companies',
}}
/>
</Tab.Navigator>
</>
) : (
<View style={styles.loadingContainer}>
<Ionicons name="leaf" size={50} color="#2E7D32" />
<Text style={styles.loadingText}>Loading your farm data...</Text>
</View>
)}
</View>
</ImageBackground>
);
}
const styles = StyleSheet.create({
background: {
flex: 1,
resizeMode: 'cover',
},
overlay: {
flex: 1,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 50,
paddingBottom: 20,
borderBottomLeftRadius: 20,
borderBottomRightRadius: 20,
backgroundColor: '#2E7D32',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 6,
elevation: 5,
},
headerContent: {
flex: 1,
},
greeting: {
fontSize: 16,
color: 'white',
opacity: 0.9,
},
userName: {
fontSize: 24,
fontWeight: 'bold',
color: 'white',
marginTop: 5,
},
logoutButton: {
padding: 10,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 20,
fontSize: 18,
color: '#2E7D32',
fontWeight: '500',
},
});
|---index.tsx
import { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
export default function SplashScreen() {
const router = useRouter();
useEffect(() => {
// Wait for 10 seconds before navigating to LoginScreen
const timer = setTimeout(() => {
router.replace('/LoginScreen'); // Navigate to LoginScreen after splash
}, 3000); // 10 seconds delay
// Clean up timeout if the component unmounts before 10 seconds
return () => clearTimeout(timer);
}, [router]);
return (
<View style={styles.container}>
<Text style={styles.text}>VertiGrow</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white', // Set splash background color
},
text: {
fontSize: 32,
fontWeight: 'bold',
},
});
|---LoginScreen.tsx
import React, { useState, useEffect } from 'react';
import { View, TextInput, Text, StyleSheet, TouchableOpacity, Animated, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import axios from '../utils/axios';
import Storage from '../utils/storage';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialIcons, MaterialCommunityIcons } from '@expo/vector-icons';
const LoginScreen = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
// Animation refs
const scaleValue = React.useRef(new Animated.Value(1)).current;
const fadeValue = React.useRef(new Animated.Value(0)).current;
const slideValue = React.useRef(new Animated.Value(20)).current;
useEffect(() => {
const animation = Animated.parallel([
Animated.timing(fadeValue, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(slideValue, {
toValue: 0,
damping: 10,
useNativeDriver: true,
})
]);
animation.start();
return () => {
animation.stop();
};
}, []);
const handlePressIn = () => {
Animated.spring(scaleValue, {
toValue: 0.95,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scaleValue, {
toValue: 1,
friction: 3,
tension: 40,
useNativeDriver: true,
}).start();
};
const handleLogin = async () => {
if (isLoading) return;
if (!username || !password) {
showErrorAnimation();
alert('Please fill in all fields');
return;
}
setIsLoading(true);
try {
const response = await axios.post('/auth/local', {
identifier: username,
password,
});
if (response.data?.jwt) {
await Storage.setItem('jwt', response.data.jwt);
router.replace('/HomeScreen');
}
} catch (error) {
console.error('Login error:', error);
showErrorAnimation();
alert('Invalid credentials, please try again.');
} finally {
setIsLoading(false);
}
};
const showErrorAnimation = () => {
Animated.sequence([
Animated.timing(slideValue, {
toValue: 10,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(slideValue, {
toValue: -10,
duration: 50,
useNativeDriver: true,
}),
Animated.spring(slideValue, {
toValue: 0,
damping: 10,
useNativeDriver: true,
}),
]).start();
};
return (
<LinearGradient
colors={['#e8f5e9', '#c8e6c9', '#a5d6a7']}
style={styles.container}
>
<Animated.View
style={[
styles.animatedContainer,
{
opacity: fadeValue,
transform: [{ translateY: slideValue }],
}
]}
>
<MaterialCommunityIcons
name="sprout"
size={80}
color="#2e7d32"
style={styles.logoIcon}
/>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Continue your VertiGrow journey</Text>
<View style={styles.inputContainer}>
<MaterialIcons name="email" size={24} color="#2e7d32" />
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#81c784"
value={username}
onChangeText={setUsername}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
editable={!isLoading}
/>
</View>
<View style={styles.inputContainer}>
<MaterialIcons name="lock" size={24} color="#2e7d32" />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#81c784"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
textContentType="password"
editable={!isLoading}
/>
</View>
<Animated.View style={{ transform: [{ scale: scaleValue }] }}>
<TouchableOpacity
style={[styles.loginButton, isLoading && styles.disabledButton]}
onPress={handleLogin}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={0.7}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'LOGGING IN...' : 'LOGIN'}
</Text>
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
onPress={() => router.push('/SignupScreen')}
style={styles.signupLinkContainer}
disabled={isLoading}
>
<Text style={styles.signupText}>
Don't have an account? <Text style={styles.signupLink}>Sign up</Text>
</Text>
</TouchableOpacity>
<View style={styles.plantsContainer}>
<MaterialCommunityIcons
name="leaf"
size={40}
color="#2e7d32"
style={[styles.plantIcon, styles.plantLeft]}
/>
<MaterialCommunityIcons
name="sprout-outline"
size={60}
color="#388e3c"
style={styles.plantCenter}
/>
<MaterialCommunityIcons
name="leaf"
size={40}
color="#2e7d32"
style={[styles.plantIcon, styles.plantRight]}
/>
</View>
</Animated.View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
animatedContainer: {
width: '100%',
alignItems: 'center',
},
logoIcon: {
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#2e7d32',
marginBottom: 5,
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
subtitle: {
fontSize: 16,
color: '#81c784',
marginBottom: 30,
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
marginBottom: 15,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 5,
},
input: {
flex: 1,
height: 50,
color: '#2e7d32',
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
marginLeft: 10,
},
loginButton: {
backgroundColor: '#2e7d32',
paddingVertical: 15,
paddingHorizontal: 40,
borderRadius: 25,
marginTop: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 5,
},
disabledButton: {
opacity: 0.7,
},
buttonText: {
color: 'white',
fontWeight: 'bold',
fontSize: 16,
textAlign: 'center',
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
signupLinkContainer: {
marginTop: 20,
},
signupText: {
color: '#81c784',
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
signupLink: {
color: '#2e7d32',
fontWeight: 'bold',
},
plantsContainer: {
flexDirection: 'row',
marginTop: 40,
alignItems: 'flex-end',
height: 80,
},
plantIcon: {
opacity: 0.8,
},
plantLeft: {
transform: [{ rotate: '-30deg' }],
marginRight: -15,
},
plantCenter: {
zIndex: 1,
},
plantRight: {
transform: [{ rotate: '30deg' }],
marginLeft: -15,
},
});
export default LoginScreen;
|---SignupScreen.tsx
import React, { useState, useEffect } from 'react';
import { View, TextInput, Text, StyleSheet, TouchableOpacity, Animated, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import axios from '../utils/axios';
import Storage from '../utils/storage';
import { LinearGradient } from 'expo-linear-gradient';
import { MaterialIcons, MaterialCommunityIcons } from '@expo/vector-icons';
const SignupScreen = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
// Animation refs
const scaleValue = React.useRef(new Animated.Value(1)).current;
const fadeValue = React.useRef(new Animated.Value(0)).current;
const slideValue = React.useRef(new Animated.Value(20)).current;
useEffect(() => {
const animation = Animated.parallel([
Animated.timing(fadeValue, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.spring(slideValue, {
toValue: 0,
damping: 10,
useNativeDriver: true,
})
]);
animation.start();
return () => {
animation.stop();
};
}, []);
const handlePressIn = () => {
Animated.spring(scaleValue, {
toValue: 0.95,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
Animated.spring(scaleValue, {
toValue: 1,
friction: 3,
tension: 40,
useNativeDriver: true,
}).start();
};
const handleSignup = async () => {
if (isLoading) return;
if (!username || !password || !confirmPassword) {
showErrorAnimation();
alert('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
showErrorAnimation();
alert('Passwords do not match');
return;
}
setIsLoading(true);
try {
const response = await axios.post('/auth/local/register', {
username,
password,
email: username,
});
if (response.data?.jwt) {
await Storage.setItem('jwt', response.data.jwt);
router.replace('/HomeScreen');
}
} catch (error) {
console.error('Signup error:', error);
showErrorAnimation();
alert('Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
const showErrorAnimation = () => {
Animated.sequence([
Animated.timing(slideValue, {
toValue: 10,
duration: 50,
useNativeDriver: true,
}),
Animated.timing(slideValue, {
toValue: -10,
duration: 50,
useNativeDriver: true,
}),
Animated.spring(slideValue, {
toValue: 0,
damping: 10,
useNativeDriver: true,
}),
]).start();
};
return (
<LinearGradient
colors={['#e8f5e9', '#c8e6c9', '#a5d6a7']}
style={styles.container}
>
<Animated.View
style={[
styles.animatedContainer,
{
opacity: fadeValue,
transform: [{ translateY: slideValue }],
}
]}
>
<MaterialCommunityIcons
name="sprout"
size={80}
color="#2e7d32"
style={styles.logoIcon}
/>
<Text style={styles.title}>Grow With Us</Text>
<Text style={styles.subtitle}>Create your VertiGrow account</Text>
<View style={styles.inputContainer}>
<MaterialIcons name="email" size={24} color="#2e7d32" />
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#81c784"
value={username}
onChangeText={setUsername}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
editable={!isLoading}
/>
</View>
<View style={styles.inputContainer}>
<MaterialIcons name="lock" size={24} color="#2e7d32" />
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#81c784"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
textContentType="password"
editable={!isLoading}
/>
</View>
<View style={styles.inputContainer}>
<MaterialIcons name="lock-outline" size={24} color="#2e7d32" />
<TextInput
style={styles.input}
placeholder="Confirm Password"
placeholderTextColor="#81c784"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
autoComplete="password"
textContentType="password"
editable={!isLoading}
/>
</View>
<Animated.View style={{ transform: [{ scale: scaleValue }] }}>
<TouchableOpacity
style={[styles.signupButton, isLoading && styles.disabledButton]}
onPress={handleSignup}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={0.7}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? 'CREATING ACCOUNT...' : 'SIGN UP'}
</Text>
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
onPress={() => router.push('/LoginScreen')}
style={styles.loginLinkContainer}
disabled={isLoading}
>
<Text style={styles.loginText}>
Already have an account? <Text style={styles.loginLink}>Login</Text>
</Text>
</TouchableOpacity>
<View style={styles.plantsContainer}>
<MaterialCommunityIcons
name="leaf"
size={40}
color="#2e7d32"
style={[styles.plantIcon, styles.plantLeft]}
/>
<MaterialCommunityIcons
name="sprout-outline"
size={60}
color="#388e3c"
style={styles.plantCenter}
/>
<MaterialCommunityIcons
name="leaf"
size={40}
color="#2e7d32"
style={[styles.plantIcon, styles.plantRight]}
/>
</View>
</Animated.View>
</LinearGradient>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
animatedContainer: {
width: '100%',
alignItems: 'center',
},
logoIcon: {
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#2e7d32',
marginBottom: 5,
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
subtitle: {
fontSize: 16,
color: '#81c784',
marginBottom: 30,
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
marginBottom: 15,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
borderRadius: 25,
paddingHorizontal: 20,
paddingVertical: 5,
},
input: {
flex: 1,
height: 50,
color: '#2e7d32',
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
marginLeft: 10,
},
signupButton: {
backgroundColor: '#2e7d32',
paddingVertical: 15,
paddingHorizontal: 40,
borderRadius: 25,
marginTop: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
elevation: 5,
},
disabledButton: {
opacity: 0.7,
},
buttonText: {
color: 'white',
fontWeight: 'bold',
fontSize: 16,
textAlign: 'center',
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
loginLinkContainer: {
marginTop: 20,
},
loginText: {
color: '#81c784',
fontFamily: Platform.OS === 'ios' ? 'Avenir' : 'Roboto',
},
loginLink: {
color: '#2e7d32',
fontWeight: 'bold',
},
plantsContainer: {
flexDirection: 'row',
marginTop: 40,
alignItems: 'flex-end',
height: 80,
},
plantIcon: {
opacity: 0.8,
},
plantLeft: {
transform: [{ rotate: '-30deg' }],
marginRight: -15,
},
plantCenter: {
zIndex: 1,
},
plantRight: {
transform: [{ rotate: '30deg' }],
marginLeft: -15,
},
});
export default SignupScreen;
|--utils
|---axios.ts
// utils/axios.ts
import axios from 'axios';
const instance = axios.create({
baseURL: 'http://192.168.29.56:1337/api', // Use your local IP and append /api
headers: {
'Content-Type': 'application/json',
},
});
export default instance;
|---storage.ts
// utils/storage.ts
import { Platform } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
const Storage = {
// Save item based on platform (AsyncStorage for mobile, localStorage for web)
async setItem(key: string, value: string) {
if (Platform.OS === 'web') {
localStorage.setItem(key, value);
// Trigger storage event to notify other tabs
window.dispatchEvent(new Event('storage'));
} else {
await AsyncStorage.setItem(key, value);
}
},
// Get item based on platform (AsyncStorage for mobile, localStorage for web)
async getItem(key: string) {
if (Platform.OS === 'web') {
return localStorage.getItem(key);
} else {
return await AsyncStorage.getItem(key);
}
},
// Remove item based on platform (AsyncStorage for mobile, localStorage for web)
async removeItem(key: string) {
if (Platform.OS === 'web') {
localStorage.removeItem(key);
// Trigger storage event to notify other tabs
window.dispatchEvent(new Event('storage'));
} else {
await AsyncStorage.removeItem(key);
}
}
};
export default Storage;
0 Comments