feat: implement mobile reader

- implement mobile epub reader
This commit is contained in:
2026-06-20 13:33:44 -05:00
parent 84c0497f21
commit 22ded87250
41 changed files with 3184 additions and 1111 deletions
+11
View File
@@ -0,0 +1,11 @@
import { type ReactNode } from "react";
import { View, Text } from "react-native";
import { authStyles } from "./authStyles";
export function AuthFormError({ message }: { message: string }): ReactNode {
return (
<View style={authStyles.errorBox}>
<Text style={authStyles.errorText}>{message}</Text>
</View>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import {
View,
Text,
Image,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from "react-native";
import type { RouteProp } from "@react-navigation/native";
import { ebooksApi } from "../api/ebooks";
import { formatFileSize } from "@cloud-reader/shared";
import type { EBookDetail } from "@cloud-reader/shared";
import type { AppStackParamList } from "../types";
type DetailRoute = RouteProp<AppStackParamList, "BookDetail">;
export default function BookDetailScreen({
route,
navigation,
}: {
route: DetailRoute;
navigation: any;
}): ReactNode {
const { ebookId } = route.params;
const [book, setBook] = useState<EBookDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const load = useCallback(async () => {
try {
const { data } = await ebooksApi.get(ebookId);
setBook(data);
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [ebookId]);
useEffect(() => {
load();
}, [load]);
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
);
}
if (error || !book) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>Could not load this book.</Text>
</View>
);
}
const progressPct =
typeof book.progress?.current_position === "number"
? Math.round(book.progress.current_position)
: 0;
const hasProgress = progressPct > 0;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.header}>
{book.cover_image ? (
<Image source={{ uri: book.cover_image }} style={styles.cover} />
) : (
<View style={styles.cover}>
<Text style={styles.coverText}>
{book.title.charAt(0).toUpperCase()}
</Text>
</View>
)}
<Text style={styles.title}>{book.title}</Text>
<Text style={styles.author}>{book.author || "Unknown author"}</Text>
</View>
<View style={styles.metaRow}>
<MetaPill label="Format" value={book.format?.toUpperCase() || "—"} />
<MetaPill
label="Pages"
value={book.page_count ? String(book.page_count) : "—"}
/>
<MetaPill label="Size" value={formatFileSize(book.file_size || 0)} />
</View>
{hasProgress && (
<View style={styles.progressWrap}>
<View style={styles.progressTrack}>
<View style={[styles.progressFill, { width: `${progressPct}%` }]} />
</View>
<Text style={styles.progressLabel}>{progressPct}% read</Text>
</View>
)}
<TouchableOpacity
style={styles.readButton}
onPress={() => navigation.navigate("Reader", { ebookId: book.id })}
>
<Text style={styles.readButtonText}>
{hasProgress ? "Resume reading" : "Start reading"}
</Text>
</TouchableOpacity>
</ScrollView>
);
}
function MetaPill({ label, value }: { label: string; value: string }): ReactNode {
return (
<View style={styles.metaPill}>
<Text style={styles.metaValue}>{value}</Text>
<Text style={styles.metaLabel}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
content: {
padding: 24,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#0f0f23",
padding: 24,
},
header: {
alignItems: "center",
marginBottom: 24,
},
cover: {
width: 140,
height: 200,
borderRadius: 12,
backgroundColor: "#2a2a4e",
justifyContent: "center",
alignItems: "center",
marginBottom: 16,
},
coverText: {
fontSize: 56,
fontWeight: "bold",
color: "#4f8ef7",
},
title: {
fontSize: 22,
fontWeight: "700",
color: "#fff",
textAlign: "center",
marginBottom: 6,
},
author: {
fontSize: 15,
color: "#888",
textAlign: "center",
},
metaRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 24,
},
metaPill: {
flex: 1,
backgroundColor: "#1a1a2e",
borderRadius: 10,
paddingVertical: 12,
marginHorizontal: 4,
alignItems: "center",
borderWidth: 1,
borderColor: "#333",
},
metaValue: {
fontSize: 15,
fontWeight: "600",
color: "#fff",
},
metaLabel: {
fontSize: 12,
color: "#666",
marginTop: 2,
},
progressWrap: {
marginBottom: 24,
},
progressTrack: {
height: 6,
borderRadius: 3,
backgroundColor: "#1a1a2e",
overflow: "hidden",
},
progressFill: {
height: "100%",
backgroundColor: "#4f8ef7",
},
progressLabel: {
fontSize: 12,
color: "#888",
marginTop: 6,
},
readButton: {
backgroundColor: "#4f8ef7",
borderRadius: 10,
paddingVertical: 16,
alignItems: "center",
},
readButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
errorText: {
color: "#888",
fontSize: 15,
},
});
+54 -52
View File
@@ -2,34 +2,31 @@ import { useState, useEffect, useCallback, type ReactNode } from "react";
import {
View,
Text,
Image,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { fetchBooks } from "../api/books";
import type { Book, PaginatedResponse } from "@cloud-reader/shared";
import { ebooksApi } from "../api/ebooks";
import type { EBookListItem } from "@cloud-reader/shared";
export default function LibraryScreen({ navigation }: { navigation: any }): ReactNode {
const [books, setBooks] = useState<Book[]>([]);
export default function LibraryScreen({
navigation,
}: {
navigation: any;
}): ReactNode {
const [books, setBooks] = useState<EBookListItem[]>([]);
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) => {
const loadBooks = useCallback(async () => {
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);
const { data } = await ebooksApi.list();
setBooks(data.results);
} catch {
// Silent error for now
// Silent error for now; pull-to-refresh lets the user retry.
} finally {
setLoading(false);
setRefreshing(false);
@@ -37,44 +34,49 @@ export default function LibraryScreen({ navigation }: { navigation: any }): Reac
}, []);
useEffect(() => {
loadBooks(1, true);
loadBooks();
}, [loadBooks]);
const onRefresh = () => {
setRefreshing(true);
loadBooks(1, true);
loadBooks();
};
const loadMore = () => {
if (hasMore && !loading) {
loadBooks(page + 1);
}
const renderBook = ({ item }: { item: EBookListItem }) => {
const progress =
typeof item.progress === "number"
? Math.round(item.progress)
: null;
return (
<TouchableOpacity
style={styles.bookCard}
onPress={() => navigation.navigate("BookDetail", { ebookId: item.id })}
>
{item.cover_image ? (
<Image source={{ uri: item.cover_image }} style={styles.bookCover} />
) : (
<View style={styles.bookCover}>
<Text style={styles.coverText}>
{item.title.charAt(0).toUpperCase()}
</Text>
</View>
)}
<View style={styles.bookInfo}>
<Text style={styles.bookTitle} numberOfLines={2}>
{item.title}
</Text>
<Text style={styles.bookAuthor} numberOfLines={1}>
{item.author || "Unknown author"}
</Text>
<Text style={styles.bookMeta}>
{item.format?.toUpperCase()}
{progress !== null ? ` · ${progress}% read` : ""}
</Text>
</View>
</TouchableOpacity>
);
};
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}>
@@ -88,9 +90,7 @@ export default function LibraryScreen({ navigation }: { navigation: any }): Reac
<FlatList
data={books}
renderItem={renderBook}
keyExtractor={(item) => item.id}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl
@@ -103,7 +103,7 @@ export default function LibraryScreen({ navigation }: { navigation: any }): Reac
<View style={styles.centered}>
<Text style={styles.emptyText}>Your library is empty</Text>
<Text style={styles.emptySubtext}>
Add books to get started
Upload books from the web app to get started
</Text>
</View>
}
@@ -125,6 +125,7 @@ const styles = StyleSheet.create({
},
list: {
padding: 16,
flexGrow: 1,
},
bookCard: {
flexDirection: "row",
@@ -164,7 +165,7 @@ const styles = StyleSheet.create({
color: "#888",
marginBottom: 4,
},
bookPages: {
bookMeta: {
fontSize: 12,
color: "#666",
},
@@ -177,5 +178,6 @@ const styles = StyleSheet.create({
emptySubtext: {
fontSize: 14,
color: "#666",
textAlign: "center",
},
});
});
+30 -102
View File
@@ -4,29 +4,31 @@ import {
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import { isValidEmail } from "@cloud-reader/shared";
import { authStyles } from "./authStyles";
import { AuthFormError } from "./AuthFormError";
import { requireValidEmail } from "./authValidation";
export default function LoginScreen({ navigation }: { navigation: any }): ReactNode {
const { state, login, clearError } = useAuth();
export default function LoginScreen({
navigation,
}: {
navigation: any;
}): ReactNode {
const { login } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const handleLogin = async () => {
clearError();
setError(null);
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.");
if (!requireValidEmail(email)) {
return;
}
if (!password) {
@@ -34,30 +36,29 @@ export default function LoginScreen({ navigation }: { navigation: any }): ReactN
return;
}
setSubmitting(true);
try {
await login(email.trim(), password);
} catch {
// Error handled in context
setError("Invalid email or password.");
} finally {
setSubmitting(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
style={authStyles.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>
<View style={authStyles.inner}>
<Text style={authStyles.title}>Cloud Reader</Text>
<Text style={authStyles.subtitle}>Sign in to your account</Text>
{state.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
{error ? <AuthFormError message={error} /> : null}
<TextInput
style={styles.input}
style={authStyles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
@@ -68,7 +69,7 @@ export default function LoginScreen({ navigation }: { navigation: any }): ReactN
/>
<TextInput
style={styles.input}
style={authStyles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
@@ -77,97 +78,24 @@ export default function LoginScreen({ navigation }: { navigation: any }): ReactN
/>
<TouchableOpacity
style={[styles.button, state.isLoading && styles.buttonDisabled]}
style={[authStyles.button, submitting && authStyles.buttonDisabled]}
onPress={handleLogin}
disabled={state.isLoading}
disabled={submitting}
>
{state.isLoading ? (
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
<Text style={authStyles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.navigate("Register")}>
<Text style={styles.linkText}>
<Text style={authStyles.linkText}>
Don't have an account?{" "}
<Text style={styles.linkBold}>Sign Up</Text>
<Text style={authStyles.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",
},
});
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState, type ReactNode } from "react";
import {
View,
Text,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import type { RouteProp } from "@react-navigation/native";
import type { EBookDetail } from "@cloud-reader/shared";
import { ebooksApi } from "../api/ebooks";
import { EpubReaderView } from "../components/reader/EpubReaderView";
import type { AppStackParamList } from "../types";
type ReaderRoute = RouteProp<AppStackParamList, "Reader">;
export default function ReaderScreen({
route,
navigation,
}: {
route: ReaderRoute;
navigation: any;
}): ReactNode {
const { ebookId } = route.params;
const [book, setBook] = useState<EBookDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
ebooksApi
.get(ebookId)
.then(({ data }) => {
if (active) setBook(data);
})
.catch(() => {
if (active) setError(true);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [ebookId]);
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#4f8ef7" />
</View>
);
}
if (error || !book) {
return (
<SafeAreaView style={styles.centered} edges={["top", "bottom"]}>
<Text style={styles.message}>Could not open this book.</Text>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.backText}>Go back</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
const format = (book.format ?? "").toLowerCase();
if (format !== "epub") {
return (
<SafeAreaView style={styles.centered} edges={["top", "bottom"]}>
<Text style={styles.placeholderTitle}>PDF reading is coming soon</Text>
<Text style={styles.message}>
{book.title} is a {format.toUpperCase() || "non-EPUB"} file. PDF
reading isn&apos;t available on mobile yet open it in the web reader
for now.
</Text>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Text style={styles.backText}>Go back</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
return (
<EpubReaderView
book={book}
ebookId={ebookId}
onClose={() => navigation.goBack()}
/>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#0f0f23",
padding: 24,
},
placeholderTitle: {
fontSize: 20,
fontWeight: "700",
color: "#fff",
marginBottom: 12,
textAlign: "center",
},
message: {
fontSize: 15,
color: "#aaa",
textAlign: "center",
lineHeight: 22,
},
backButton: {
marginTop: 24,
backgroundColor: "#4f8ef7",
borderRadius: 10,
paddingHorizontal: 24,
paddingVertical: 12,
},
backText: {
color: "#fff",
fontSize: 15,
fontWeight: "600",
},
});
+34 -112
View File
@@ -1,10 +1,8 @@
import { useState, type ReactNode } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
Alert,
ActivityIndicator,
KeyboardAvoidingView,
@@ -12,40 +10,39 @@ import {
ScrollView,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import {
isValidEmail,
validatePasswordStrength,
} from "@cloud-reader/shared";
import { isStrongPassword } from "@cloud-reader/shared";
import { authStyles } from "./authStyles";
import { AuthFormError } from "./AuthFormError";
import { requireValidEmail } from "./authValidation";
export default function RegisterScreen({
navigation,
}: {
navigation: any;
}): ReactNode {
const { state, register, clearError } = useAuth();
const { register } = useAuth();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const handleRegister = async () => {
clearError();
setError(null);
if (!username.trim()) {
Alert.alert("Validation Error", "Please enter a username.");
return;
}
if (!email.trim()) {
Alert.alert("Validation Error", "Please enter your email.");
if (!requireValidEmail(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);
if (!isStrongPassword(password)) {
Alert.alert(
"Validation Error",
"Password must be at least 8 characters and include uppercase, lowercase, and a number.",
);
return;
}
if (password !== confirmPassword) {
@@ -53,30 +50,29 @@ export default function RegisterScreen({
return;
}
setSubmitting(true);
try {
await register(email.trim(), password, username.trim());
await register(email.trim(), username.trim(), password);
} catch {
// Error handled in context
setError("Could not create the account. Try a different email.");
} finally {
setSubmitting(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
style={authStyles.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>
<ScrollView contentContainerStyle={authStyles.innerScroll}>
<Text style={authStyles.titleCompact}>Create Account</Text>
<Text style={authStyles.subtitle}>Join Cloud Reader</Text>
{state.error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{state.error}</Text>
</View>
)}
{error ? <AuthFormError message={error} /> : null}
<TextInput
style={styles.input}
style={authStyles.input}
placeholder="Username"
placeholderTextColor="#666"
value={username}
@@ -86,7 +82,7 @@ export default function RegisterScreen({
/>
<TextInput
style={styles.input}
style={authStyles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
@@ -97,7 +93,7 @@ export default function RegisterScreen({
/>
<TextInput
style={styles.input}
style={authStyles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
@@ -106,7 +102,7 @@ export default function RegisterScreen({
/>
<TextInput
style={styles.input}
style={authStyles.input}
placeholder="Confirm Password"
placeholderTextColor="#666"
value={confirmPassword}
@@ -115,98 +111,24 @@ export default function RegisterScreen({
/>
<TouchableOpacity
style={[styles.button, state.isLoading && styles.buttonDisabled]}
style={[authStyles.button, submitting && authStyles.buttonDisabled]}
onPress={handleRegister}
disabled={state.isLoading}
disabled={submitting}
>
{state.isLoading ? (
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Create Account</Text>
<Text style={authStyles.buttonText}>Create Account</Text>
)}
</TouchableOpacity>
<TouchableOpacity onPress={() => navigation.goBack()}>
<Text style={styles.linkText}>
<Text style={authStyles.linkText}>
Already have an account?{" "}
<Text style={styles.linkBold}>Sign In</Text>
<Text style={authStyles.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",
},
});
+3 -3
View File
@@ -9,7 +9,7 @@ import {
import { useAuth } from "../context/AuthContext";
export default function SettingsScreen(): ReactNode {
const { state, logout } = useAuth();
const { user, logout } = useAuth();
const handleLogout = () => {
Alert.alert("Logout", "Are you sure you want to sign out?", [
@@ -24,11 +24,11 @@ export default function SettingsScreen(): ReactNode {
<Text style={styles.sectionTitle}>Account</Text>
<View style={styles.infoRow}>
<Text style={styles.label}>Username</Text>
<Text style={styles.value}>{state.user?.username ?? "—"}</Text>
<Text style={styles.value}>{user?.username ?? "—"}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.label}>Email</Text>
<Text style={styles.value}>{state.user?.email ?? "—"}</Text>
<Text style={styles.value}>{user?.email ?? "—"}</Text>
</View>
</View>
+88
View File
@@ -0,0 +1,88 @@
import { StyleSheet } from "react-native";
/** Shared layout/styles for Login and Register screens. */
export const authStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0f0f23",
},
inner: {
flex: 1,
justifyContent: "center",
paddingHorizontal: 24,
},
innerScroll: {
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: 24,
paddingVertical: 48,
},
title: {
fontSize: 32,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
marginBottom: 8,
},
titleCompact: {
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",
},
});
+15
View File
@@ -0,0 +1,15 @@
import { Alert } from "react-native";
import { isValidEmail } from "@cloud-reader/shared";
/** Returns true when email is non-empty and well-formed. */
export function requireValidEmail(email: string): boolean {
if (!email.trim()) {
Alert.alert("Validation Error", "Please enter your email.");
return false;
}
if (!isValidEmail(email.trim())) {
Alert.alert("Validation Error", "Please enter a valid email address.");
return false;
}
return true;
}