Polished up some UI and added dark/light mode

This commit is contained in:
Christopher Sanden
2026-03-06 17:20:41 +01:00
parent 45ab15ff40
commit 3e81e46b1a
25 changed files with 472 additions and 309 deletions

BIN
FastNotes/Diagrams/ER.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@@ -1,26 +1,35 @@
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useAuthContext } from '@/hooks/use-auth-context'
import { Stack } from 'expo-router'; import AuthProvider from '@/providers/auth-provider'
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 { NotesProvider } from "@/src/notes/NotesContext"
import { useColorScheme } from '@/hooks/use-color-scheme'; import { AppThemeProvider, useAppTheme } from '@/src/theme/AppThemeProvider'
import { useAuthContext } from '@/hooks/use-auth-context'; import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import AuthProvider from '@/providers/auth-provider'; import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import 'react-native-reanimated'
import { SafeAreaProvider } from 'react-native-safe-area-context'
// Separate RootNavigator so we can access the AuthContext // Separate RootNavigator so we can access the AuthContext
function RootNavigator() { function RootNavigator() {
const { isLoggedIn, isLoading } = useAuthContext() const { isLoggedIn, isLoading } = useAuthContext()
const { palette } = useAppTheme()
if (isLoading) { if (isLoading) {
return null return null
} }
return ( return (
<Stack> <Stack
screenOptions={{
contentStyle: { backgroundColor: palette.background },
headerStyle: { backgroundColor: palette.surface },
headerShadowVisible: false,
headerTintColor: palette.text,
headerTitleStyle: { color: palette.text },
}}
>
<Stack.Protected guard={isLoggedIn}> <Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false,title: "Notes", headerBackTitle: "Notes" }} />
<Stack.Screen name="newNote" options={{ headerShown: true, title: 'New Note' }} /> <Stack.Screen name="newNote" options={{ headerShown: true, title: 'New Note' }} />
<Stack.Screen name="detail" options={{ headerShown: true, title: 'Note' }} /> <Stack.Screen name="detail" options={{ headerShown: true, title: 'Note' }} />
</Stack.Protected> </Stack.Protected>
@@ -28,27 +37,57 @@ function RootNavigator() {
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="signup" options={{ headerShown: false }} /> <Stack.Screen name="signup" options={{ headerShown: false }} />
</Stack.Protected> </Stack.Protected>
<Stack.Screen name="+not-found" />
</Stack> </Stack>
) )
} }
export default function RootLayout() { function ThemedRootLayout() {
const colorScheme = useColorScheme() const { colorScheme, palette } = useAppTheme()
/*TODO const navigationTheme = colorScheme === "dark"
Fix ThemeProvider to work with dark theme ? {
*/ ...DarkTheme,
colors: {
...DarkTheme.colors,
background: palette.background,
card: palette.surface,
text: palette.text,
border: palette.border,
primary: palette.accent,
},
}
: {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: palette.background,
card: palette.surface,
text: palette.text,
border: palette.border,
primary: palette.accent,
},
}
return ( return (
<SafeAreaProvider> <SafeAreaProvider>
<ThemeProvider value={colorScheme === 'dark' ? DefaultTheme : DefaultTheme}> <ThemeProvider value={navigationTheme}>
<AuthProvider> <AuthProvider>
<NotesProvider> <NotesProvider>
<RootNavigator /> <RootNavigator />
</NotesProvider> </NotesProvider>
</AuthProvider> </AuthProvider>
<StatusBar style="auto" /> <StatusBar
style={colorScheme === "dark" ? "light" : "dark"}
backgroundColor={palette.statusBar}
/>
</ThemeProvider> </ThemeProvider>
</SafeAreaProvider> </SafeAreaProvider>
); )
}
export default function RootLayout() {
return (
<AppThemeProvider>
<ThemedRootLayout />
</AppThemeProvider>
)
} }

View File

