Added 'cloud storage' functionality

This commit is contained in:
Christopher Sanden
2026-03-04 02:15:53 +01:00
parent 19b9438ef8
commit 45ab15ff40
29 changed files with 1822 additions and 109 deletions

View File

@@ -1,31 +1,54 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NotesProvider } from "@/src/notes/NotesContext"
import { useColorScheme } from '@/hooks/use-color-scheme';
;
import { useAuthContext } from '@/hooks/use-auth-context';
import AuthProvider from '@/providers/auth-provider';
// Separate RootNavigator so we can access the AuthContext
function RootNavigator() {
const { isLoggedIn, isLoading } = useAuthContext()
if (isLoading) {
return null
}
return (
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="newNote" options={{ headerShown: true, title: 'New Note' }} />
<Stack.Screen name="detail" options={{ headerShown: true, title: 'Note' }} />
</Stack.Protected>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="signup" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Screen name="+not-found" />
</Stack>
)
}
export default function RootLayout() {
const colorScheme = useColorScheme();
const colorScheme = useColorScheme()
/*TODO
Sort ThemeProvider to work with dark theme on iOS
Fix ThemeProvider to work with dark theme
*/
return (
<SafeAreaProvider>
<NotesProvider>
<ThemeProvider value={colorScheme === 'dark' ? DefaultTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ title: 'FastNotes' }} />
<Stack.Screen name="newNote" options={{ title: 'New Note'}}/>
<Stack.Screen name="detail" options={{ title: 'Detail'}}/>
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</NotesProvider>
<ThemeProvider value={colorScheme === 'dark' ? DefaultTheme : DefaultTheme}>
<AuthProvider>
<NotesProvider>
<RootNavigator />
</NotesProvider>
</AuthProvider>
<StatusBar style="auto" />
</ThemeProvider>
</SafeAreaProvider>
);
}

View File

@@ -1,18 +1,158 @@
import { StyleSheet, Text, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useAuthContext } from "@/hooks/use-auth-context";
import { useNotes } from "@/src/notes/NotesContext";
export default function DetailScreen()
{
const { title, content } = useLocalSearchParams<
const { id } = useLocalSearchParams<
{
title?: string;
content?: string;
id?: string;
}>();
const { claims } = useAuthContext();
const { deleteNote, errorMessage, notes, updateNote } = useNotes();
const note = notes.find((entry) => entry.id === id);
const canEdit = note?.createdBy === claims?.sub;
const [title, setTitle] = useState(note?.title ?? "");
const [content, setContent] = useState(note?.content ?? "");
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
return(
const formatTimestamp = (value: string) => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return "Unknown";
}
return parsed.toLocaleString();
};
useEffect(() => {
setTitle(note?.title ?? "");
setContent(note?.content ?? "");
}, [note?.content, note?.title]);
const onSave = async () => {
if (!id) {
setLocalErrorMessage("This note could not be found.");
return;
}
if (!title.trim() || !content.trim()) {
setLocalErrorMessage("Title and content are required.");
return;
}
setIsSaving(true);
setLocalErrorMessage(null);
setStatusMessage(null);
const wasSaved = await updateNote(id, title, content);
setIsSaving(false);
if (wasSaved) {
setStatusMessage("Note updated.");
}
};
const confirmDelete = () => {
// Require explicit confirmation before deleting the note.
Alert.alert(
"Delete note",
"Are you sure you want to delete this note?",
[
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
void onDelete();
},
},
]
);
};
const onDelete = async () => {
if (!id) {
setLocalErrorMessage("This note could not be found.");
return;
}
setIsDeleting(true);
setLocalErrorMessage(null);
setStatusMessage(null);
const wasDeleted = await deleteNote(id);
setIsDeleting(false);
if (wasDeleted) {
router.replace("/");
}
};
if (!note) {
return (
<View style={styles.container}>
<Text style={styles.title}>Note not found</Text>
<Text style={styles.content}>The note may have been deleted.</Text>
</View>
);
}
return(
<View style={styles.container}>
<Text style={styles.title}>{title ?? "(No title)"}</Text>
<Text style={styles.content}>{content ?? ""}</Text>
<TextInput
editable={canEdit}
onChangeText={setTitle}
style={styles.titleInput}
value={title}
/>
<Text style={styles.signature}>Created by {note.creatorLabel}</Text>
<Text style={styles.signature}>
Last changed {formatTimestamp(note.lastChangedAt)}
</Text>
<TextInput
editable={canEdit}
multiline
onChangeText={setContent}
style={styles.contentInput}
textAlignVertical="top"
value={content}
/>
{!canEdit ? (
<Text style={styles.readOnlyText}>
Only the creator of this note can update or delete it.
</Text>
) : null}
{localErrorMessage ? <Text style={styles.errorText}>{localErrorMessage}</Text> : null}
{!localErrorMessage && errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null}
{canEdit ? (
<View style={styles.actions}>
<Pressable disabled={isSaving} onPress={onSave} style={styles.primaryButton}>
<Text style={styles.primaryButtonText}>
{isSaving ? "Saving..." : "Save changes"}
</Text>
</Pressable>
<Pressable disabled={isDeleting} onPress={confirmDelete} style={styles.deleteButton}>
<Text style={styles.deleteButtonText}>
{isDeleting ? "Deleting..." : "Delete note"}
</Text>
</Pressable>
</View>
) : null}
</View>
);
}
@@ -22,5 +162,58 @@ const styles = StyleSheet.create(
{
container: { flex: 1, padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight:"700" },
content: { fontSize: 16 }
});
content: { fontSize: 16 },
titleInput: {
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 22,
fontWeight: "700",
},
signature: {
fontSize: 12,
color: "#666",
},
contentInput: {
flex: 1,
minHeight: 200,
borderWidth: 1,
borderRadius: 8,
padding: 12,
fontSize: 16,
},
errorText: {
color: "#c62828",
},
successText: {
color: "#2e7d32",
},
readOnlyText: {
color: "#666",
},
actions: {
gap: 12,
},
primaryButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
backgroundColor: "#111",
},
primaryButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
deleteButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: "center",
backgroundColor: "#b71c1c",
},
deleteButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "700",
},
});

