feat: Integrate Expo mobile application into monorepo (#16)

- Add mobile/ directory with Expo React Native project
- Create API client with JWT auth and token refresh using AsyncStorage
- Implement AuthContext for login/register/logout flow
- Add screens: Login, Register, Library, Search, Settings
- Set up React Navigation with AuthStack and MainTabs
- Create packages/shared/ with shared types and utilities
- Add shared validation utilities (email, password strength)
- Update root package.json workspaces to include mobile + shared
- Add spec document docs/backend/009-expo-integration.md
This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 02:50:09 +00:00
parent 332b539880
commit fa82fab44a
36 changed files with 2051 additions and 4 deletions
+181
View File
@@ -0,0 +1,181 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { fetchBooks } from "../api/books";
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
export default function LibraryScreen({ navigation }: { navigation: any }): ReactNode {
const [books, setBooks] = useState<Book[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadBooks = useCallback(async (pageNum: number, isRefresh = false) => {
try {
const data: PaginatedResponse<Book> = await fetchBooks(pageNum);
if (isRefresh) {
setBooks(data.results);
} else {
setBooks((prev) => [...prev, ...data.results]);
}
setHasMore(data.next !== null);
setPage(pageNum);
} catch {
// Silent error for now
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadBooks(1, true);
}, [loadBooks]);
const onRefresh = () => {
setRefreshing(true);
loadBooks(1, true);
};
const loadMore = () => {
if (hasMore && !loading) {
loadBooks(page + 1);
}
};
const renderBook = ({ item }: { item: Book }) => (
<TouchableOpacity
style={styles.bookCard}
onPress={() =>
navigation.navigate("BookDetail", { bookId: item.id })
}
>
<View style={styles.bookCover}>
<Text style={styles.coverText}>
{item.title.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.bookInfo}>
<Text style={styles.bookTitle} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.bookAuthor} numberOfLines={1}>
{item.author}
</Text>
<Text style={styles.bookPages}>{item.total_pages} pages</Text>
</View>
</TouchableOpacity>
);
if (loading && books.length === 0) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={books}
renderItem={renderBook}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#4f8ef7"
/>
}
ListEmptyComponent={
<View style={styles.centered}>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
Add books to get started
</Text>
</View>
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
},
list: {
padding: 16,
},
bookCard: {
flexDirection: "row",
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 12,
marginBottom: 12,
borderWidth: 1,
borderColor: "#333",
},
bookCover: {
width: 60,
height: 80,
backgroundColor: "#2a2a4e",
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
coverText: {
fontSize: 24,
fontWeight: "bold",
color: "#4f8ef7",
},
bookInfo: {
flex: 1,
marginLeft: 12,
justifyContent: "center",
},
bookTitle: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
marginBottom: 4,
},
bookAuthor: {
fontSize: 14,
color: "#888",
marginBottom: 4,
},
bookPages: {
fontSize: 12,
color: "#666",
},
emptyText: {
fontSize: 18,
fontWeight: "600",
color: "#888",
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: "#666",
},
});
+173
View File
@@ -0,0 +1,173 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { isValidEmail } from "@cloud-reader/shared";
export default function LoginScreen({ navigation }: { navigation: any }): ReactNode {
const { state, login, clearError } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleLogin = async () => {
clearError();
if (!email.trim()) {
Alert.alert("Validation Error", "Please enter your email.");
return;
}
if (!isValidEmail(email.trim())) {
Alert.alert("Validation Error", "Please enter a valid email address.");
return;
}
if (!password) {
Alert.alert("Validation Error", "Please enter your password.");
return;
}
try {
await login(email.trim(), password);
} catch {
// Error handled in context
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View style={styles.inner}>
<Text style={styles.title}>Cloud Reader</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
{state.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, state.isLoading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={state.isLoading}
>
{state.isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Register")}>
<Text style={styles.linkText}>
Don't have an account?{" "}
<Text style={styles.linkBold}>Sign Up</Text>
</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
inner: {
flex: 1,
justifyContent: "center",
paddingHorizontal: 24,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
textAlign: "center",
marginBottom: 32,
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 8,
padding: 16,
fontSize: 16,
color: "#fff",
marginBottom: 12,
borderWidth: 1,
borderColor: "#333",
},
button: {
backgroundColor: "#4f8ef7",
borderRadius: 8,
padding: 16,
alignItems: "center",
marginTop: 8,
marginBottom: 24,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
errorBox: {
backgroundColor: "rgba(255, 69, 58, 0.15)",
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: "rgba(255, 69, 58, 0.3)",
},
errorText: {
color: "#ff453a",
fontSize: 14,
textAlign: "center",
},
linkText: {
color: "#888",
textAlign: "center",
fontSize: 14,
},
linkBold: {
color: "#4f8ef7",
fontWeight: "600",
},
});
+212
View File
@@ -0,0 +1,212 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import {
isValidEmail,
validatePasswordStrength,
} from "@cloud-reader/shared";
export default function RegisterScreen({
navigation,
}: {
navigation: any;
}): ReactNode {
const { state, register, clearError } = useAuth();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const handleRegister = async () => {
clearError();
if (!username.trim()) {
Alert.alert("Validation Error", "Please enter a username.");
return;
}
if (!email.trim()) {
Alert.alert("Validation Error", "Please enter your email.");
return;
}
if (!isValidEmail(email.trim())) {
Alert.alert("Validation Error", "Please enter a valid email address.");
return;
}
const passwordError = validatePasswordStrength(password);
if (passwordError) {
Alert.alert("Validation Error", passwordError);
return;
}
if (password !== confirmPassword) {
Alert.alert("Validation Error", "Passwords do not match.");
return;
}
try {
await register(email.trim(), password, username.trim());
} catch {
// Error handled in context
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ScrollView contentContainerStyle={styles.inner}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Join Cloud Reader</Text>
{state.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
<TextInput
style={styles.input}
placeholder="Username"
placeholderTextColor="#666"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TextInput
style={styles.input}
placeholder="Confirm Password"
placeholderTextColor="#666"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, state.isLoading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={state.isLoading}
>
{state.isLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Create Account</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.linkText}>
Already have an account?{" "}
<Text style={styles.linkBold}>Sign In</Text>
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
inner: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 48,
},
title: {
fontSize: 28,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#888",
textAlign: "center",
marginBottom: 32,
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 8,
padding: 16,
fontSize: 16,
color: "#fff",
marginBottom: 12,
borderWidth: 1,
borderColor: "#333",
},
button: {
backgroundColor: "#4f8ef7",
borderRadius: 8,
padding: 16,
alignItems: "center",
marginTop: 8,
marginBottom: 24,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
errorBox: {
backgroundColor: "rgba(255, 69, 58, 0.15)",
borderRadius: 8,
padding: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: "rgba(255, 69, 58, 0.3)",
},
errorText: {
color: "#ff453a",
fontSize: 14,
textAlign: "center",
},
linkText: {
color: "#888",
textAlign: "center",
fontSize: 14,
},
linkBold: {
color: "#4f8ef7",
fontWeight: "600",
},
});
+130
View File
@@ -0,0 +1,130 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from "react-native";
import { searchBooks } from "../api/books";
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
export default function SearchScreen(): ReactNode {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Book[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const handleSearch = async () => {
if (!query.trim()) return;
setLoading(true);
setSearched(true);
try {
const data: PaginatedResponse<Book> = await searchBooks(query.trim());
setResults(data.results);
} catch {
setResults([]);
} finally {
setLoading(false);
}
};
const renderBook = ({ item }: { item: Book }) => (
<View style={styles.resultCard}>
<Text style={styles.resultTitle}>{item.title}</Text>
<Text style={styles.resultAuthor}>{item.author}</Text>
</View>
);
return (
<View style={styles.container}>
<View style={styles.searchBar}>
<TextInput
style={styles.input}
placeholder="Search books by title, author..."
placeholderTextColor="#666"
value={query}
onChangeText={setQuery}
onSubmitEditing={handleSearch}
returnKeyType="search"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{loading && (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
)}
{!loading && searched && results.length === 0 && (
<View style={styles.centered}>
<Text style={styles.noResults}>No books found for "{query}"</Text>
</View>
)}
<FlatList
data={results}
renderItem={renderBook}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
searchBar: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
input: {
backgroundColor: "#1a1a2e",
borderRadius: 8,
padding: 14,
fontSize: 16,
color: "#fff",
borderWidth: 1,
borderColor: "#333",
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 24,
},
list: {
padding: 16,
},
resultCard: {
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 16,
marginBottom: 8,
borderWidth: 1,
borderColor: "#333",
},
resultTitle: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
marginBottom: 4,
},
resultAuthor: {
fontSize: 14,
color: "#888",
},
noResults: {
fontSize: 16,
color: "#888",
textAlign: "center",
},
});
+91
View File
@@ -0,0 +1,91 @@
import { type ReactNode } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function SettingsScreen(): ReactNode {
const { state, logout } = useAuth();
const handleLogout = () => {
Alert.alert("Logout", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: logout },
]);
};
return (
<View style={styles.container}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<View style={styles.infoRow}>
<Text style={styles.label}>Username</Text>
<Text style={styles.value}>{state.user?.username ?? "—"}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.label}>Email</Text>
<Text style={styles.value}>{state.user?.email ?? "—"}</Text>
</View>
</View>
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Text style={styles.logoutText}>Sign Out</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
padding: 16,
},
section: {
backgroundColor: "#1a1a2e",
borderRadius: 12,
padding: 16,
marginBottom: 24,
borderWidth: 1,
borderColor: "#333",
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#fff",
marginBottom: 16,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#333",
},
label: {
fontSize: 14,
color: "#888",
},
value: {
fontSize: 14,
color: "#fff",
fontWeight: "500",
},
logoutButton: {
backgroundColor: "rgba(255, 69, 58, 0.15)",
borderRadius: 8,
padding: 16,
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 69, 58, 0.3)",
},
logoutText: {
color: "#ff453a",
fontSize: 16,
fontWeight: "600",
},
});