diff --git a/.gitignore b/FastNotes/.gitignore similarity index 77% rename from .gitignore rename to FastNotes/.gitignore index 6a9b42c..30f0284 100644 --- a/.gitignore +++ b/FastNotes/.gitignore @@ -1,5 +1,3 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files - # dependencies node_modules/ @@ -34,12 +32,12 @@ yarn-error.* # local env files .env*.local +.env.local +.env # typescript *.tsbuildinfo -app-example - # generated native folders /ios /android diff --git a/FastNotes/app.config.js b/FastNotes/app.config.js new file mode 100644 index 0000000..60f4433 --- /dev/null +++ b/FastNotes/app.config.js @@ -0,0 +1,10 @@ +import "dotenv/config"; + +export default ({ config }) => ({ + ...config, + extra: { + ...config.extra, + supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL, + supabaseKey: process.env.EXPO_PUBLIC_SUPABASE_KEY, + }, +}); \ No newline at end of file diff --git a/FastNotes/app.json b/FastNotes/app.json index 875c3a4..6d727a6 100644 --- a/FastNotes/app.json +++ b/FastNotes/app.json @@ -38,7 +38,9 @@ "backgroundColor": "#000000" } } - ] + ], + "expo-sqlite", + "expo-secure-store" ], "experiments": { "typedRoutes": true, diff --git a/FastNotes/app/_layout.tsx b/FastNotes/app/_layout.tsx index c9bb8e1..3289d0c 100644 --- a/FastNotes/app/_layout.tsx +++ b/FastNotes/app/_layout.tsx @@ -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 ( + + + + + + + + + + + + + ) +} 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 ( - - - - - - - - - - + + + + + + + + ); } diff --git a/FastNotes/app/detail.tsx b/FastNotes/app/detail.tsx index ee726f1..1ce0262 100644 --- a/FastNotes/app/detail.tsx +++ b/FastNotes/app/detail.tsx @@ -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(null); + const [statusMessage, setStatusMessage] = useState(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 ( + + Note not found + The note may have been deleted. + + ); + } + + return( - {title ?? "(No title)"} - {content ?? ""} + + Created by {note.creatorLabel} + + Last changed {formatTimestamp(note.lastChangedAt)} + + + {!canEdit ? ( + + Only the creator of this note can update or delete it. + + ) : null} + {localErrorMessage ? {localErrorMessage} : null} + {!localErrorMessage && errorMessage ? {errorMessage} : null} + {statusMessage ? {statusMessage} : null} + {canEdit ? ( + + + + {isSaving ? "Saving..." : "Save changes"} + + + + + {isDeleting ? "Deleting..." : "Delete note"} + + + + ) : null} ); } @@ -22,5 +162,58 @@ const styles = StyleSheet.create( { container: { flex: 1, padding: 16, gap: 12 }, title: { fontSize: 22, fontWeight:"700" }, - content: { fontSize: 16 } - }); \ No newline at end of file + 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", + }, + }); diff --git a/FastNotes/app/index.tsx b/FastNotes/app/index.tsx index 7475e3d..1621f28 100644 --- a/FastNotes/app/index.tsx +++ b/FastNotes/app/index.tsx @@ -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("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 ( + + FastNotes + + + + + setActiveTab("my-notes")} + style={[ + styles.tabButton, + activeTab === "my-notes" ? styles.tabButtonActive : null, + ]} + > + + My Notes + + + setActiveTab("work-notes")} + style={[ + styles.tabButton, + activeTab === "work-notes" ? styles.tabButtonActive : null, + ]} + > + + Work Notes + + + + + {errorMessage ? {errorMessage} : null} + n.id} contentContainerStyle={[styles.list, { paddingBottom: 120 }]} + ListEmptyComponent={ + + {isLoading ? "Loading notes..." : emptyText} + + } renderItem={({ item }) => ( router.push({ pathname: "/detail", - params: { title: item.title, content: item.content }, + params: { id: item.id }, }) } > {item.title} + {item.content} + Created by {item.creatorLabel} + + Last changed {formatTimestamp(item.lastChangedAt)} + )} /> - router.push("/newNote")} - > - + - + {activeTab === "my-notes" ? ( + router.push("/newNote")} + > + + + + ) : null} ); } 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", diff --git a/FastNotes/app/login.tsx b/FastNotes/app/login.tsx new file mode 100644 index 0000000..0adecc4 --- /dev/null +++ b/FastNotes/app/login.tsx @@ -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(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 ( + <> + + + Login + + E-mail + + + Password + + + {errorMessage ? ( + {errorMessage} + ) : null} + + [ + styles.loginButton, + pressed && !isSubmitting ? styles.loginButtonPressed : null, + isSubmitting ? styles.loginButtonDisabled : null, + ]} + > + + {isSubmitting ? 'Logging in...' : 'Log in'} + + + + + Create a new account + + + + ) +} + +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, + }, +}) diff --git a/FastNotes/app/newNote.tsx b/FastNotes/app/newNote.tsx index ac195bc..41fc817 100644 --- a/FastNotes/app/newNote.tsx +++ b/FastNotes/app/newNote.tsx @@ -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(null); + const { addNote, errorMessage } = useNotes() + const [title, setTitle] = useState("") + const [content, setContent] = useState("") + const [isSaving, setIsSaving] = useState(false) + const [localErrorMessage, setLocalErrorMessage] = useState(null) + const insets = useSafeAreaInsets() + const headerHeight = useHeaderHeight() + const [contentHeight, setContentHeight] = useState(160) + const scrollRef = useRef(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 ( {setContentHeight(e.nativeEvent.contentSize.height); + onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height); scrollRef.current?.scrollToEnd({ animated: true }); }}/> + {localErrorMessage ? ( + {localErrorMessage} + ) : null} + + {!localErrorMessage && errorMessage ? ( + {errorMessage} + ) : null} + - - Save + + {isSaving ? "Saving..." : "Save"} + @@ -101,6 +125,9 @@ const styles = StyleSheet.create( color: "white", fontSize: 16, fontWeight: "700" - } + }, + errorText: { + color: "#c62828" + }, } -); \ No newline at end of file +); diff --git a/FastNotes/app/signup.tsx b/FastNotes/app/signup.tsx new file mode 100644 index 0000000..cb0a9c3 --- /dev/null +++ b/FastNotes/app/signup.tsx @@ -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(null) + const [successMessage, setSuccessMessage] = useState(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 ( + <> + + + Sign up + + E-mail + + + Password + + + {errorMessage ? ( + {errorMessage} + ) : null} + + {successMessage ? ( + {successMessage} + ) : null} + + [ + styles.actionButton, + pressed && !isSubmitting ? styles.actionButtonPressed : null, + isSubmitting ? styles.actionButtonDisabled : null, + ]} + > + + {isSubmitting ? 'Creating account...' : 'Sign up'} + + + + + Already have an account? Log in + + + + + ) +} + + +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, + }, +}) diff --git a/FastNotes/components/social-auth-buttons/sign-out-button.tsx b/FastNotes/components/social-auth-buttons/sign-out-button.tsx new file mode 100644 index 0000000..0aa20d1 --- /dev/null +++ b/FastNotes/components/social-auth-buttons/sign-out-button.tsx @@ -0,0 +1,19 @@ +import { supabase } from '@/libs/supabase' +import { router } from 'expo-router' +import React from 'react' +import { Button } from 'react-native' + +async function onSignOutButtonPress() { + const { error } = await supabase.auth.signOut() + + if (error) { + console.error('Error signing out:', error) + return + } + + router.replace('/login') +} + +export default function SignOutButton() { + return