View File

@@ -1,49 +1,181 @@
import { useMemo, useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
import { router } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAuthContext } from "@/hooks/use-auth-context";
import { useNotes } from "@/src/notes/NotesContext";
import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
type TabKey = "my-notes" | "work-notes"
export default function HomeScreen()
{
const { notes } = useNotes();
const { claims } = useAuthContext();
const { errorMessage, isLoading, notes } = useNotes();
const [activeTab, setActiveTab] = useState<TabKey>("my-notes");
const insets = useSafeAreaInsets();
const userId = claims?.sub;
const filteredNotes = useMemo(
() =>
notes.filter((note) =>
activeTab === "my-notes" ? note.createdBy === userId : note.createdBy !== userId
),
[activeTab, notes, userId]
);
const emptyText =
activeTab === "my-notes"
? "No personal notes yet. Create your first note."
: "No work notes yet."
const formatTimestamp = (value: string) => {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return "Unknown"
}
return parsed.toLocaleString()
}
return (
<View style={styles.container}>
<View style={[styles.topBar, { paddingTop: insets.top + 8 }]}>
<Text style={styles.screenTitle}>FastNotes</Text>
<SignOutButton />
</View>
<View style={styles.tabBar}>
<Pressable
onPress={() => setActiveTab("my-notes")}
style={[
styles.tabButton,
activeTab === "my-notes" ? styles.tabButtonActive : null,
]}
>
<Text
style={[
styles.tabButtonText,
activeTab === "my-notes" ? styles.tabButtonTextActive : null,
]}
>
My Notes
</Text>
</Pressable>
<Pressable
onPress={() => setActiveTab("work-notes")}
style={[
styles.tabButton,
activeTab === "work-notes" ? styles.tabButtonActive : null,
]}
>
<Text
style={[
styles.tabButtonText,
activeTab === "work-notes" ? styles.tabButtonTextActive : null,
]}
>
Work Notes
</Text>
</Pressable>
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
<FlatList
data={notes}
data={filteredNotes}
keyExtractor={(n) => n.id}
contentContainerStyle={[styles.list, { paddingBottom: 120 }]}
ListEmptyComponent={
<Text style={styles.emptyText}>
{isLoading ? "Loading notes..." : emptyText}
</Text>
}
renderItem={({ item }) => (
<Pressable
style={styles.noteItem}
onPress={() =>
router.push({
pathname: "/detail",
params: { title: item.title, content: item.content },
params: { id: item.id },
})
}
>
<Text style={styles.noteTitle}>{item.title}</Text>
<Text numberOfLines={2} style={styles.notePreview}>{item.content}</Text>
<Text style={styles.noteMeta}>Created by {item.creatorLabel}</Text>
<Text style={styles.noteMeta}>
Last changed {formatTimestamp(item.lastChangedAt)}
</Text>
</Pressable>
)}
/>
<Pressable
style={[styles.fab, { bottom: insets.bottom + 24, right: insets.right + 40 }]}
onPress={() => router.push("/newNote")}
>
<Text style={styles.fabText}>+</Text>
</Pressable>
{activeTab === "my-notes" ? (
<Pressable
style={[styles.fab, { bottom: insets.bottom + 24, right: insets.right + 40 }]}
onPress={() => router.push("/newNote")}
>
<Text style={styles.fabText}>+</Text>
</Pressable>
) : null}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
list: { padding: 16, gap: 12, paddingTop: 20 },
noteItem: { padding: 16, borderWidth: 1, borderRadius:12 },
topBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
},
screenTitle: {
fontSize: 24,
fontWeight: "700",
},
tabBar: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingBottom: 8,
},
tabButton: {
flex: 1,
borderRadius: 999,
paddingVertical: 10,
alignItems: "center",
backgroundColor: "#e6e6e6",
},
tabButtonActive: {
backgroundColor: "#111",
},
tabButtonText: {
fontSize: 14,
fontWeight: "600",
color: "#111",
},
tabButtonTextActive: {
color: "#fff",
},
list: { padding: 16, gap: 12, paddingTop: 8 },
noteItem: { padding: 16, borderWidth: 1, borderRadius:12, gap: 8 },
noteTitle: { fontSize: 16, fontWeight: "600" },
notePreview: { fontSize: 14, color: "#444" },
noteMeta: { fontSize: 12, color: "#666" },
emptyText: {
textAlign: "center",
paddingVertical: 32,
color: "#666",
},
errorText: {
color: "#c62828",
paddingHorizontal: 16,
paddingBottom: 8,
},
fab:
{
position: "absolute",

146
FastNotes/app/login.tsx Normal file
View File

@@ -0,0 +1,146 @@
import { useState } from 'react'
import { Link, Stack } from 'expo-router'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { supabase } from '@/libs/supabase'
export default function LoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const onLogin = async () => {
if (!email.trim() || !password) {
setErrorMessage('Log in with email and password')
return
}
setIsSubmitting(true)
setErrorMessage(null)
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
})
if (error) {
setErrorMessage(error.message)
}
setIsSubmitting(false)
}
return (
<>
<Stack.Screen options={{ title: 'Login' }} />
<View style={styles.container}>
<Text style={styles.title}>Login</Text>
<Text style={styles.label}>E-mail</Text>
<TextInput
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
onChangeText={setEmail}
placeholder="name@email.com"
placeholderTextColor="#666"
style={styles.input}
value={email}
/>
<Text style={styles.label}>Password</Text>
<TextInput
autoCapitalize="none"
autoComplete="password"
onChangeText={setPassword}
placeholder="Password"
placeholderTextColor="#666"
secureTextEntry
style={styles.input}
value={password}
/>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
) : null}
<Pressable
disabled={isSubmitting}
onPress={onLogin}
style={({ pressed }) => [
styles.loginButton,
pressed && !isSubmitting ? styles.loginButtonPressed : null,
isSubmitting ? styles.loginButtonDisabled : null,
]}
>
<Text style={styles.loginButtonText}>
{isSubmitting ? 'Logging in...' : 'Log in'}
</Text>
</Pressable>
<Link href="/signup" style={styles.link}>
<Text style={styles.linkText}>Create a new account</Text>
</Link>
</View>
</>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 24,
gap: 12,
backgroundColor: '#f7f7f7',
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#111',
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#111',
},
input: {
borderWidth: 1,
borderColor: '#cfcfcf',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#111',
backgroundColor: '#fff',
},
errorText: {
color: '#c62828',
fontSize: 14,
},
loginButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
backgroundColor: '#111',
marginTop: 8,
},
loginButtonPressed: {
opacity: 0.85,
},
loginButtonDisabled: {
opacity: 0.6,
},
loginButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
link: {
alignSelf: 'center',
marginTop: 8,
},
linkText: {
color: '#0b57d0',
fontSize: 16,
},
})