@@ -1,66 +1,72 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react"
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router"
import { useHeaderHeight } from "@react-navigation/elements"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useAppTheme } from "@/src/theme/AppThemeProvider"
import { useAuthContext } from "@/hooks/use-auth-context"; import { useAuthContext } from "@/hooks/use-auth-context"
import { useNotes } from "@/src/notes/NotesContext"; import { useNotes } from "@/src/notes/NotesContext"
export default function DetailScreen() export default function DetailScreen()
{ {
const { id } = useLocalSearchParams< const { id } = useLocalSearchParams<
{ {
id?: string; id?: string
}>(); }>()
const { claims } = useAuthContext(); const { claims } = useAuthContext()
const { deleteNote, errorMessage, notes, updateNote } = useNotes(); const { deleteNote, errorMessage, notes, updateNote } = useNotes()
const note = notes.find((entry) => entry.id === id); const note = notes.find((entry) => entry.id === id)
const canEdit = note?.createdBy === claims?.sub; const canEdit = note?.createdBy === claims?.sub
const [title, setTitle] = useState(note?.title ?? ""); const [title, setTitle] = useState(note?.title ?? "")
const [content, setContent] = useState(note?.content ?? ""); const [content, setContent] = useState(note?.content ?? "")
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false)
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null); const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null)
const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight()
const { colorScheme, palette } = useAppTheme()
const formatTimestamp = (value: string) => { const formatTimestamp = (value: string) => {
const parsed = new Date(value); const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) { if (Number.isNaN(parsed.getTime())) {
return "Unknown"; return "Unknown"
} }
return parsed.toLocaleString(); return parsed.toLocaleString()
}; }
useEffect(() => { useEffect(() => {
setTitle(note?.title ?? ""); setTitle(note?.title ?? "")
setContent(note?.content ?? ""); setContent(note?.content ?? "")
}, [note?.content, note?.title]); }, [note?.content, note?.title])
const onSave = async () => { const onSave = async () => {
if (!id) { if (!id) {
setLocalErrorMessage("This note could not be found."); setLocalErrorMessage("This note could not be found.")
return; return
} }
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
setLocalErrorMessage("Title and content are required."); setLocalErrorMessage("Title and content are required.")
return; return
} }
setIsSaving(true); setIsSaving(true)
setLocalErrorMessage(null); setLocalErrorMessage(null)
setStatusMessage(null); setStatusMessage(null)
const wasSaved = await updateNote(id, title, content); const wasSaved = await updateNote(id, title, content)
setIsSaving(false); setIsSaving(false)
if (wasSaved) { if (wasSaved) {
setStatusMessage("Note updated."); setStatusMessage("Note updated.")
} }
}; }
const confirmDelete = () => { const confirmDelete = () => {
// Require explicit confirmation before deleting the note. // Require explicit confirmation before deleting the note.
@@ -76,91 +82,106 @@ export default function DetailScreen()
text: "Delete", text: "Delete",
style: "destructive", style: "destructive",
onPress: () => { onPress: () => {
void onDelete(); void onDelete()
}, },
}, },
] ]
); )
}; }
const onDelete = async () => { const onDelete = async () => {
if (!id) { if (!id) {
setLocalErrorMessage("This note could not be found."); setLocalErrorMessage("This note could not be found.")
return; return
} }
setIsDeleting(true); setIsDeleting(true)
setLocalErrorMessage(null); setLocalErrorMessage(null)
setStatusMessage(null); setStatusMessage(null)
const wasDeleted = await deleteNote(id); const wasDeleted = await deleteNote(id)
setIsDeleting(false); setIsDeleting(false)
if (wasDeleted) { if (wasDeleted) {
router.replace("/"); router.replace("/")
} }
}; }
if (!note) { if (!note) {
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: palette.background, padding: 16 }]}>
<Text style={styles.title}>Note not found</Text> <Text style={[styles.title, { color: palette.text }]}>Note not found</Text>
<Text style={styles.content}>The note may have been deleted.</Text> <Text style={[styles.content, { color: palette.mutedText }]}>The note may have been deleted.</Text>
</View> </View>
); )
} }
return( return(
<View style={styles.container}> <KeyboardAvoidingView
<TextInput behavior={Platform.OS === "ios" ? "padding" : "height"}
editable={canEdit} keyboardVerticalOffset={headerHeight}
onChangeText={setTitle} style={styles.keyboardAvoider}
style={styles.titleInput} >
value={title} <View style={[styles.container, { backgroundColor: palette.background }]}>
/> <ScrollView
<Text style={styles.signature}>Created by {note.creatorLabel}</Text> contentContainerStyle={[styles.formContent, { paddingBottom: insets.bottom + 112 }]}
<Text style={styles.signature}> keyboardShouldPersistTaps="handled"
Last changed {formatTimestamp(note.lastChangedAt)} >
</Text> <TextInput
<TextInput editable={canEdit}
editable={canEdit} onChangeText={setTitle}
multiline style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
onChangeText={setContent} value={title}
style={styles.contentInput} placeholderTextColor={palette.mutedText}
textAlignVertical="top" />
value={content} <Text style={[styles.signature, { color: palette.mutedText }]}>Created by {note.creatorLabel}</Text>
/> <Text style={[styles.signature, { color: palette.mutedText }]}>
{!canEdit ? ( Last changed {formatTimestamp(note.lastChangedAt)}
<Text style={styles.readOnlyText}> </Text>
Only the creator of this note can update or delete it. <TextInput
</Text> editable={canEdit}
) : null} multiline
{localErrorMessage ? <Text style={styles.errorText}>{localErrorMessage}</Text> : null} onChangeText={setContent}
{!localErrorMessage && errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} style={[styles.contentInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
{statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null} textAlignVertical="top"
{canEdit ? ( value={content}
<View style={styles.actions}> placeholderTextColor={palette.mutedText}
<Pressable disabled={isSaving} onPress={onSave} style={styles.primaryButton}> />
<Text style={styles.primaryButtonText}> {!canEdit ? (
{isSaving ? "Saving..." : "Save changes"} <Text style={[styles.readOnlyText, { color: palette.mutedText }]}>
Only the creator of this note can update or delete it.
</Text> </Text>
</Pressable> ) : null}
<Pressable disabled={isDeleting} onPress={confirmDelete} style={styles.deleteButton}> {localErrorMessage ? <Text style={styles.errorText}>{localErrorMessage}</Text> : null}
<Text style={styles.deleteButtonText}> {!localErrorMessage && errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{isDeleting ? "Deleting..." : "Delete note"} {statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null}
</Text> </ScrollView>
</Pressable> {canEdit ? (
</View> <View style={[styles.actions, { bottom: insets.bottom + 16 }]}>
) : null} <Pressable disabled={isSaving} onPress={onSave} style={[styles.primaryButton, { backgroundColor: palette.accent }]}>
</View> <Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
); {isSaving ? "Saving..." : "Save changes"}
</Text>
</Pressable>
<Pressable disabled={isDeleting} onPress={confirmDelete} style={[styles.deleteButton, { backgroundColor: palette.destructive }]}>
<Text style={styles.deleteButtonText}>
{isDeleting ? "Deleting..." : "Delete note"}
</Text>
</Pressable>
</View>
) : null}
</View>
</KeyboardAvoidingView>
)
} }
const styles = StyleSheet.create( const styles = StyleSheet.create(
{ {
container: { flex: 1, padding: 16, gap: 12 }, keyboardAvoider: { flex: 1 },
container: { flex: 1 },
formContent: { padding: 16, gap: 12 },
title: { fontSize: 22, fontWeight:"700" }, title: { fontSize: 22, fontWeight:"700" },
content: { fontSize: 16 }, content: { fontSize: 16 },
titleInput: { titleInput: {
@@ -175,7 +196,6 @@ const styles = StyleSheet.create(
color: "#666", color: "#666",
}, },
contentInput: { contentInput: {
flex: 1,
minHeight: 200, minHeight: 200,
borderWidth: 1, borderWidth: 1,
borderRadius: 8, borderRadius: 8,
@@ -192,28 +212,41 @@ const styles = StyleSheet.create(
color: "#666", color: "#666",
}, },
actions: { actions: {
position: "absolute",
left: 16,
right: 16,
flexDirection: "row",
gap: 12, gap: 12,
}, },
primaryButton: { primaryButton: {
flex: 1,
borderRadius: 12, borderRadius: 12,
paddingVertical: 14, paddingVertical: 14,
alignItems: "center", alignItems: "center",
backgroundColor: "#111", shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
}, },
primaryButtonText: { primaryButtonText: {
color: "#fff",
fontSize: 16, fontSize: 16,
fontWeight: "700", fontWeight: "700",
}, },
deleteButton: { deleteButton: {
flex: 1,
borderRadius: 12, borderRadius: 12,
paddingVertical: 14, paddingVertical: 14,
alignItems: "center", alignItems: "center",
backgroundColor: "#b71c1c", shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
}, },
deleteButtonText: { deleteButtonText: {
color: "#fff", color: "#fff",
fontSize: 16, fontSize: 16,
fontWeight: "700", fontWeight: "700",
}, },
}); })

View File

@@ -1,20 +1,23 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react"
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native"; import { Appearance, FlatList, Pressable, StyleSheet, Text, View } from "react-native"
import { router } from "expo-router"; import { router } from "expo-router"
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useAuthContext } from "@/hooks/use-auth-context"; import { useAuthContext } from "@/hooks/use-auth-context"
import { useNotes } from "@/src/notes/NotesContext"; import { useNotes } from "@/src/notes/NotesContext"
import SignOutButton from '@/components/social-auth-buttons/sign-out-button' import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
import { useAppTheme } from "@/src/theme/AppThemeProvider"
type TabKey = "my-notes" | "work-notes" type TabKey = "my-notes" | "work-notes"
export default function HomeScreen() export default function HomeScreen()
{ {
const { claims } = useAuthContext(); const { claims } = useAuthContext()
const { errorMessage, isLoading, notes } = useNotes(); const { errorMessage, isLoading, notes } = useNotes()
const [activeTab, setActiveTab] = useState<TabKey>("my-notes"); const [activeTab, setActiveTab] = useState<TabKey>("my-notes")
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets()
const userId = claims?.sub; const { colorScheme, palette } = useAppTheme()
const userId = claims?.sub
const filteredNotes = useMemo( const filteredNotes = useMemo(
() => () =>
@@ -22,7 +25,7 @@ export default function HomeScreen()
activeTab === "my-notes" ? note.createdBy === userId : note.createdBy !== userId activeTab === "my-notes" ? note.createdBy === userId : note.createdBy !== userId
), ),
[activeTab, notes, userId] [activeTab, notes, userId]
); )
const emptyText = const emptyText =
activeTab === "my-notes" activeTab === "my-notes"
@@ -39,10 +42,10 @@ export default function HomeScreen()
return parsed.toLocaleString() return parsed.toLocaleString()
} }
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: palette.background }]}>
<View style={[styles.topBar, { paddingTop: insets.top + 8 }]}> <View style={[styles.topBar, { paddingTop: insets.top + 8, backgroundColor: palette.surface, borderBottomColor: palette.border }]}>
<Text style={styles.screenTitle}>FastNotes</Text> <Text style={[styles.screenTitle, { color: palette.text }]}>FastNotes</Text>
<SignOutButton /> <SignOutButton />
</View> </View>
@@ -51,12 +54,14 @@ return (
onPress={() => setActiveTab("my-notes")} onPress={() => setActiveTab("my-notes")}
style={[ style={[
styles.tabButton, styles.tabButton,
{ backgroundColor: palette.elevated, borderColor: palette.border },
activeTab === "my-notes" ? styles.tabButtonActive : null, activeTab === "my-notes" ? styles.tabButtonActive : null,
]} ]}
> >
<Text <Text
style={[ style={[
styles.tabButtonText, styles.tabButtonText,
{ color: palette.text },
activeTab === "my-notes" ? styles.tabButtonTextActive : null, activeTab === "my-notes" ? styles.tabButtonTextActive : null,
]} ]}
> >
@@ -67,12 +72,14 @@ return (
onPress={() => setActiveTab("work-notes")} onPress={() => setActiveTab("work-notes")}
style={[ style={[
styles.tabButton, styles.tabButton,
{ backgroundColor: palette.elevated, borderColor: palette.border },
activeTab === "work-notes" ? styles.tabButtonActive : null, activeTab === "work-notes" ? styles.tabButtonActive : null,
]} ]}
> >
<Text <Text
style={[ style={[
styles.tabButtonText, styles.tabButtonText,
{ color: palette.text },
activeTab === "work-notes" ? styles.tabButtonTextActive : null, activeTab === "work-notes" ? styles.tabButtonTextActive : null,
]} ]}
> >
@@ -94,7 +101,7 @@ return (
} }
renderItem={({ item }) => ( renderItem={({ item }) => (
<Pressable <Pressable
style={styles.noteItem} style={[styles.noteItem, { backgroundColor: palette.surface, borderColor: palette.border }]}
onPress={() => onPress={() =>
router.push({ router.push({
pathname: "/detail", pathname: "/detail",
@@ -102,10 +109,10 @@ return (
}) })
} }
> >
<Text style={styles.noteTitle}>{item.title}</Text> <Text style={[styles.noteTitle, { color: palette.text }]}>{item.title}</Text>
<Text numberOfLines={2} style={styles.notePreview}>{item.content}</Text> <Text numberOfLines={2} style={[styles.notePreview, { color: palette.mutedText }]}>{item.content}</Text>
<Text style={styles.noteMeta}>Created by {item.creatorLabel}</Text> <Text style={[styles.noteMeta, { color: palette.mutedText }]}>Created by {item.creatorLabel}</Text>
<Text style={styles.noteMeta}> <Text style={[styles.noteMeta, { color: palette.mutedText }]}>
Last changed {formatTimestamp(item.lastChangedAt)} Last changed {formatTimestamp(item.lastChangedAt)}
</Text> </Text>
</Pressable> </Pressable>
@@ -114,14 +121,14 @@ return (
{activeTab === "my-notes" ? ( {activeTab === "my-notes" ? (
<Pressable <Pressable
style={[styles.fab, { bottom: insets.bottom + 24, right: insets.right + 40 }]} style={[styles.fab, { bottom: insets.bottom + 24, right: insets.right + 40, backgroundColor: palette.accent }]}
onPress={() => router.push("/newNote")} onPress={() => router.push("/newNote")}
> >
<Text style={styles.fabText}>+</Text> <Text style={[styles.fabText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>+</Text>
</Pressable> </Pressable>
) : null} ) : null}
</View> </View>
); )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -132,6 +139,7 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 8, paddingBottom: 8,
borderBottomWidth: 1,
}, },
screenTitle: { screenTitle: {
fontSize: 24, fontSize: 24,
@@ -142,21 +150,21 @@ const styles = StyleSheet.create({
gap: 8, gap: 8,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 8, paddingBottom: 8,
paddingTop: 12
}, },
tabButton: { tabButton: {
flex: 1, flex: 1,
borderWidth: 1,
borderRadius: 999, borderRadius: 999,
paddingVertical: 10, paddingVertical: 10,
alignItems: "center", alignItems: "center",
backgroundColor: "#e6e6e6",
}, },
tabButtonActive: { tabButtonActive: {
backgroundColor: "#111", backgroundColor: Appearance.getColorScheme() === "light" ? "#000000":"#8a8888",
}, },
tabButtonText: { tabButtonText: {
fontSize: 14, fontSize: 14,
fontWeight: "600", fontWeight: "600",
color: "#111",
}, },
tabButtonTextActive: { tabButtonTextActive: {
color: "#fff", color: "#fff",
@@ -164,8 +172,8 @@ const styles = StyleSheet.create({
list: { padding: 16, gap: 12, paddingTop: 8 }, list: { padding: 16, gap: 12, paddingTop: 8 },
noteItem: { padding: 16, borderWidth: 1, borderRadius:12, gap: 8 }, noteItem: { padding: 16, borderWidth: 1, borderRadius:12, gap: 8 },
noteTitle: { fontSize: 16, fontWeight: "600" }, noteTitle: { fontSize: 16, fontWeight: "600" },
notePreview: { fontSize: 14, color: "#444" }, notePreview: { fontSize: 14 },
noteMeta: { fontSize: 12, color: "#666" }, noteMeta: { fontSize: 12 },
emptyText: { emptyText: {
textAlign: "center", textAlign: "center",
paddingVertical: 32, paddingVertical: 32,
@@ -181,7 +189,11 @@ const styles = StyleSheet.create({
position: "absolute", position: "absolute",
width: 56, height: 56, borderRadius: 28, width: 56, height: 56, borderRadius: 28,
alignItems: "center", justifyContent: "center", alignItems: "center", justifyContent: "center",
backgroundColor: "grey" shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
}, },
fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700"} fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700"}
}); })

View File

@@ -1,13 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, Stack } from 'expo-router' import { Link, Stack } from 'expo-router'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { Appearance, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { supabase } from '@/libs/supabase' import { supabase } from '@/libs/supabase'
import { useAppTheme } from '@/src/theme/AppThemeProvider'
export default function LoginScreen() { export default function LoginScreen() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const { colorScheme, palette } = useAppTheme()
const onLogin = async () => { const onLogin = async () => {
if (!email.trim() || !password) { if (!email.trim() || !password) {
@@ -33,30 +35,30 @@ export default function LoginScreen() {
return ( return (
<> <>
<Stack.Screen options={{ title: 'Login' }} /> <Stack.Screen options={{ title: 'Login' }} />
<View style={styles.container}> <View style={[styles.container, { backgroundColor: palette.background }]}>
<Text style={styles.title}>Login</Text> <Text style={[styles.title, { color: palette.text }]}>Login</Text>
<Text style={styles.label}>E-mail</Text> <Text style={[styles.label, { color: palette.text }]}>E-mail</Text>
<TextInput <TextInput
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
keyboardType="email-address" keyboardType="email-address"
onChangeText={setEmail} onChangeText={setEmail}
placeholder="name@email.com" placeholder="name@email.com"
placeholderTextColor="#666" placeholderTextColor={palette.mutedText}
style={styles.input} style={[styles.input, { color: palette.text, backgroundColor: palette.input, borderColor: palette.border }]}
value={email} value={email}
/> />
<Text style={styles.label}>Password</Text> <Text style={[styles.label, { color: palette.text }]}>Password</Text>
<TextInput <TextInput
autoCapitalize="none" autoCapitalize="none"
autoComplete="password" autoComplete="password"
onChangeText={setPassword} onChangeText={setPassword}
placeholder="Password" placeholder="Password"
placeholderTextColor="#666" placeholderTextColor={palette.mutedText}
secureTextEntry secureTextEntry
style={styles.input} style={[styles.input, { color: palette.text, backgroundColor: palette.input, borderColor: palette.border }]}
value={password} value={password}
/> />
@@ -79,7 +81,7 @@ export default function LoginScreen() {
</Pressable> </Pressable>
<Link href="/signup" style={styles.link}> <Link href="/signup" style={styles.link}>
<Text style={styles.linkText}>Create a new account</Text> <Text style={[styles.linkText, { color: colorScheme === "dark" ? "#8ab4ff" : "#0b57d0" }]}>Create a new account</Text>
</Link> </Link>
</View> </View>
</> </>
@@ -92,26 +94,20 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 24,
gap: 12, gap: 12,
backgroundColor: '#f7f7f7',
}, },
title: { title: {
fontSize: 28, fontSize: 28,
fontWeight: '700', fontWeight: '700',
color: '#111',
}, },
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#111',
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: '#cfcfcf',
borderRadius: 8, borderRadius: 8,
padding: 12, padding: 12,
fontSize: 16, fontSize: 16,
color: '#111',
backgroundColor: '#fff',
}, },
errorText: { errorText: {
color: '#c62828', color: '#c62828',
@@ -121,7 +117,7 @@ const styles = StyleSheet.create({
borderRadius: 12, borderRadius: 12,
paddingVertical: 14, paddingVertical: 14,
alignItems: 'center', alignItems: 'center',
backgroundColor: '#111', backgroundColor: Appearance.getColorScheme() === "light" ? '#000000':'#696969',
marginTop: 8, marginTop: 8,
}, },
loginButtonPressed: { loginButtonPressed: {

View File

@@ -2,12 +2,13 @@ import
{ {
StyleSheet, Text, View, KeyboardAvoidingView, StyleSheet, Text, View, KeyboardAvoidingView,
Platform, Pressable, TextInput, ScrollView Platform, Pressable, TextInput, ScrollView
} from "react-native"; } from "react-native"
import { router, } from "expo-router"; import { router, } from "expo-router"
import { useNotes } from "@/src/notes/NotesContext"; import { useNotes } from "@/src/notes/NotesContext"
import { useState, useRef } from "react"; import { useState, useRef } from "react"
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useHeaderHeight } from "@react-navigation/elements"; import { useHeaderHeight } from "@react-navigation/elements"
import { useAppTheme } from "@/src/theme/AppThemeProvider"
@@ -20,6 +21,7 @@ export default function NewNoteScreen()
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null) const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const headerHeight = useHeaderHeight() const headerHeight = useHeaderHeight()
const { colorScheme, palette } = useAppTheme()
const [contentHeight, setContentHeight] = useState(160) const [contentHeight, setContentHeight] = useState(160)
const scrollRef = useRef<ScrollView>(null) const scrollRef = useRef<ScrollView>(null)
@@ -43,22 +45,24 @@ export default function NewNoteScreen()
} }
return ( return (
<KeyboardAvoidingView style={{ flex: 1}} behavior={Platform.OS === "ios" ? "padding" : undefined} <KeyboardAvoidingView
style={styles.keyboardAvoider}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={headerHeight}> keyboardVerticalOffset={headerHeight}>
<View style={styles.container}> <View style={[styles.container, { backgroundColor: palette.background }]}>
<ScrollView ref={scrollRef} contentContainerStyle={{ padding: 16, paddingBottom: 120, gap: 10}} <ScrollView ref={scrollRef} contentContainerStyle={[styles.formContent, { paddingBottom: insets.bottom + 112 }]}
keyboardShouldPersistTaps="handled"> keyboardShouldPersistTaps="handled">
<Text style={styles.label}>Title</Text>
<TextInput value={title} onChangeText={setTitle} <TextInput value={title} onChangeText={setTitle}
placeholder="Give it a title..." style={styles.input} placeholder="Give it a title..." style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
placeholderTextColor={palette.mutedText}
returnKeyType="next"/> returnKeyType="next"/>
<Text style={styles.label}>Content</Text>
<TextInput value={content} onChangeText={setContent} placeholder="Write your note..." <TextInput value={content} onChangeText={setContent} placeholder="Write your note..."
style={[styles.input, { height: Math.max(160, contentHeight) }]} multiline style={[styles.contentInput, { minHeight: Math.max(200, contentHeight), color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} multiline
placeholderTextColor={palette.mutedText}
textAlignVertical="top" textAlignVertical="top"
onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height); onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height)
scrollRef.current?.scrollToEnd({ animated: true }); scrollRef.current?.scrollToEnd({ animated: true })
}}/> }}/>
{localErrorMessage ? ( {localErrorMessage ? (
@@ -70,59 +74,60 @@ export default function NewNoteScreen()
) : null} ) : null}
</ScrollView> </ScrollView>
<View> <View style={[styles.actions, { bottom: insets.bottom + 16 }]}>
<Pressable disabled={isSaving} onPress={onSave} <Pressable disabled={isSaving} onPress={onSave}
style={[styles.saveFloating, { bottom: insets.bottom + 16 }]}> style={[styles.saveButton, { backgroundColor: palette.accent }]}>
<Text style={styles.saveFloatingText}> <Text style={[styles.saveFloatingText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
{isSaving ? "Saving..." : "Save"} {isSaving ? "Saving..." : "Save note"}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
); )
} }
const styles = StyleSheet.create( const styles = StyleSheet.create(
{ {
container: { flex: 1, gap: 10 }, keyboardAvoider: { flex: 1 },
label: { fontSize: 14, fontWeight: "600" }, container: { flex: 1 },
input: formContent: { padding: 16, gap: 12 },
titleInput:
{ {
borderWidth: 1, borderRadius: 7, borderWidth: 1,
padding: 12, fontSize: 16 borderRadius: 8,
padding: 12,
fontSize: 22,
fontWeight: "700",
}, },
saveBtn: contentInput:
{ {
borderRadius: 12, borderWidth: 1,
paddingVertical: 14, alignItems: "center", borderRadius: 8,
backgroundColor: "grey" padding: 12,
fontSize: 16,
}, },
saveText: { color: "white", fontSize: 16, fontWeight: "700"}, actions: {
saveBar:
{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
paddingTop: 12,
paddingHorizontal: 16,
backgroundColor: "white",
borderTopWidth: 1
},
saveFloating:
{
position: "absolute", position: "absolute",
left: 16, left: 16,
right: 16, right: 16,
flexDirection: "row",
gap: 12,
},
saveButton:
{
flex: 1,
borderRadius: 12, borderRadius: 12,
paddingVertical: 14, paddingVertical: 14,
alignItems: "center", alignItems: "center",
backgroundColor: "#111" shadowColor: "#000",
shadowOpacity: 0.18,
shadowOffset: { width: 0, height: 8 },
shadowRadius: 16,
elevation: 8,
}, },
saveFloatingText: saveFloatingText:
{ {
color: "white",
fontSize: 16, fontSize: 16,
fontWeight: "700" fontWeight: "700"
}, },
@@ -130,4 +135,4 @@ const styles = StyleSheet.create(
color: "#c62828" color: "#c62828"
}, },
} }
); )

View File

@@ -1,7 +1,8 @@
import { supabase } from "@/libs/supabase"; import { supabase } from "@/libs/supabase"
import { Link, Stack } from "expo-router"; import { Link, Stack } from "expo-router"
import { useState } from "react"; import { useState } from "react"
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { useAppTheme } from "@/src/theme/AppThemeProvider"
export default function SignupScreen(){ export default function SignupScreen(){
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@@ -9,6 +10,7 @@ export default function SignupScreen(){
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null) const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const { colorScheme, palette } = useAppTheme()
const onSignup = async () => { const onSignup = async () => {
if(!email.trim() || !password){ if(!email.trim() || !password){
@@ -56,30 +58,30 @@ export default function SignupScreen(){
return ( return (
<> <>
<Stack.Screen options={{title: "Signup"}}/> <Stack.Screen options={{title: "Signup"}}/>
<View style={styles.container}> <View style={[styles.container, { backgroundColor: palette.background }]}>
<Text style={styles.title}>Sign up</Text> <Text style={[styles.title, { color: palette.text }]}>Sign up</Text>
<Text style={styles.label}>E-mail</Text> <Text style={[styles.label, { color: palette.text }]}>E-mail</Text>
<TextInput <TextInput
autoCapitalize="none" autoCapitalize="none"
autoComplete="email" autoComplete="email"
keyboardType="email-address" keyboardType="email-address"
onChangeText={setEmail} onChangeText={setEmail}
placeholder="name@email.com" placeholder="name@email.com"
placeholderTextColor="#666" placeholderTextColor={palette.mutedText}
style={styles.input} style={[styles.input, { color: palette.text, backgroundColor: palette.input, borderColor: palette.border }]}
value={email} value={email}
/> />
<Text style={styles.label} >Password</Text> <Text style={[styles.label, { color: palette.text }]} >Password</Text>
<TextInput <TextInput
autoCapitalize="none" autoCapitalize="none"
autoComplete="password" autoComplete="password"
onChangeText={setPassword} onChangeText={setPassword}
placeholder="Password" placeholder="Password"
placeholderTextColor="#666" placeholderTextColor={palette.mutedText}
secureTextEntry secureTextEntry
style={styles.input} style={[styles.input, { color: palette.text, backgroundColor: palette.input, borderColor: palette.border }]}
value={password} value={password}
/> />
@@ -106,7 +108,7 @@ export default function SignupScreen(){
</Pressable> </Pressable>
<Link href="/login" style={styles.link}> <Link href="/login" style={styles.link}>
<Text style={styles.linkText}>Already have an account? Log in</Text> <Text style={[styles.linkText, { color: colorScheme === "dark" ? "#8ab4ff" : "#0b57d0" }]}>Already have an account? Log in</Text>
</Link> </Link>
</View> </View>
@@ -121,26 +123,20 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
padding: 24, padding: 24,
gap: 12, gap: 12,
backgroundColor: '#f7f7f7',
}, },
title: { title: {
fontSize: 28, fontSize: 28,
fontWeight: '700', fontWeight: '700',
color: '#111',
}, },
label: { label: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#111',
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: '#cfcfcf',
borderRadius: 8, borderRadius: 8,
padding: 12, padding: 12,
fontSize: 16, fontSize: 16,
color: '#111',
backgroundColor: '#fff',
}, },
errorText: { errorText: {
color: '#c62828', color: '#c62828',

View File

@@ -1,7 +1,8 @@
import { supabase } from '@/libs/supabase' import { supabase } from '@/libs/supabase'
import { router } from 'expo-router' import { router } from 'expo-router'
import React from 'react' import React from 'react'
import { Button } from 'react-native' import { Pressable, StyleSheet, Text } from 'react-native'
import { useAppTheme } from '@/src/theme/AppThemeProvider'
async function onSignOutButtonPress() { async function onSignOutButtonPress() {
const { error } = await supabase.auth.signOut() const { error } = await supabase.auth.signOut()
@@ -15,5 +16,27 @@ async function onSignOutButtonPress() {
} }
export default function SignOutButton() { export default function SignOutButton() {
return <Button title="Sign out" onPress={onSignOutButtonPress} /> const { palette } = useAppTheme()
return (
<Pressable
onPress={onSignOutButtonPress}
style={[styles.button, { borderColor: palette.border, backgroundColor: palette.elevated }]}
>
<Text style={[styles.text, { color: palette.text }]}>Sign out</Text>
</Pressable>
)
} }
const styles = StyleSheet.create({
button: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 8,
},
text: {
fontSize: 14,
fontWeight: '600',
},
})

View File

@@ -1,12 +1,12 @@
import { StyleSheet, Text, type TextProps } from 'react-native'; import { StyleSheet, Text, type TextProps } from 'react-native'
import { useThemeColor } from '@/hooks/use-theme-color'; import { useThemeColor } from '@/hooks/use-theme-color'
export type ThemedTextProps = TextProps & { export type ThemedTextProps = TextProps & {
lightColor?: string; lightColor?: string
darkColor?: string; darkColor?: string
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'
}; }
export function ThemedText({ export function ThemedText({
style, style,
@@ -15,7 +15,7 @@ export function ThemedText({
type = 'default', type = 'default',
...rest ...rest
}: ThemedTextProps) { }: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text')
return ( return (
<Text <Text
@@ -30,7 +30,7 @@ export function ThemedText({
]} ]}
{...rest} {...rest}
/> />
); )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -57,4 +57,4 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
color: '#0a7ea4', color: '#0a7ea4',
}, },
}); })

View File

@@ -1,14 +1,14 @@
import { View, type ViewProps } from 'react-native'; import { View, type ViewProps } from 'react-native'
import { useThemeColor } from '@/hooks/use-theme-color'; import { useThemeColor } from '@/hooks/use-theme-color'
export type ThemedViewProps = ViewProps & { export type ThemedViewProps = ViewProps & {
lightColor?: string; lightColor?: string
darkColor?: string; darkColor?: string
}; }
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background')
return <View style={[{ backgroundColor }, style]} {...otherProps} />; return <View style={[{ backgroundColor }, style]} {...otherProps} />
} }

View File

@@ -1 +1 @@
export { useColorScheme } from 'react-native'; export { useAppColorScheme as useColorScheme } from '@/src/theme/AppThemeProvider';

View File

@@ -1,21 +1 @@
import { useEffect, useState } from 'react'; export { useAppColorScheme as useColorScheme } from '@/src/theme/AppThemeProvider';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -5,7 +5,7 @@ export function useThemeColor(
props: { light?: string; dark?: string }, props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) { ) {
const theme = useColorScheme() ?? 'light'; const theme = useColorScheme();
const colorFromProps = props[theme]; const colorFromProps = props[theme];
if (colorFromProps) { if (colorFromProps) {

View File

@@ -1,12 +1,11 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js'
import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store'; import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store'
import 'react-native-url-polyfill/auto'; import 'react-native-url-polyfill/auto'
import Constants from "expo-constants" import Constants from "expo-constants"
import { Platform } from 'react-native'; import { Platform } from 'react-native'
const ExpoSecureStoreAdapter = { const ExpoSecureStoreAdapter = {
getItem: (key: string) => { getItem: (key: string) => {
console.debug("getItem", { key, getItemAsync })
return getItemAsync(key) return getItemAsync(key)
}, },
setItem: (key: string, value: string) => { setItem: (key: string, value: string) => {
@@ -18,12 +17,12 @@ const ExpoSecureStoreAdapter = {
removeItem: (key: string) => { removeItem: (key: string) => {
return deleteItemAsync(key) return deleteItemAsync(key)
}, },
}; }
const extra = (Constants.expoConfig?.extra ?? Constants.manifest?.extra) as { const extra = (Constants.expoConfig?.extra ?? Constants.manifest?.extra) as {
supabaseUrl?: string; supabaseUrl?: string
supabaseKey?: string; supabaseKey?: string
}; }
const supabaseUrl = extra?.supabaseUrl const supabaseUrl = extra?.supabaseUrl
const supabaseAnonKey = extra?.supabaseKey const supabaseAnonKey = extra?.supabaseKey
@@ -46,4 +45,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
detectSessionInUrl: false, detectSessionInUrl: false,
}, },
} }
) )

View File

@@ -2,50 +2,41 @@ import { AuthContext } from '@/hooks/use-auth-context'
import { supabase } from '@/libs/supabase' import { supabase } from '@/libs/supabase'
import { PropsWithChildren, useEffect, useState } from 'react' import { PropsWithChildren, useEffect, useState } from 'react'
const buildClaims = (user?: { id: string; email?: string | null } | null) =>
user
? {
sub: user.id,
email: user.email,
}
: null
export default function AuthProvider({ children }: PropsWithChildren) { export default function AuthProvider({ children }: PropsWithChildren) {
const [claims, setClaims] = useState<Record<string, any> | undefined | null>() const [claims, setClaims] = useState<Record<string, any> | undefined | null>()
const [profile, setProfile] = useState<any>() const [profile, setProfile] = useState<any>()
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => { useEffect(() => {
const syncAuthState = async () => { const hydrateSession = async () => {
setIsLoading(true)
const { const {
data: { user }, data: { session },
error, error,
} = await supabase.auth.getUser() } = await supabase.auth.getSession()
if (error) { if (error) {
console.error('Error fetching user:', error) console.error('Error hydrating session:', error)
} }
setClaims( setClaims(buildClaims(session?.user))
user
? {
sub: user.id,
email: user.email,
}
: null
)
setIsLoading(false) setIsLoading(false)
} }
void syncAuthState() void hydrateSession()
const { const {
data: { subscription }, data: { subscription },
} = supabase.auth.onAuthStateChange(async (_event, session) => { } = supabase.auth.onAuthStateChange((_event, session) => {
console.log('Auth state changed:', { event: _event }) setClaims(buildClaims(session?.user))
setIsLoading(false)
setClaims(
session?.user
? {
sub: session.user.id,
email: session.user.email,
}
: null
)
}) })
return () => { return () => {

View File

@@ -148,7 +148,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
// Poll for remote changes so other users' edits appear without a manual refresh. // Poll for remote changes so other users' edits appear without a manual refresh.
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
void loadNotes() void loadNotes()
}, 60000) }, 30000)
return () => { return () => {
clearInterval(intervalId) clearInterval(intervalId)

View File

@@ -0,0 +1,58 @@
import { createContext, useContext, useEffect, useState } from "react"
import { Appearance } from "react-native"
import { AppColorScheme, getThemePalette } from "@/src/theme/palette"
type AppThemeContextValue = {
colorScheme: AppColorScheme
palette: ReturnType<typeof getThemePalette>
}
const AppThemeContext = createContext<AppThemeContextValue | undefined>(undefined)
function resolveColorScheme(colorScheme: "light" | "dark" | null | undefined): AppColorScheme {
return colorScheme === "dark" ? "dark" : "light"
}
export function AppThemeProvider({ children }: { children: React.ReactNode }) {
const [colorScheme, setColorScheme] = useState<AppColorScheme>(() =>
resolveColorScheme(Appearance.getColorScheme())
)
useEffect(() => {
const subscription = Appearance.addChangeListener(({ colorScheme: nextColorScheme }) => {
setColorScheme(resolveColorScheme(nextColorScheme))
})
setColorScheme(resolveColorScheme(Appearance.getColorScheme()))
return () => {
subscription.remove()
}
}, [])
return (
<AppThemeContext.Provider
value={{
colorScheme,
palette: getThemePalette(colorScheme),
}}
>
{children}
</AppThemeContext.Provider>
)
}
export function useAppTheme() {
const value = useContext(AppThemeContext)
if (!value) {
throw new Error("useAppTheme must be used inside AppThemeProvider")
}
return value
}
export function useAppColorScheme() {
return useAppTheme().colorScheme
}

View File

@@ -0,0 +1,31 @@
export type AppColorScheme = "light" | "dark"
const lightPalette = {
background: "#f5efe6",
surface: "#fffdf8",
elevated: "#ffffff",
text: "#111111",
mutedText: "#5f5a52",
border: "#77736e",
input: "#ffffff",
accent: "#111111",
destructive: "#b71c1c",
statusBar: "#fffdf8",
}
const darkPalette = {
background: "#000000",
surface: "#0b0b0b",
elevated: "#111111",
text: "#ffffff",
mutedText: "#b8b8b8",
border: "#ffffff",
input: "#151515",
accent: "#ffffff",
destructive: "#ff6b6b",
statusBar: "#000000",
}
export function getThemePalette(colorScheme: AppColorScheme) {
return colorScheme === "dark" ? darkPalette : lightPalette
}