View File

@@ -13,20 +13,34 @@ import { useHeaderHeight } from "@react-navigation/elements";
export default function NewNoteScreen()
{
const { addNote } = useNotes();
const[title, setTitle] = useState("");
const [content, setContent] = useState("");
const insets = useSafeAreaInsets();
const headerHeight = useHeaderHeight();
const [contentHeight, setContentHeight] = useState(160);
const scrollRef = useRef<ScrollView>(null);
const { addNote, errorMessage } = useNotes()
const [title, setTitle] = useState("")
const [content, setContent] = useState("")
const [isSaving, setIsSaving] = useState(false)
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight()
const [contentHeight, setContentHeight] = useState(160)
const scrollRef = useRef<ScrollView>(null)
const onSave = () =>
const onSave = async () =>
{
if(!title.trim() && !content.trim()) return;
addNote(title, content);
router.back();
};
if(!title.trim() || !content.trim()) {
setLocalErrorMessage("Title and content are required.")
return
}
setIsSaving(true)
setLocalErrorMessage(null)
const wasSaved = await addNote(title, content)
setIsSaving(false)
if (wasSaved) {
router.back()
}
}
return (
<KeyboardAvoidingView style={{ flex: 1}} behavior={Platform.OS === "ios" ? "padding" : undefined}
@@ -43,15 +57,25 @@ export default function NewNoteScreen()
<TextInput value={content} onChangeText={setContent} placeholder="Write your note..."
style={[styles.input, { height: Math.max(160, contentHeight) }]} multiline
textAlignVertical="top"
onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height);
onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height);
scrollRef.current?.scrollToEnd({ animated: true });
}}/>
{localErrorMessage ? (
<Text style={styles.errorText}>{localErrorMessage}</Text>
) : null}
{!localErrorMessage && errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
) : null}
</ScrollView>
<View>
<Pressable onPress={onSave}
<Pressable disabled={isSaving} onPress={onSave}
style={[styles.saveFloating, { bottom: insets.bottom + 16 }]}>
<Text style={styles.saveFloatingText}>Save</Text>
<Text style={styles.saveFloatingText}>
{isSaving ? "Saving..." : "Save"}
</Text>
</Pressable>
</View>
</View>
@@ -101,6 +125,9 @@ const styles = StyleSheet.create(
color: "white",
fontSize: 16,
fontWeight: "700"
}
},
errorText: {
color: "#c62828"
},
}
);
);

179
FastNotes/app/signup.tsx Normal file
View File

@@ -0,0 +1,179 @@
import { supabase } from "@/libs/supabase";
import { Link, Stack } from "expo-router";
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
export default function SignupScreen(){
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const onSignup = async () => {
if(!email.trim() || !password){
setErrorMessage('Sign up using email and password')
return
}
setIsSubmitting(true)
setErrorMessage(null)
setSuccessMessage(null)
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password
})
if(error){
setErrorMessage(error.message)
} else if (data.session) {
const user = data.user
if (user) {
const { error: profileError } = await supabase
.from('profiles')
.upsert({
id: user.id,
email: user.email ?? email.trim(),
})
if (profileError) {
setErrorMessage(profileError.message)
}
}
}
if (!error && data.session) {
setSuccessMessage('Account created. You are now signed in.')
} else if (!error) {
setSuccessMessage('Account created. Check your email to confirm the signup.')
}
setIsSubmitting(false)
}
return (
<>
<Stack.Screen options={{title: "Signup"}}/>
<View style={styles.container}>
<Text style={styles.title}>Sign up</Text>
<Text style={styles.label}>E-mail</Text>
<TextInput
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
onChangeText={setEmail}
placeholder="name@email.com"
placeholderTextColor="#666"
style={styles.input}
value={email}
/>
<Text style={styles.label} >Password</Text>
<TextInput
autoCapitalize="none"
autoComplete="password"
onChangeText={setPassword}
placeholder="Password"
placeholderTextColor="#666"
secureTextEntry
style={styles.input}
value={password}
/>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
) : null}
{successMessage ? (
<Text style={styles.successText}>{successMessage}</Text>
) : null}
<Pressable
disabled={isSubmitting}
onPress={onSignup}
style={({ pressed }) => [
styles.actionButton,
pressed && !isSubmitting ? styles.actionButtonPressed : null,
isSubmitting ? styles.actionButtonDisabled : null,
]}
>
<Text style={styles.actionButtonText}>
{isSubmitting ? 'Creating account...' : 'Sign up'}
</Text>
</Pressable>
<Link href="/login" style={styles.link}>
<Text style={styles.linkText}>Already have an account? Log in</Text>
</Link>
</View>
</>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 24,
gap: 12,
backgroundColor: '#f7f7f7',
},
title: {
fontSize: 28,
fontWeight: '700',
color: '#111',
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#111',
},
input: {
borderWidth: 1,
borderColor: '#cfcfcf',
borderRadius: 8,
padding: 12,
fontSize: 16,
color: '#111',
backgroundColor: '#fff',
},
errorText: {
color: '#c62828',
fontSize: 14,
},
successText: {
color: '#2e7d32',
fontSize: 14,
},
actionButton: {
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
backgroundColor: '#111',
marginTop: 8,
},
actionButtonPressed: {
opacity: 0.85,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '700',
},
link: {
alignSelf: 'center',
marginTop: 8,
},
linkText: {
color: '#0b57d0',
fontSize: 16,
},
})