From a4c2d55aeab56f13909f40e0f7c68f65ab66b62c Mon Sep 17 00:00:00 2001 From: Christopher Sanden Date: Mon, 16 Mar 2026 13:31:41 +0100 Subject: [PATCH 1/6] initial branch commit --- FastNotes/libs/supabase.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FastNotes/libs/supabase.ts b/FastNotes/libs/supabase.ts index 2e58882..4d7d7a7 100644 --- a/FastNotes/libs/supabase.ts +++ b/FastNotes/libs/supabase.ts @@ -47,3 +47,5 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, { }, } ) + + From ca915ec8e88ef27fe8789cf38bc530efc37a7d02 Mon Sep 17 00:00:00 2001 From: Christopher Sanden Date: Mon, 16 Mar 2026 17:42:22 +0100 Subject: [PATCH 2/6] finished assignment 3 - 100% --- FastNotes/.env | 2 + FastNotes/.gitignore | 13 +- FastNotes/app.config.js | 3 +- FastNotes/app.json | 15 +- FastNotes/app/_layout.tsx | 49 +- FastNotes/app/detail.tsx | 249 ++++---- FastNotes/app/index.tsx | 94 +-- FastNotes/app/login.tsx | 56 +- FastNotes/app/newNote.tsx | 261 ++++---- FastNotes/app/signup.tsx | 61 +- FastNotes/components/hello-wave.tsx | 19 - FastNotes/components/note-image-panel.tsx | 173 ++++++ FastNotes/components/parallax-scroll-view.tsx | 18 +- .../social-auth-buttons/sign-out-button.tsx | 32 +- FastNotes/components/themed-text.tsx | 29 +- FastNotes/components/ui/collapsible.tsx | 15 +- FastNotes/components/upload-progress-bar.tsx | 36 ++ FastNotes/libs/supabase.ts | 22 +- FastNotes/package-lock.json | 281 ++++++--- FastNotes/package.json | 4 + FastNotes/providers/auth-provider.tsx | 6 +- FastNotes/src/notes/NotesContext.tsx | 292 +++++++-- FastNotes/src/notes/image-utils.ts | 199 ++++++ FastNotes/src/notes/native-image-picker.ts | 79 +++ FastNotes/src/notes/note-image-storage.ts | 153 +++++ .../PushNotificationsProvider.tsx | 105 ++++ .../src/notifications/push-notifications.ts | 134 +++++ FastNotes/src/styles/app-styles.ts | 568 ++++++++++++++++++ FastNotes/src/theme/palette.ts | 4 +- FastNotes/supabase/.gitignore | 0 FastNotes/supabase/config.toml | 388 ++++++++++++ FastNotes/supabase/functions/push/index.ts | 164 +++++ FastNotes/tempStorage.tsx | 56 -- 33 files changed, 2869 insertions(+), 711 deletions(-) create mode 100644 FastNotes/.env delete mode 100644 FastNotes/components/hello-wave.tsx create mode 100644 FastNotes/components/note-image-panel.tsx create mode 100644 FastNotes/components/upload-progress-bar.tsx create mode 100644 FastNotes/src/notes/image-utils.ts create mode 100644 FastNotes/src/notes/native-image-picker.ts create mode 100644 FastNotes/src/notes/note-image-storage.ts create mode 100644 FastNotes/src/notifications/PushNotificationsProvider.tsx create mode 100644 FastNotes/src/notifications/push-notifications.ts create mode 100644 FastNotes/src/styles/app-styles.ts create mode 100644 FastNotes/supabase/.gitignore create mode 100644 FastNotes/supabase/config.toml create mode 100644 FastNotes/supabase/functions/push/index.ts delete mode 100644 FastNotes/tempStorage.tsx diff --git a/FastNotes/.env b/FastNotes/.env new file mode 100644 index 0000000..e440fd2 --- /dev/null +++ b/FastNotes/.env @@ -0,0 +1,2 @@ +EXPO_PUBLIC_SUPABASE_URL=https://mogieparkvgcobaukpsr.supabase.co +EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_V_59BIi7RykQTnH7HNMWbg_dWN9hlbS \ No newline at end of file diff --git a/FastNotes/.gitignore b/FastNotes/.gitignore index 30f0284..a30b89a 100644 --- a/FastNotes/.gitignore +++ b/FastNotes/.gitignore @@ -30,10 +30,6 @@ yarn-error.* .DS_Store *.pem -# local env files -.env*.local -.env.local -.env # typescript *.tsbuildinfo @@ -41,3 +37,12 @@ yarn-error.* # generated native folders /ios /android + +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/FastNotes/app.config.js b/FastNotes/app.config.js index 60f4433..503edbb 100644 --- a/FastNotes/app.config.js +++ b/FastNotes/app.config.js @@ -6,5 +6,6 @@ export default ({ config }) => ({ ...config.extra, supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL, supabaseKey: process.env.EXPO_PUBLIC_SUPABASE_KEY, + easProjectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID, }, -}); \ No newline at end of file +}); diff --git a/FastNotes/app.json b/FastNotes/app.json index 6d727a6..0d38391 100644 --- a/FastNotes/app.json +++ b/FastNotes/app.json @@ -9,15 +9,20 @@ "userInterfaceStyle": "automatic", "newArchEnabled": true, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.fastnotes.app" }, "android": { + "package": "com.fastnotes.app", "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", "backgroundImage": "./assets/images/android-icon-background.png", "monochromeImage": "./assets/images/android-icon-monochrome.png" }, + "permissions": [ + "POST_NOTIFICATIONS" + ], "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false }, @@ -27,6 +32,14 @@ }, "plugins": [ "expo-router", + [ + "expo-image-picker", + { + "photosPermission": "Allow FastNotes to access your photo library so you can attach images to notes.", + "cameraPermission": "Allow FastNotes to use your camera so you can take photos for notes." + } + ], + "expo-notifications", [ "expo-splash-screen", { diff --git a/FastNotes/app/_layout.tsx b/FastNotes/app/_layout.tsx index 3613f09..f56a41b 100644 --- a/FastNotes/app/_layout.tsx +++ b/FastNotes/app/_layout.tsx @@ -3,8 +3,11 @@ import AuthProvider from '@/providers/auth-provider' import { NotesProvider } from "@/src/notes/NotesContext" import { AppThemeProvider, useAppTheme } from '@/src/theme/AppThemeProvider' import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' +import Constants from "expo-constants" import { Stack } from 'expo-router' import { StatusBar } from 'expo-status-bar' +import { ComponentType, PropsWithChildren, useEffect, useState } from "react" +import { Platform } from "react-native" import 'react-native-reanimated' import { SafeAreaProvider } from 'react-native-safe-area-context' @@ -71,9 +74,11 @@ function ThemedRootLayout() { - - - + + + + + | null>(null) + + useEffect(() => { + if (isAndroidExpoGo) { + return + } + + let isMounted = true + + const loadProvider = async () => { + try { + const module = await import("@/src/notifications/PushNotificationsProvider") + + if (isMounted) { + setProvider(() => module.default) + } + } catch (error) { + console.error("Failed to load push notifications provider:", error) + } + } + + void loadProvider() + + return () => { + isMounted = false + } + }, [isAndroidExpoGo]) + + if (isAndroidExpoGo || !provider) { + return children + } + + const PushNotificationsProvider = provider + return {children} +} + export default function RootLayout() { return ( diff --git a/FastNotes/app/detail.tsx b/FastNotes/app/detail.tsx index 46b5eea..65349dd 100644 --- a/FastNotes/app/detail.tsx +++ b/FastNotes/app/detail.tsx @@ -1,18 +1,30 @@ import { useEffect, useState } from "react" -import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native" +import { + Alert, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, +} from "react-native" import { router, useLocalSearchParams } from "expo-router" +import { BlurView } from "expo-blur" import { useHeaderHeight } from "@react-navigation/elements" import { useSafeAreaInsets } from "react-native-safe-area-context" + +import NoteImagePanel from "@/components/note-image-panel" +import UploadProgressBar from "@/components/upload-progress-bar" +import { useAuthContext } from "@/hooks/use-auth-context" +import { NoteImageChange, useNotes } from "@/src/notes/NotesContext" +import { StagedNoteImage, validateStagedNoteImage } from "@/src/notes/image-utils" +import { pickImageFromCamera, pickImageFromLibrary } from "@/src/notes/native-image-picker" +import { detailScreenStyles as styles } from "@/src/styles/app-styles" import { useAppTheme } from "@/src/theme/AppThemeProvider" -import { useAuthContext } from "@/hooks/use-auth-context" -import { useNotes } from "@/src/notes/NotesContext" - - -export default function DetailScreen() -{ - const { id } = useLocalSearchParams< - { +export default function DetailScreen() { + const { id } = useLocalSearchParams<{ id?: string }>() const { claims } = useAuthContext() @@ -21,8 +33,11 @@ export default function DetailScreen() const canEdit = note?.createdBy === claims?.sub const [title, setTitle] = useState(note?.title ?? "") const [content, setContent] = useState(note?.content ?? "") + const [stagedImage, setStagedImage] = useState(null) + const [imageChange, setImageChange] = useState({ type: "keep" }) const [isSaving, setIsSaving] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [uploadProgress, setUploadProgress] = useState(null) const [localErrorMessage, setLocalErrorMessage] = useState(null) const [statusMessage, setStatusMessage] = useState(null) const insets = useSafeAreaInsets() @@ -42,7 +57,48 @@ export default function DetailScreen() useEffect(() => { setTitle(note?.title ?? "") setContent(note?.content ?? "") - }, [note?.content, note?.title]) + setStagedImage(null) + setImageChange({ type: "keep" }) + }, [note?.content, note?.id, note?.title]) + + const attachFromCamera = async () => { + try { + const image = await pickImageFromCamera() + + if (image) { + validateStagedNoteImage(image) + setStagedImage(image) + setImageChange({ type: "replace", image }) + setLocalErrorMessage(null) + setStatusMessage(null) + } + } catch (error) { + setLocalErrorMessage(error instanceof Error ? error.message : "The camera could not be opened.") + } + } + + const attachFromGallery = async () => { + try { + const image = await pickImageFromLibrary() + + if (image) { + validateStagedNoteImage(image) + setStagedImage(image) + setImageChange({ type: "replace", image }) + setLocalErrorMessage(null) + setStatusMessage(null) + } + } catch (error) { + setLocalErrorMessage(error instanceof Error ? error.message : "The gallery could not be opened.") + } + } + + const clearImage = () => { + setStagedImage(null) + setStatusMessage(null) + setLocalErrorMessage(null) + setImageChange(note?.imageUrl ? { type: "remove" } : { type: "keep" }) + } const onSave = async () => { if (!id) { @@ -56,20 +112,27 @@ export default function DetailScreen() } setIsSaving(true) + setUploadProgress(null) setLocalErrorMessage(null) setStatusMessage(null) - const wasSaved = await updateNote(id, title, content) + const wasSaved = await updateNote(id, title, content, imageChange, { + onImageUploadProgress: (progress) => { + setUploadProgress(progress.progress) + }, + }) setIsSaving(false) + setUploadProgress(null) if (wasSaved) { + setStagedImage(null) + setImageChange({ type: "keep" }) 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?", @@ -117,7 +180,17 @@ export default function DetailScreen() ) } - return( + const currentImageUrl = imageChange.type === "remove" ? null : note.imageUrl + const currentImageMimeType = imageChange.type === "remove" ? null : note.imageMimeType + const currentImageSizeBytes = imageChange.type === "remove" ? null : note.imageSizeBytes + const isUploading = uploadProgress !== null + const imageActionsDisabled = isSaving || isUploading + const saveDisabled = isSaving + const deleteDisabled = isDeleting || isUploading + const primaryButtonStyle = saveDisabled ? styles.disabledButton : styles.enabledButtonShadow + const deleteButtonStyle = deleteDisabled ? styles.disabledButton : styles.enabledButtonShadow + + return ( @@ -143,11 +219,40 @@ export default function DetailScreen() editable={canEdit} multiline onChangeText={setContent} - style={[styles.contentInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} + style={[ + styles.contentInput, + { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }, + ]} textAlignVertical="top" value={content} placeholderTextColor={palette.mutedText} /> + + { + void attachFromCamera() + }} + onChooseFromLibrary={() => { + void attachFromGallery() + }} + onRemoveImage={clearImage} + /> + + {uploadProgress !== null ? : null} + {!canEdit ? ( Only the creator of this note can update or delete it. @@ -158,95 +263,37 @@ export default function DetailScreen() {statusMessage ? {statusMessage} : null} {canEdit ? ( - - - - {isSaving ? "Saving..." : "Save changes"} - - - - - {isDeleting ? "Deleting..." : "Delete note"} - - + + + + { + void onSave() + }} + style={[styles.primaryButton, primaryButtonStyle, { backgroundColor: palette.accent }]} + > + + {isSaving ? "Saving..." : "Save changes"} + + + + + {isDeleting ? "Deleting..." : "Delete note"} + + + ) : null} ) } - - -const styles = StyleSheet.create( - { - keyboardAvoider: { flex: 1 }, - container: { flex: 1 }, - formContent: { padding: 16, gap: 12 }, - title: { fontSize: 22, fontWeight:"700" }, - content: { fontSize: 16 }, - titleInput: { - borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 22, - fontWeight: "700", - }, - signature: { - fontSize: 12, - color: "#666", - }, - contentInput: { - minHeight: 200, - borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 16, - }, - errorText: { - color: "#c62828", - }, - successText: { - color: "#2e7d32", - }, - readOnlyText: { - color: "#666", - }, - actions: { - position: "absolute", - left: 16, - right: 16, - flexDirection: "row", - gap: 12, - }, - primaryButton: { - flex: 1, - borderRadius: 12, - paddingVertical: 14, - alignItems: "center", - shadowColor: "#000", - shadowOpacity: 0.18, - shadowOffset: { width: 0, height: 8 }, - shadowRadius: 16, - elevation: 8, - }, - primaryButtonText: { - fontSize: 16, - fontWeight: "700", - }, - deleteButton: { - flex: 1, - borderRadius: 12, - paddingVertical: 14, - alignItems: "center", - shadowColor: "#000", - shadowOpacity: 0.18, - shadowOffset: { width: 0, height: 8 }, - shadowRadius: 16, - elevation: 8, - }, - deleteButtonText: { - color: "#fff", - fontSize: 16, - fontWeight: "700", - }, - }) diff --git a/FastNotes/app/index.tsx b/FastNotes/app/index.tsx index 2cd2b9e..4f8d5d8 100644 --- a/FastNotes/app/index.tsx +++ b/FastNotes/app/index.tsx @@ -1,11 +1,13 @@ import { useMemo, useState } from "react" -import { Appearance, FlatList, Pressable, StyleSheet, Text, View } from "react-native" +import { FlatList, Pressable, Text, View } from "react-native" import { router } from "expo-router" import { useSafeAreaInsets } from "react-native-safe-area-context" +import { Image } from "expo-image" import { useAuthContext } from "@/hooks/use-auth-context" import { useNotes } from "@/src/notes/NotesContext" import SignOutButton from '@/components/social-auth-buttons/sign-out-button' import { useAppTheme } from "@/src/theme/AppThemeProvider" +import { homeScreenStyles as styles } from "@/src/styles/app-styles" type TabKey = "my-notes" | "work-notes" @@ -109,12 +111,23 @@ export default function HomeScreen() }) } > - {item.title} - {item.content} - Created by {item.creatorLabel} - - Last changed {formatTimestamp(item.lastChangedAt)} - + + + {item.title} + + {item.content} + + Created by {item.creatorLabel} + + Last changed {formatTimestamp(item.lastChangedAt)} + + + {item.imageUrl ? ( + + + + ) : null} + )} /> @@ -130,70 +143,3 @@ export default function HomeScreen() ) } - -const styles = StyleSheet.create({ - container: { flex: 1 }, - topBar: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - paddingHorizontal: 16, - paddingBottom: 8, - borderBottomWidth: 1, - }, - screenTitle: { - fontSize: 24, - fontWeight: "700", - }, - tabBar: { - flexDirection: "row", - gap: 8, - paddingHorizontal: 16, - paddingBottom: 8, - paddingTop: 12 - }, - tabButton: { - flex: 1, - borderWidth: 1, - borderRadius: 999, - paddingVertical: 10, - alignItems: "center", - }, - tabButtonActive: { - backgroundColor: Appearance.getColorScheme() === "light" ? "#000000":"#8a8888", - }, - tabButtonText: { - fontSize: 14, - fontWeight: "600", - }, - 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 }, - noteMeta: { fontSize: 12 }, - emptyText: { - textAlign: "center", - paddingVertical: 32, - color: "#666", - }, - errorText: { - color: "#c62828", - paddingHorizontal: 16, - paddingBottom: 8, - }, - fab: - { - position: "absolute", - width: 56, height: 56, borderRadius: 28, - alignItems: "center", justifyContent: "center", - shadowColor: "#000", - shadowOpacity: 0.18, - shadowOffset: { width: 0, height: 8 }, - shadowRadius: 16, - elevation: 8, - }, - fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700"} -}) diff --git a/FastNotes/app/login.tsx b/FastNotes/app/login.tsx index 609cb56..b8c419d 100644 --- a/FastNotes/app/login.tsx +++ b/FastNotes/app/login.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' import { Link, Stack } from 'expo-router' -import { Appearance, Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { Pressable, Text, TextInput, View } from 'react-native' import { supabase } from '@/libs/supabase' import { useAppTheme } from '@/src/theme/AppThemeProvider' +import { loginScreenStyles as styles } from '@/src/styles/app-styles' export default function LoginScreen() { const [email, setEmail] = useState('') @@ -87,56 +88,3 @@ export default function LoginScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - padding: 24, - gap: 12, - }, - title: { - fontSize: 28, - fontWeight: '700', - }, - label: { - fontSize: 14, - fontWeight: '600', - }, - input: { - borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 16, - }, - errorText: { - color: '#c62828', - fontSize: 14, - }, - loginButton: { - borderRadius: 12, - paddingVertical: 14, - alignItems: 'center', - backgroundColor: Appearance.getColorScheme() === "light" ? '#000000':'#696969', - 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 b47a291..a995bff 100644 --- a/FastNotes/app/newNote.tsx +++ b/FastNotes/app/newNote.tsx @@ -1,23 +1,33 @@ -import -{ - StyleSheet, Text, View, KeyboardAvoidingView, - Platform, Pressable, TextInput, ScrollView +import { useRef, useState } from "react" +import { + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + Text, + TextInput, + View, } from "react-native" -import { router, } from "expo-router" -import { useNotes } from "@/src/notes/NotesContext" -import { useState, useRef } from "react" -import { useSafeAreaInsets } from "react-native-safe-area-context" +import { router } from "expo-router" +import { BlurView } from "expo-blur" import { useHeaderHeight } from "@react-navigation/elements" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import NoteImagePanel from "@/components/note-image-panel" +import UploadProgressBar from "@/components/upload-progress-bar" +import { useNotes } from "@/src/notes/NotesContext" +import { StagedNoteImage, validateStagedNoteImage } from "@/src/notes/image-utils" +import { newNoteScreenStyles as styles } from "@/src/styles/app-styles" +import { pickImageFromCamera, pickImageFromLibrary } from "@/src/notes/native-image-picker" import { useAppTheme } from "@/src/theme/AppThemeProvider" - - -export default function NewNoteScreen() -{ +export default function NewNoteScreen() { const { addNote, errorMessage } = useNotes() const [title, setTitle] = useState("") const [content, setContent] = useState("") + const [stagedImage, setStagedImage] = useState(null) const [isSaving, setIsSaving] = useState(false) + const [uploadProgress, setUploadProgress] = useState(null) const [localErrorMessage, setLocalErrorMessage] = useState(null) const insets = useSafeAreaInsets() const headerHeight = useHeaderHeight() @@ -25,114 +35,163 @@ export default function NewNoteScreen() const [contentHeight, setContentHeight] = useState(160) const scrollRef = useRef(null) - const onSave = async () => - { - if(!title.trim() || !content.trim()) { + const attachFromCamera = async () => { + try { + const image = await pickImageFromCamera() + + if (image) { + validateStagedNoteImage(image) + setStagedImage(image) + setLocalErrorMessage(null) + } + } catch (error) { + setLocalErrorMessage(error instanceof Error ? error.message : "The camera could not be opened.") + } + } + + const attachFromGallery = async () => { + try { + const image = await pickImageFromLibrary() + + if (image) { + validateStagedNoteImage(image) + setStagedImage(image) + setLocalErrorMessage(null) + } + } catch (error) { + setLocalErrorMessage(error instanceof Error ? error.message : "The gallery could not be opened.") + } + } + + const onSave = async () => { + if (!title.trim() || !content.trim()) { setLocalErrorMessage("Title and content are required.") return } setIsSaving(true) + setUploadProgress(null) setLocalErrorMessage(null) - const wasSaved = await addNote(title, content) + const wasSaved = await addNote(title, content, stagedImage, { + onImageUploadProgress: (progress) => { + setUploadProgress(progress.progress) + }, + }) setIsSaving(false) + setUploadProgress(null) if (wasSaved) { - router.back() + if (router.canGoBack()) { + router.back() + return + } + + router.replace("/index") } } + const saveDisabled = isSaving + const imageActionsDisabled = isSaving + const saveButtonStyle = saveDisabled ? styles.disabledButton : styles.enabledButtonShadow + return ( - - - - + + + + - {setContentHeight(e.nativeEvent.contentSize.height) - scrollRef.current?.scrollToEnd({ animated: true }) - }}/> + { + setContentHeight(e.nativeEvent.contentSize.height) + scrollRef.current?.scrollToEnd({ animated: true }) + }} + /> - {localErrorMessage ? ( - {localErrorMessage} - ) : null} + { + void attachFromCamera() + }} + onChooseFromLibrary={() => { + void attachFromGallery() + }} + onRemoveImage={() => { + setStagedImage(null) + setLocalErrorMessage(null) + }} + /> - {!localErrorMessage && errorMessage ? ( - {errorMessage} - ) : null} + {uploadProgress !== null ? : null} - - - - - {isSaving ? "Saving..." : "Save note"} - - + {localErrorMessage ? ( + {localErrorMessage} + ) : null} + + {!localErrorMessage && errorMessage ? ( + {errorMessage} + ) : null} + + + + + { + void onSave() + }} + style={[styles.saveButton, saveButtonStyle, { backgroundColor: palette.accent }]} + > + + {isSaving ? "Saving..." : "Save note"} + + + + - - + ) } - -const styles = StyleSheet.create( - { - keyboardAvoider: { flex: 1 }, - container: { flex: 1 }, - formContent: { padding: 16, gap: 12 }, - titleInput: - { - borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 22, - fontWeight: "700", - }, - contentInput: - { - borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 16, - }, - actions: { - position: "absolute", - left: 16, - right: 16, - flexDirection: "row", - gap: 12, - }, - saveButton: - { - flex: 1, - borderRadius: 12, - paddingVertical: 14, - alignItems: "center", - shadowColor: "#000", - shadowOpacity: 0.18, - shadowOffset: { width: 0, height: 8 }, - shadowRadius: 16, - elevation: 8, - }, - saveFloatingText: - { - fontSize: 16, - fontWeight: "700" - }, - errorText: { - color: "#c62828" - }, - } -) diff --git a/FastNotes/app/signup.tsx b/FastNotes/app/signup.tsx index 5b17d0a..e243dbc 100644 --- a/FastNotes/app/signup.tsx +++ b/FastNotes/app/signup.tsx @@ -1,8 +1,9 @@ import { supabase } from "@/libs/supabase" import { Link, Stack } from "expo-router" import { useState } from "react" -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' +import { Pressable, Text, TextInput, View } from 'react-native' import { useAppTheme } from "@/src/theme/AppThemeProvider" +import { signupScreenStyles as styles } from "@/src/styles/app-styles" export default function SignupScreen(){ const [email, setEmail] = useState('') @@ -115,61 +116,3 @@ export default function SignupScreen(){ ) } - - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - padding: 24, - gap: 12, - }, - title: { - fontSize: 28, - fontWeight: '700', - }, - label: { - fontSize: 14, - fontWeight: '600', - }, - input: { - borderWidth: 1, - borderRadius: 8, - padding: 12, - fontSize: 16, - }, - 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/hello-wave.tsx b/FastNotes/components/hello-wave.tsx deleted file mode 100644 index 5def547..0000000 --- a/FastNotes/components/hello-wave.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Animated from 'react-native-reanimated'; - -export function HelloWave() { - return ( - - 👋 - - ); -} diff --git a/FastNotes/components/note-image-panel.tsx b/FastNotes/components/note-image-panel.tsx new file mode 100644 index 0000000..9594ea6 --- /dev/null +++ b/FastNotes/components/note-image-panel.tsx @@ -0,0 +1,173 @@ +import { Image } from "expo-image" +import { useState } from "react" +import { Modal, Pressable, Text, useWindowDimensions, View } from "react-native" + +import { formatBytes, StagedNoteImage } from "@/src/notes/image-utils" +import { noteImagePanelStyles as styles } from "@/src/styles/app-styles" + +type Palette = { + surface: string + elevated: string + text: string + mutedText: string + border: string + accent: string + destructive: string +} + +type NoteImagePanelProps = { + canEdit: boolean + isBusy?: boolean + currentImageUrl?: string | null + currentImageMimeType?: string | null + currentImageSizeBytes?: number | null + stagedImage?: StagedNoteImage | null + helperText?: string | null + palette: Palette + primaryTextColor: string + onTakePhoto?: () => void + onChooseFromLibrary?: () => void + onRemoveImage?: () => void +} + +export default function NoteImagePanel({ + canEdit, + isBusy = false, + currentImageUrl, + currentImageMimeType, + currentImageSizeBytes, + stagedImage, + helperText, + palette, + primaryTextColor, + onTakePhoto, + onChooseFromLibrary, + onRemoveImage, +}: NoteImagePanelProps) { + const [isFullscreenOpen, setIsFullscreenOpen] = useState(false) + const { width } = useWindowDimensions() + const previewUri = stagedImage?.uri ?? currentImageUrl ?? null + const mimeType = stagedImage?.mimeType ?? currentImageMimeType ?? null + const sizeBytes = stagedImage?.fileSize ?? currentImageSizeBytes ?? null + const useStackedLayout = width < 680 + + return ( + + {previewUri ? ( + + + + {stagedImage ? "Staged image" : "Saved image"} + + + {(mimeType ?? "Unknown type").toUpperCase()} + + + {formatBytes(sizeBytes)} + + {!stagedImage && currentImageUrl ? ( + + {currentImageUrl} + + ) : null} + + { + if (previewUri) { + setIsFullscreenOpen(true) + } + }} + style={styles.previewFrame} + > + + + + ) : ( + + No image attached. + + )} + + {helperText ? {helperText} : null} + + {canEdit ? ( + + + Take photo + + + Choose from gallery + + {previewUri ? ( + + Remove image + + ) : null} + + ) : null} + + { + setIsFullscreenOpen(false) + }} + > + + { + setIsFullscreenOpen(false) + }} + /> + + { + setIsFullscreenOpen(false) + }} + style={[styles.closeButton, { borderColor: palette.border, backgroundColor: palette.elevated }]} + > + Close + + {previewUri ? ( + + ) : null} + + + + + ) +} diff --git a/FastNotes/components/parallax-scroll-view.tsx b/FastNotes/components/parallax-scroll-view.tsx index 6f674a7..c0aca68 100644 --- a/FastNotes/components/parallax-scroll-view.tsx +++ b/FastNotes/components/parallax-scroll-view.tsx @@ -1,5 +1,4 @@ import type { PropsWithChildren, ReactElement } from 'react'; -import { StyleSheet } from 'react-native'; import Animated, { interpolate, useAnimatedRef, @@ -10,6 +9,7 @@ import Animated, { import { ThemedView } from '@/components/themed-view'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { useThemeColor } from '@/hooks/use-theme-color'; +import { parallaxScrollViewStyles as styles } from '@/src/styles/app-styles'; const HEADER_HEIGHT = 250; @@ -61,19 +61,3 @@ export default function ParallaxScrollView({ ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: HEADER_HEIGHT, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}); diff --git a/FastNotes/components/social-auth-buttons/sign-out-button.tsx b/FastNotes/components/social-auth-buttons/sign-out-button.tsx index ed5f476..ab3364b 100644 --- a/FastNotes/components/social-auth-buttons/sign-out-button.tsx +++ b/FastNotes/components/social-auth-buttons/sign-out-button.tsx @@ -1,10 +1,27 @@ import { supabase } from '@/libs/supabase' import { router } from 'expo-router' +import Constants from "expo-constants" import React from 'react' -import { Pressable, StyleSheet, Text } from 'react-native' +import { Platform, Pressable, Text } from 'react-native' import { useAppTheme } from '@/src/theme/AppThemeProvider' +import { signOutButtonStyles as styles } from '@/src/styles/app-styles' async function onSignOutButtonPress() { + const { + data: { user }, + } = await supabase.auth.getUser() + + const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient" + + if (user?.id && !isAndroidExpoGo) { + const { unregisterPushNotifications } = await import('@/src/notifications/push-notifications') + const removed = await unregisterPushNotifications(user.id) + + if (!removed) { + console.error('Failed to unregister push notifications before sign out.') + } + } + const { error } = await supabase.auth.signOut() if (error) { @@ -27,16 +44,3 @@ export default function SignOutButton() { ) } - -const styles = StyleSheet.create({ - button: { - borderWidth: 1, - borderRadius: 999, - paddingHorizontal: 14, - paddingVertical: 8, - }, - text: { - fontSize: 14, - fontWeight: '600', - }, -}) diff --git a/FastNotes/components/themed-text.tsx b/FastNotes/components/themed-text.tsx index db723d3..06d969d 100644 --- a/FastNotes/components/themed-text.tsx +++ b/FastNotes/components/themed-text.tsx @@ -1,6 +1,7 @@ -import { StyleSheet, Text, type TextProps } from 'react-native' +import { Text, type TextProps } from 'react-native' import { useThemeColor } from '@/hooks/use-theme-color' +import { themedTextStyles as styles } from '@/src/styles/app-styles' export type ThemedTextProps = TextProps & { lightColor?: string @@ -32,29 +33,3 @@ export function ThemedText({ /> ) } - -const styles = StyleSheet.create({ - default: { - fontSize: 16, - lineHeight: 24, - }, - defaultSemiBold: { - fontSize: 16, - lineHeight: 24, - fontWeight: '600', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - lineHeight: 32, - }, - subtitle: { - fontSize: 20, - fontWeight: 'bold', - }, - link: { - lineHeight: 30, - fontSize: 16, - color: '#0a7ea4', - }, -}) diff --git a/FastNotes/components/ui/collapsible.tsx b/FastNotes/components/ui/collapsible.tsx index 6345fde..af91e55 100644 --- a/FastNotes/components/ui/collapsible.tsx +++ b/FastNotes/components/ui/collapsible.tsx @@ -1,11 +1,12 @@ import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; +import { TouchableOpacity } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { IconSymbol } from '@/components/ui/icon-symbol'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; +import { collapsibleStyles as styles } from '@/src/styles/app-styles'; export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { const [isOpen, setIsOpen] = useState(false); @@ -31,15 +32,3 @@ export function Collapsible({ children, title }: PropsWithChildren & { title: st ); } - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/FastNotes/components/upload-progress-bar.tsx b/FastNotes/components/upload-progress-bar.tsx new file mode 100644 index 0000000..ce4fa44 --- /dev/null +++ b/FastNotes/components/upload-progress-bar.tsx @@ -0,0 +1,36 @@ +import { Text, View } from "react-native" +import { uploadProgressBarStyles as styles } from "@/src/styles/app-styles" + +type Palette = { + accent: string + border: string + elevated: string + mutedText: string + text: string +} + +type UploadProgressBarProps = { + progress: number + label?: string + palette: Palette +} + +export default function UploadProgressBar({ + progress, + label = "Uploading image...", + palette, +}: UploadProgressBarProps) { + const clampedProgress = Math.max(0, Math.min(100, progress)) + + return ( + + + {label} + {clampedProgress}% + + + + + + ) +} diff --git a/FastNotes/libs/supabase.ts b/FastNotes/libs/supabase.ts index 4d7d7a7..6197405 100644 --- a/FastNotes/libs/supabase.ts +++ b/FastNotes/libs/supabase.ts @@ -1,25 +1,9 @@ -import '@react-native-async-storage/async-storage' +import AsyncStorage from '@react-native-async-storage/async-storage' import { createClient } from '@supabase/supabase-js' import Constants from "expo-constants" -import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store' import { Platform } from 'react-native' import 'react-native-url-polyfill/auto' -const ExpoSecureStoreAdapter = { - getItem: (key: string) => { - return getItemAsync(key) - }, - setItem: (key: string, value: string) => { - if (value.length > 2048) { - console.warn('Value being stored in SecureStore is larger than 2048 bytes and it may not be stored successfully. In a future SDK version, this call may throw an error.') - } - return setItemAsync(key, value) - }, - removeItem: (key: string) => { - return deleteItemAsync(key) - }, -} - const extra = (Constants.expoConfig?.extra ?? Constants.expoConfig?.extra) as { supabaseUrl?: string supabaseKey?: string @@ -35,7 +19,7 @@ if(!supabaseUrl || !supabaseAnonKey){ const storage = ( Platform.OS === "web" ? window.localStorage - : ExpoSecureStoreAdapter + : AsyncStorage ) export const supabase = createClient(supabaseUrl, supabaseAnonKey, { @@ -47,5 +31,3 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, { }, } ) - - diff --git a/FastNotes/package-lock.json b/FastNotes/package-lock.json index b6ea872..a91c92f 100644 --- a/FastNotes/package-lock.json +++ b/FastNotes/package-lock.json @@ -16,11 +16,15 @@ "@supabase/supabase-js": "^2.98.0", "async-storage": "^0.1.0", "expo": "~54.0.33", + "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-manipulator": "^55.0.10", + "expo-image-picker": "^55.0.12", "expo-linking": "~8.0.11", + "expo-notifications": "^55.0.12", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", @@ -2128,9 +2132,9 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz", - "integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.12.tgz", + "integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.7.2", @@ -2139,10 +2143,7 @@ "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", - "resolve-global": "^1.0.0", - "semver": "^7.6.0", - "temp-dir": "~2.0.0", - "unique-string": "~2.0.0" + "semver": "^7.6.0" } }, "node_modules/@expo/image-utils/node_modules/semver": { @@ -2158,24 +2159,15 @@ } }, "node_modules/@expo/json-file": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz", - "integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==", + "version": "10.0.12", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.12.tgz", + "integrity": "sha512-inbDycp1rMAelAofg7h/mMzIe+Owx6F7pur3XdQ3EPTy00tme+4P6FWgHKUcjN8dBSrnbRNpSyh5/shzHyVCyQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", + "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, - "node_modules/@expo/json-file/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, "node_modules/@expo/metro": { "version": "54.2.0", "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", @@ -2365,6 +2357,25 @@ "node": ">=10" } }, + "node_modules/@expo/require-utils": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.2.tgz", + "integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@expo/schema-utils": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", @@ -4814,6 +4825,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5462,15 +5479,6 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", - "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/css-in-js-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", @@ -6489,6 +6497,15 @@ } } }, + "node_modules/expo-application": { + "version": "55.0.9", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-55.0.9.tgz", + "integrity": "sha512-jXTaLKdW4cvGSUjF2UQed9ao4P/7TsEo/To7TjxM+jNa74xCSUCBSTxdQftm6hZWRzXG8KT7rSoQDEL51neh1w==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "12.0.12", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", @@ -6504,6 +6521,17 @@ "react-native": "*" } }, + "node_modules/expo-blur": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz", + "integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -6568,6 +6596,39 @@ } } }, + "node_modules/expo-image-loader": { + "version": "55.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-55.0.0.tgz", + "integrity": "sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-manipulator": { + "version": "55.0.10", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-55.0.10.tgz", + "integrity": "sha512-eEiHSznWa0i5I7iNFDRuHz663XiS26s8SEFigGbsvkFDibGI9x391Qb76DPSGtnqNkJa39etuFw42lbErHphHA==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~55.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "55.0.12", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.12.tgz", + "integrity": "sha512-ky8nzXTd5eLUDct5daAHng0xrWYRJyXfLCRmEdE9v/IUywYCnFU7aCnQ7PTQJvzGSzhePJJmP/POvTkVP//+qQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~55.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -6621,6 +6682,121 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "55.0.12", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-55.0.12.tgz", + "integrity": "sha512-AUAH1ipq7yChZqwp9P/gfmXNoaleKWvEhnIB6/dhtWtTnZZ5VDHdxqzQIbTemYQyIK6kpUc4JZpR9eU3d59K3g==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.12", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~55.0.9", + "expo-constants": "~55.0.7" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-notifications/node_modules/@expo/config": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.8.tgz", + "integrity": "sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~55.0.6", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.12", + "@expo/require-utils": "^55.0.2", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/expo-notifications/node_modules/@expo/config-plugins": { + "version": "55.0.6", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.6.tgz", + "integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.12", + "@expo/plist": "^0.5.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/expo-notifications/node_modules/@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "license": "MIT" + }, + "node_modules/expo-notifications/node_modules/@expo/env": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz", + "integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/expo-notifications/node_modules/@expo/plist": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz", + "integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/expo-notifications/node_modules/expo-constants": { + "version": "55.0.7", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz", + "integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~55.0.8", + "@expo/env": "~2.1.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-notifications/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-router": { "version": "6.0.23", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", @@ -7475,18 +7651,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/global-dirs": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", - "license": "MIT", - "dependencies": { - "ini": "^1.3.4" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -11140,18 +11304,6 @@ "node": ">=8" } }, - "node_modules/resolve-global": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", - "license": "MIT", - "dependencies": { - "global-dirs": "^0.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -12102,15 +12254,6 @@ "node": ">=18" } }, - "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -12455,7 +12598,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12565,18 +12708,6 @@ "node": ">=4" } }, - "node_modules/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/FastNotes/package.json b/FastNotes/package.json index ca309ac..fbbf4a3 100644 --- a/FastNotes/package.json +++ b/FastNotes/package.json @@ -19,11 +19,15 @@ "@supabase/supabase-js": "^2.98.0", "async-storage": "^0.1.0", "expo": "~54.0.33", + "expo-blur": "~15.0.8", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image": "~3.0.11", + "expo-image-manipulator": "^55.0.10", + "expo-image-picker": "^55.0.12", "expo-linking": "~8.0.11", + "expo-notifications": "^55.0.12", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", diff --git a/FastNotes/providers/auth-provider.tsx b/FastNotes/providers/auth-provider.tsx index 9df0df8..3abc1a0 100644 --- a/FastNotes/providers/auth-provider.tsx +++ b/FastNotes/providers/auth-provider.tsx @@ -1,5 +1,5 @@ import { AuthContext } from '@/hooks/use-auth-context' -import { hasSecureRefreshToken, supabase } from '@/libs/supabase' +import { supabase } from '@/libs/supabase' import { PropsWithChildren, useEffect, useState } from 'react' const buildClaims = (user?: { id: string; email?: string | null } | null) => @@ -26,10 +26,6 @@ export default function AuthProvider({ children }: PropsWithChildren) { console.error('Error hydrating session:', error) } - if (!session && await hasSecureRefreshToken()) { - console.warn('Found an encrypted Supabase refresh token backup, but fallback session recovery is not implemented.') - } - setClaims(buildClaims(session?.user)) setIsLoading(false) } diff --git a/FastNotes/src/notes/NotesContext.tsx b/FastNotes/src/notes/NotesContext.tsx index 39eded4..29a40a3 100644 --- a/FastNotes/src/notes/NotesContext.tsx +++ b/FastNotes/src/notes/NotesContext.tsx @@ -1,6 +1,9 @@ -import React, { createContext, useContext, useEffect, useState } from "react" +import React, { createContext, useCallback, useContext, useEffect, useState } from "react" + import { useAuthContext } from "@/hooks/use-auth-context" import { supabase } from "@/libs/supabase" +import { deleteNoteImage, NoteImageUploadProgress, uploadNoteImage } from "@/src/notes/note-image-storage" +import { StagedNoteImage } from "@/src/notes/image-utils" type NoteRow = { id: number @@ -9,6 +12,10 @@ type NoteRow = { content: string created_at: string updated_at?: string | null + image_url?: string | null + image_path?: string | null + image_mime_type?: string | null + image_size_bytes?: number | null } type ProfileRow = { @@ -26,20 +33,53 @@ export type Note = { title: string content: string creatorLabel: string + imageUrl: string | null + imagePath: string | null + imageMimeType: string | null + imageSizeBytes: number | null } +export type NoteImageChange = + | { type: "keep" } + | { type: "remove" } + | { type: "replace"; image: StagedNoteImage } + type NotesContextValue = { notes: Note[] isLoading: boolean errorMessage: string | null refreshNotes: () => Promise - addNote: (title: string, content: string) => Promise - updateNote: (noteId: string, title: string, content: string) => Promise + addNote: ( + title: string, + content: string, + image?: StagedNoteImage | null, + options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void } + ) => Promise + updateNote: ( + noteId: string, + title: string, + content: string, + imageChange?: NoteImageChange, + options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void } + ) => Promise deleteNote: (noteId: string) => Promise } const NotesContext = createContext(undefined) +function normalizeImageSizeBytes(value: number | string | null | undefined) { + if (typeof value === "number") { + return value + } + + if (typeof value === "string") { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : null + } + + return null +} + export function NotesProvider({ children }: { children: React.ReactNode }) { const { claims, isLoggedIn, profile } = useAuthContext() const [notes, setNotes] = useState([]) @@ -81,19 +121,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { }, {}) } - const mapNote = (row: NoteRow, labels: Record): Note => ({ - id: String(row.id), - createdBy: row.created_by, - createdAt: row.created_at, - lastChangedAt: row.updated_at || row.created_at, - title: row.title, - content: row.content, - creatorLabel: - labels[row.created_by] || - (row.created_by === userId ? creatorLabel : "Unknown user"), - }) - - const loadNotes = async () => { + const loadNotes = useCallback(async () => { if (!isLoggedIn) { setNotes([]) setErrorMessage(null) @@ -106,7 +134,9 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const { data, error } = await supabase .from("Notes") - .select("id, created_by, title, content, created_at, updated_at") + .select( + "id, created_by, title, content, created_at, updated_at, image_url, image_path, image_mime_type, image_size_bytes" + ) .order("updated_at", { ascending: false, nullsFirst: false }) .order("created_at", { ascending: false }) @@ -118,12 +148,27 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { } const rows = (data ?? []) as NoteRow[] - // Resolve public creator labels separately so note reads do not depend on a view schema. const labels = await buildCreatorLabels(rows) - setNotes(rows.map((row) => mapNote(row, labels))) + setNotes( + rows.map((row) => ({ + id: String(row.id), + createdBy: row.created_by, + createdAt: row.created_at, + lastChangedAt: row.updated_at || row.created_at, + title: row.title, + content: row.content, + creatorLabel: + labels[row.created_by] || + (row.created_by === userId ? creatorLabel : "Unknown user"), + imageUrl: row.image_url ?? null, + imagePath: row.image_path ?? null, + imageMimeType: row.image_mime_type ?? null, + imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes), + })) + ) setIsLoading(false) - } + }, [creatorLabel, isLoggedIn, userId]) const refreshNotes = async () => { await loadNotes() @@ -138,14 +183,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { } void loadNotes() - }, [creatorLabel, isLoggedIn, userId]) + }, [creatorLabel, isLoggedIn, loadNotes, userId]) useEffect(() => { if (!isLoggedIn || !userId) { return } - // Poll for remote changes so other users' edits appear without a manual refresh. const intervalId = setInterval(() => { void loadNotes() }, 30000) @@ -153,9 +197,14 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { return () => { clearInterval(intervalId) } - }, [creatorLabel, isLoggedIn, userId]) + }, [creatorLabel, isLoggedIn, loadNotes, userId]) - const addNote = async (title: string, content: string) => { + const addNote = async ( + title: string, + content: string, + image?: StagedNoteImage | null, + options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void } + ) => { const trimmedTitle = title.trim() const trimmedContent = content.trim() @@ -170,16 +219,47 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { } setErrorMessage(null) - - const { error } = await supabase - .from("Notes") - .insert({ - title: trimmedTitle, - content: trimmedContent, - }) - if (error) { - setErrorMessage(error.message) + let uploadedImage: + | { + path: string + publicUrl: string + mimeType: string + sizeBytes: number + } + | null = null + + try { + if (image) { + uploadedImage = await uploadNoteImage(userId, image, { + onProgress: options?.onImageUploadProgress, + }) + } + + const { error } = await supabase + .from("Notes") + .insert({ + title: trimmedTitle, + content: trimmedContent, + image_url: uploadedImage?.publicUrl ?? null, + image_path: uploadedImage?.path ?? null, + image_mime_type: uploadedImage?.mimeType ?? null, + image_size_bytes: uploadedImage?.sizeBytes ?? null, + }) + + if (error) { + throw new Error(error.message) + } + } catch (error) { + if (uploadedImage?.path) { + try { + await deleteNoteImage(uploadedImage.path) + } catch (cleanupError) { + console.error("Failed to roll back uploaded note image:", cleanupError) + } + } + + setErrorMessage(error instanceof Error ? error.message : "Failed to save note.") return false } @@ -187,7 +267,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { return true } - const updateNote = async (noteId: string, title: string, content: string) => { + const updateNote = async ( + noteId: string, + title: string, + content: string, + imageChange: NoteImageChange = { type: "keep" }, + options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void } + ) => { const trimmedTitle = title.trim() const trimmedContent = content.trim() @@ -201,44 +287,109 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { return false } + const existingNote = notes.find((note) => note.id === noteId) + + if (!existingNote) { + setErrorMessage("This note could not be found.") + return false + } + setErrorMessage(null) - const { data, error } = await supabase - .from("Notes") - .update({ - title: trimmedTitle, - content: trimmedContent, - updated_at: new Date().toISOString(), - }) - .eq("id", Number(noteId)) - .eq("created_by", userId) - .select("id, title, content, updated_at") - .maybeSingle() + let uploadedImage: + | { + path: string + publicUrl: string + mimeType: string + sizeBytes: number + } + | null = null - if (error) { - setErrorMessage(error.message) - return false + const updates: Record = { + title: trimmedTitle, + content: trimmedContent, + updated_at: new Date().toISOString(), } - if (!data) { - setErrorMessage("Update failed. You can only edit notes that you created.") - return false - } + try { + if (imageChange.type === "replace") { + uploadedImage = await uploadNoteImage(userId, imageChange.image, { + onProgress: options?.onImageUploadProgress, + }) + updates.image_url = uploadedImage.publicUrl + updates.image_path = uploadedImage.path + updates.image_mime_type = uploadedImage.mimeType + updates.image_size_bytes = uploadedImage.sizeBytes + } else if (imageChange.type === "remove") { + updates.image_url = null + updates.image_path = null + updates.image_mime_type = null + updates.image_size_bytes = null + } - // Update the edited note locally so the new timestamp is visible immediately. - setNotes((prev) => - prev.map((note) => - note.id === noteId - ? { - ...note, - title: data.title ?? trimmedTitle, - content: data.content ?? trimmedContent, - lastChangedAt: data.updated_at ?? new Date().toISOString(), - } - : note + const { data, error } = await supabase + .from("Notes") + .update(updates) + .eq("id", Number(noteId)) + .eq("created_by", userId) + .select( + "id, title, content, updated_at, image_url, image_path, image_mime_type, image_size_bytes" + ) + .maybeSingle() + + if (error) { + throw new Error(error.message) + } + + if (!data) { + throw new Error("Update failed. You can only edit notes that you created.") + } + + if (imageChange.type === "replace" && existingNote.imagePath) { + try { + await deleteNoteImage(existingNote.imagePath) + } catch (cleanupError) { + console.error("Failed to remove replaced note image:", cleanupError) + } + } + + if (imageChange.type === "remove" && existingNote.imagePath) { + try { + await deleteNoteImage(existingNote.imagePath) + } catch (cleanupError) { + console.error("Failed to remove deleted note image:", cleanupError) + } + } + + setNotes((prev) => + prev.map((note) => + note.id === noteId + ? { + ...note, + title: data.title ?? trimmedTitle, + content: data.content ?? trimmedContent, + lastChangedAt: data.updated_at ?? updates.updated_at ?? new Date().toISOString(), + imageUrl: data.image_url ?? null, + imagePath: data.image_path ?? null, + imageMimeType: data.image_mime_type ?? null, + imageSizeBytes: normalizeImageSizeBytes(data.image_size_bytes), + } + : note + ) ) - ) - return true + return true + } catch (error) { + if (uploadedImage?.path) { + try { + await deleteNoteImage(uploadedImage.path) + } catch (cleanupError) { + console.error("Failed to roll back uploaded replacement image:", cleanupError) + } + } + + setErrorMessage(error instanceof Error ? error.message : "Failed to update note.") + return false + } } const deleteNote = async (noteId: string) => { @@ -247,6 +398,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { return false } + const existingNote = notes.find((note) => note.id === noteId) + setErrorMessage(null) const { error } = await supabase @@ -260,6 +413,15 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { return false } + if (existingNote?.imagePath) { + try { + await deleteNoteImage(existingNote.imagePath) + } catch (cleanupError) { + console.error("Failed to remove note image during deletion:", cleanupError) + setErrorMessage("The note was deleted, but its stored image could not be cleaned up.") + } + } + setNotes((prev) => prev.filter((note) => note.id !== noteId)) return true } diff --git a/FastNotes/src/notes/image-utils.ts b/FastNotes/src/notes/image-utils.ts new file mode 100644 index 0000000..8f72d11 --- /dev/null +++ b/FastNotes/src/notes/image-utils.ts @@ -0,0 +1,199 @@ +import * as ImageManipulator from "expo-image-manipulator" + +export const NOTE_IMAGE_BUCKET = "note-images" +export const MAX_NOTE_IMAGE_BYTES = 15 * 1024 * 1024 +export const SUPPORTED_NOTE_IMAGE_FORMATS = "PNG, JPG/JPEG, WEBP" + +const JPEG_MIME = "image/jpeg" +const PNG_MIME = "image/png" +const WEBP_MIME = "image/webp" + +export type SupportedMimeType = + | typeof JPEG_MIME + | typeof PNG_MIME + | typeof WEBP_MIME + +export type StagedNoteImage = { + uri: string + fileName: string + mimeType: string | null + fileSize: number | null + width?: number | null + height?: number | null +} + +export type UploadedNoteImage = { + path: string + publicUrl: string + mimeType: SupportedMimeType + sizeBytes: number +} + +export type PreparedNoteImage = { + uri: string + mimeType: SupportedMimeType + sizeBytes: number +} + +function normalizeFileExtension(fileName: string | null | undefined) { + const extension = fileName?.split(".").pop()?.toLowerCase() + + if (!extension) { + return null + } + + if (extension === "jpg") { + return "jpeg" + } + + return extension +} + +export function normalizeMimeType( + mimeType: string | null | undefined, + fileName?: string | null +): SupportedMimeType | null { + if (mimeType === JPEG_MIME || mimeType === PNG_MIME || mimeType === WEBP_MIME) { + return mimeType + } + + if (mimeType === "image/jpg") { + return JPEG_MIME + } + + const extension = normalizeFileExtension(fileName) + + if (extension === "jpeg") { + return JPEG_MIME + } + + if (extension === "png") { + return PNG_MIME + } + + if (extension === "webp") { + return WEBP_MIME + } + + return null +} + +function getFileExtension(mimeType: SupportedMimeType) { + if (mimeType === PNG_MIME) { + return "png" + } + + if (mimeType === WEBP_MIME) { + return "webp" + } + + return "jpg" +} + +function getSaveFormat(mimeType: SupportedMimeType) { + if (mimeType === PNG_MIME) { + return ImageManipulator.SaveFormat.PNG + } + + if (mimeType === WEBP_MIME) { + return ImageManipulator.SaveFormat.WEBP + } + + return ImageManipulator.SaveFormat.JPEG +} + +export function formatBytes(bytes: number | null | undefined) { + if (!bytes || Number.isNaN(bytes) || bytes <= 0) { + return "Unknown size" + } + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB` + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export function validateStagedNoteImage(image: StagedNoteImage) { + const normalizedMimeType = normalizeMimeType(image.mimeType, image.fileName) + + if (!normalizedMimeType) { + throw new Error(`Unsupported image format. Allowed formats: ${SUPPORTED_NOTE_IMAGE_FORMATS}.`) + } + + return normalizedMimeType +} + +async function getFileSize(uri: string) { + const response = await fetch(uri) + const blob = await response.blob() + return blob.size +} + +export async function readUriAsArrayBuffer(uri: string) { + const response = await fetch(uri) + + if (!response.ok) { + throw new Error("The selected image could not be read.") + } + + return response.arrayBuffer() +} + +function buildActions(width: number, height: number) { + const largestSide = Math.max(width, height) + + if (largestSide <= 1600) { + return [] + } + + const scale = 1600 / largestSide + + return [ + { + resize: { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }, + }, + ] +} + +export async function prepareNoteImage(image: StagedNoteImage): Promise { + const normalizedMimeType = validateStagedNoteImage(image) + const targetMimeType = normalizedMimeType === PNG_MIME ? PNG_MIME : JPEG_MIME + const saveFormat = getSaveFormat(targetMimeType) + const actions = + image.width && image.height + ? buildActions(image.width, image.height) + : [] + + const compressions = targetMimeType === PNG_MIME ? [1] : [0.82, 0.7, 0.56, 0.42, 0.32] + + for (const compression of compressions) { + const result = await ImageManipulator.manipulateAsync(image.uri, actions, { + compress: compression, + format: saveFormat, + }) + const sizeBytes = await getFileSize(result.uri) + + if (sizeBytes <= MAX_NOTE_IMAGE_BYTES) { + return { + uri: result.uri, + mimeType: targetMimeType, + sizeBytes, + } + } + } + + throw new Error("Image too large. The selected image is still larger than 15 MB after compression.") +} + +export function createStoragePath(userId: string, mimeType: SupportedMimeType) { + const id = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}` + + return `${userId}/${id}.${getFileExtension(mimeType)}` +} diff --git a/FastNotes/src/notes/native-image-picker.ts b/FastNotes/src/notes/native-image-picker.ts new file mode 100644 index 0000000..bdcf574 --- /dev/null +++ b/FastNotes/src/notes/native-image-picker.ts @@ -0,0 +1,79 @@ +import * as ImagePicker from "expo-image-picker" +import { Platform } from "react-native" + +import { StagedNoteImage } from "@/src/notes/image-utils" + +function buildStagedImage(asset: ImagePicker.ImagePickerAsset): StagedNoteImage { + return { + uri: asset.uri, + fileName: asset.fileName ?? `note-image-${Date.now()}.jpg`, + mimeType: asset.mimeType ?? null, + fileSize: asset.fileSize ?? null, + width: asset.width, + height: asset.height, + } +} + +function unsupportedPlatformError(action: "camera" | "gallery") { + return Platform.OS === "web" + ? `Native ${action} support is only enabled on iOS and Android in this app.` + : `This device cannot open the ${action}.` +} + +export async function pickImageFromLibrary() { + if (Platform.OS === "web") { + throw new Error(unsupportedPlatformError("gallery")) + } + + const permission = await ImagePicker.getMediaLibraryPermissionsAsync() + + if (!permission.granted) { + const requested = await ImagePicker.requestMediaLibraryPermissionsAsync() + + if (!requested.granted) { + throw new Error("Photo library access is required to choose an image.") + } + } + + const result = await ImagePicker.launchImageLibraryAsync({ + allowsEditing: false, + mediaTypes: ["images"], + quality: 1, + selectionLimit: 1, + }) + + if (result.canceled || !result.assets?.length) { + return null + } + + return buildStagedImage(result.assets[0]) +} + +export async function pickImageFromCamera() { + if (Platform.OS === "web") { + throw new Error(unsupportedPlatformError("camera")) + } + + const permission = await ImagePicker.getCameraPermissionsAsync() + + if (!permission.granted) { + const requested = await ImagePicker.requestCameraPermissionsAsync() + + if (!requested.granted) { + throw new Error("Camera access is required to take a photo.") + } + } + + const result = await ImagePicker.launchCameraAsync({ + allowsEditing: false, + cameraType: ImagePicker.CameraType.back, + mediaTypes: ["images"], + quality: 1, + }) + + if (result.canceled || !result.assets?.length) { + return null + } + + return buildStagedImage(result.assets[0]) +} diff --git a/FastNotes/src/notes/note-image-storage.ts b/FastNotes/src/notes/note-image-storage.ts new file mode 100644 index 0000000..3fbcc70 --- /dev/null +++ b/FastNotes/src/notes/note-image-storage.ts @@ -0,0 +1,153 @@ +import { supabase, supabaseAnonKey, supabaseUrl } from "@/libs/supabase" +import { + createStoragePath, + NOTE_IMAGE_BUCKET, + prepareNoteImage, + StagedNoteImage, + UploadedNoteImage, +} from "@/src/notes/image-utils" + +export type NoteImageUploadProgress = { + loaded: number + total: number + progress: number +} + +type UploadNoteImageOptions = { + onProgress?: (progress: NoteImageUploadProgress) => void +} + +function normalizeUploadFailureMessage(error: unknown) { + if (error instanceof Error) { + if ( + error.message.startsWith("Unsupported image format.") || + error.message.startsWith("Image too large.") + ) { + return error.message + } + + if (error.message.trim()) { + return `Image upload failed. ${error.message}` + } + } + + return "Image upload failed. Check your connection and try again." +} + +async function uploadWithProgress( + path: string, + fileUri: string, + mimeType: string, + onProgress?: (progress: NoteImageUploadProgress) => void +) { + if (!supabaseUrl || !supabaseAnonKey) { + throw new Error("Image upload failed. Storage configuration is missing.") + } + + const anonKey = supabaseAnonKey + const storageBaseUrl = supabaseUrl + const session = await supabase.auth.getSession() + const accessToken = session.data.session?.access_token + const uploadUrl = `${storageBaseUrl}/storage/v1/object/${NOTE_IMAGE_BUCKET}/${path}` + + const response = await fetch(fileUri) + + if (!response.ok) { + throw new Error("The selected image could not be read.") + } + + const fileBlob = await response.blob() + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + + xhr.open("POST", uploadUrl) + xhr.setRequestHeader("apikey", anonKey) + xhr.setRequestHeader("Authorization", `Bearer ${accessToken ?? anonKey}`) + xhr.setRequestHeader("x-upsert", "false") + xhr.setRequestHeader("cache-control", "3600") + xhr.setRequestHeader("content-type", mimeType) + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable || !event.total) { + return + } + + onProgress?.({ + loaded: event.loaded, + total: event.total, + progress: Math.min(100, Math.round((event.loaded / event.total) * 100)), + }) + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress?.({ + loaded: fileBlob.size, + total: fileBlob.size, + progress: 100, + }) + resolve() + return + } + + try { + const parsed = JSON.parse(xhr.responseText) as { message?: string; error?: string } + reject(new Error(parsed.message || parsed.error || "Upload failed.")) + } catch { + reject(new Error("Upload failed.")) + } + } + + xhr.onerror = () => { + reject(new Error("Check your connection and try again.")) + } + + xhr.onabort = () => { + reject(new Error("Upload cancelled.")) + } + + onProgress?.({ + loaded: 0, + total: fileBlob.size, + progress: 0, + }) + xhr.send(fileBlob) + }) +} + +export async function uploadNoteImage( + userId: string, + image: StagedNoteImage, + options: UploadNoteImageOptions = {} +): Promise { + const preparedImage = await prepareNoteImage(image) + const path = createStoragePath(userId, preparedImage.mimeType) + + try { + await uploadWithProgress(path, preparedImage.uri, preparedImage.mimeType, options.onProgress) + } catch (error) { + throw new Error(normalizeUploadFailureMessage(error)) + } + + const { data } = supabase.storage + .from(NOTE_IMAGE_BUCKET) + .getPublicUrl(path) + + return { + path, + publicUrl: data.publicUrl, + mimeType: preparedImage.mimeType, + sizeBytes: preparedImage.sizeBytes, + } +} + +export async function deleteNoteImage(path: string) { + const { error } = await supabase.storage + .from(NOTE_IMAGE_BUCKET) + .remove([path]) + + if (error) { + throw new Error(error.message) + } +} diff --git a/FastNotes/src/notifications/PushNotificationsProvider.tsx b/FastNotes/src/notifications/PushNotificationsProvider.tsx new file mode 100644 index 0000000..409b529 --- /dev/null +++ b/FastNotes/src/notifications/PushNotificationsProvider.tsx @@ -0,0 +1,105 @@ +import { PropsWithChildren, useEffect } from "react" +import * as Notifications from "expo-notifications" + +import { useAuthContext } from "@/hooks/use-auth-context" +import { supabase } from "@/libs/supabase" +import { registerPushNotifications } from "@/src/notifications/push-notifications" + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}) + +async function loadCreatorEmail(userId: string) { + const { data, error } = await supabase + .from("profiles") + .select("email") + .eq("id", userId) + .maybeSingle() + + if (error) { + console.error("Failed to load note creator email:", error.message) + return "unknown user" + } + + return typeof data?.email === "string" && data.email.trim() ? data.email : "unknown user" +} + +export default function PushNotificationsProvider({ children }: PropsWithChildren) { + const { claims, isLoading, isLoggedIn } = useAuthContext() + + useEffect(() => { + if (isLoading || !isLoggedIn || !claims?.sub) { + return + } + + let isCancelled = false + let teardownRealtimeFallback: (() => void) | undefined + + const register = async () => { + const result = await registerPushNotifications(claims.sub as string) + + if (isCancelled || result.status === "registered" || result.status === "denied" || result.status === "unsupported") { + return + } + + if (result.status === "local-only" || result.status === "missing-project-id" || result.status === "error") { + const channel = supabase + .channel(`notes-local-notifications-${claims.sub}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "Notes", + }, + async (payload) => { + const note = payload.new as { id?: string | number; title?: string; created_by?: string } + + if (!note?.created_by || note.created_by === claims.sub) { + return + } + + const creatorEmail = await loadCreatorEmail(note.created_by) + + void Notifications.scheduleNotificationAsync({ + content: { + title: "FastNotes", + body: `New note: "${note.title ?? "Untitled"}" by ${creatorEmail}`, + data: { + type: "new-note", + noteId: note.id ? String(note.id) : "", + title: note.title ?? "Untitled", + }, + }, + trigger: null, + }) + } + ) + .subscribe() + + teardownRealtimeFallback = () => { + void supabase.removeChannel(channel) + } + } + + if (result.status === "missing-project-id" || result.status === "error") { + console.error("Push notification setup failed:", result.message) + } + } + + void register() + + return () => { + isCancelled = true + teardownRealtimeFallback?.() + } + }, [claims?.sub, isLoading, isLoggedIn]) + + return children +} diff --git a/FastNotes/src/notifications/push-notifications.ts b/FastNotes/src/notifications/push-notifications.ts new file mode 100644 index 0000000..d88f942 --- /dev/null +++ b/FastNotes/src/notifications/push-notifications.ts @@ -0,0 +1,134 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" +import Constants from "expo-constants" +import * as Notifications from "expo-notifications" +import { Platform } from "react-native" + +import { supabase } from "@/libs/supabase" + +const INSTALLATION_ID_STORAGE_KEY = "fastnotes.push.installation-id" +const PUSH_TOKEN_TABLE = "user_push_tokens" + +type PushRegistrationResult = + | { status: "unsupported" | "denied" | "registered" | "local-only" } + | { status: "missing-project-id" | "error"; message: string } + +function getEasProjectId() { + return ( + Constants.easConfig?.projectId ?? + Constants.expoConfig?.extra?.easProjectId ?? + Constants.expoConfig?.extra?.eas?.projectId ?? + null + ) +} + +async function getInstallationId() { + const existingInstallationId = await AsyncStorage.getItem(INSTALLATION_ID_STORAGE_KEY) + + if (existingInstallationId) { + return existingInstallationId + } + + const nextInstallationId = `${Date.now()}-${Math.random().toString(36).slice(2, 12)}` + await AsyncStorage.setItem(INSTALLATION_ID_STORAGE_KEY, nextInstallationId) + return nextInstallationId +} + +async function ensureAndroidChannel() { + if (Platform.OS !== "android") { + return + } + + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#1f6feb", + }) +} + +function isPhysicalDevice() { + return Constants.isDevice ?? false +} + +export async function registerPushNotifications(userId: string): Promise { + if (Platform.OS === "web") { + return { status: "unsupported" } + } + + try { + await ensureAndroidChannel() + + const existingPermissions = await Notifications.getPermissionsAsync() + let finalStatus = existingPermissions.status + + if (finalStatus !== "granted") { + const requestedPermissions = await Notifications.requestPermissionsAsync() + finalStatus = requestedPermissions.status + } + + if (finalStatus !== "granted") { + return { status: "denied" } + } + + if (!isPhysicalDevice()) { + return { status: "local-only" } + } + + const projectId = getEasProjectId() + + if (!projectId) { + return { + status: "missing-project-id", + message: "Missing Expo EAS project ID. Set EXPO_PUBLIC_EAS_PROJECT_ID before building the app.", + } + } + + const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId }) + const installationId = await getInstallationId() + + const { error } = await supabase.from(PUSH_TOKEN_TABLE).upsert( + { + installation_id: installationId, + user_id: userId, + push_token: expoPushToken.data, + platform: Platform.OS, + is_active: true, + updated_at: new Date().toISOString(), + }, + { + onConflict: "installation_id", + } + ) + + if (error) { + return { status: "error", message: error.message } + } + + return { status: "registered" } + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : "Push notification registration failed.", + } + } +} + +export async function unregisterPushNotifications(userId?: string) { + if (Platform.OS === "web") { + return true + } + + try { + const installationId = await getInstallationId() + let query = supabase.from(PUSH_TOKEN_TABLE).delete().eq("installation_id", installationId) + + if (userId) { + query = query.eq("user_id", userId) + } + + const { error } = await query + return !error + } catch { + return false + } +} diff --git a/FastNotes/src/styles/app-styles.ts b/FastNotes/src/styles/app-styles.ts new file mode 100644 index 0000000..429271c --- /dev/null +++ b/FastNotes/src/styles/app-styles.ts @@ -0,0 +1,568 @@ +import { Appearance, StyleSheet } from "react-native" + +export const uploadProgressBarStyles = StyleSheet.create({ + container: { + gap: 8, + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + label: { + fontSize: 13, + fontWeight: "600", + }, + percentage: { + fontSize: 13, + fontWeight: "600", + }, + track: { + height: 10, + borderWidth: 1, + borderRadius: 999, + overflow: "hidden", + }, + fill: { + height: "100%", + borderRadius: 999, + }, +}) + +export const parallaxScrollViewStyles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + height: 250, + overflow: "hidden", + }, + content: { + flex: 1, + padding: 32, + gap: 16, + overflow: "hidden", + }, +}) + +export const themedTextStyles = StyleSheet.create({ + default: { + fontSize: 16, + lineHeight: 24, + }, + defaultSemiBold: { + fontSize: 16, + lineHeight: 24, + fontWeight: "600", + }, + title: { + fontSize: 32, + fontWeight: "bold", + lineHeight: 32, + }, + subtitle: { + fontSize: 20, + fontWeight: "bold", + }, + link: { + lineHeight: 30, + fontSize: 16, + color: "#0a7ea4", + }, +}) + +export const loginScreenStyles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + padding: 24, + gap: 12, + }, + title: { + fontSize: 28, + fontWeight: "700", + }, + label: { + fontSize: 14, + fontWeight: "600", + }, + input: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + errorText: { + color: "#c62828", + fontSize: 14, + }, + loginButton: { + borderRadius: 12, + paddingVertical: 14, + alignItems: "center", + backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#696969", + 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, + }, +}) + +export const signupScreenStyles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + padding: 24, + gap: 12, + }, + title: { + fontSize: 28, + fontWeight: "700", + }, + label: { + fontSize: 14, + fontWeight: "600", + }, + input: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + 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, + }, +}) + +export const signOutButtonStyles = StyleSheet.create({ + button: { + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 14, + paddingVertical: 8, + }, + text: { + fontSize: 14, + fontWeight: "600", + }, +}) + +export const collapsibleStyles = StyleSheet.create({ + heading: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + content: { + marginTop: 6, + marginLeft: 24, + }, +}) + +export const homeScreenStyles = StyleSheet.create({ + container: { flex: 1 }, + topBar: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 16, + paddingBottom: 8, + borderBottomWidth: 1, + }, + screenTitle: { + fontSize: 24, + fontWeight: "700", + }, + tabBar: { + flexDirection: "row", + gap: 8, + paddingHorizontal: 16, + paddingBottom: 8, + paddingTop: 12, + }, + tabButton: { + flex: 1, + borderWidth: 1, + borderRadius: 999, + paddingVertical: 10, + alignItems: "center", + }, + tabButtonActive: { + backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#8a8888", + }, + tabButtonText: { + fontSize: 14, + fontWeight: "600", + }, + tabButtonTextActive: { + color: "#fff", + }, + list: { padding: 16, gap: 12, paddingTop: 8 }, + noteItem: { padding: 16, borderWidth: 1, borderRadius: 12, gap: 8 }, + noteCardRow: { + flexDirection: "row", + alignItems: "stretch", + gap: 12, + }, + noteBody: { + flex: 1, + gap: 8, + }, + noteTitle: { fontSize: 16, fontWeight: "600" }, + noteThumbnailFrame: { + width: 110, + height: 110, + alignItems: "center", + justifyContent: "center", + }, + noteThumbnail: { + width: "100%", + height: "100%", + }, + notePreview: { fontSize: 14 }, + noteMeta: { fontSize: 12 }, + emptyText: { + textAlign: "center", + paddingVertical: 32, + color: "#666", + }, + errorText: { + color: "#c62828", + paddingHorizontal: 16, + paddingBottom: 8, + }, + fab: { + position: "absolute", + width: 56, + height: 56, + borderRadius: 28, + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", + shadowOpacity: 0.18, + shadowOffset: { width: 0, height: 8 }, + shadowRadius: 16, + elevation: 8, + }, + fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700" }, +}) + +export const noteImagePanelStyles = StyleSheet.create({ + section: { + gap: 12, + }, + previewCard: { + borderWidth: 1, + borderRadius: 12, + padding: 12, + }, + previewLayout: { + flexDirection: "row", + gap: 12, + alignItems: "stretch", + }, + previewLayoutStacked: { + flexDirection: "column", + }, + previewDetails: { + flex: 0.95, + gap: 6, + minWidth: 0, + }, + previewFrame: { + flex: 1.7, + minHeight: 220, + justifyContent: "center", + alignItems: "center", + overflow: "hidden", + }, + previewImage: { + width: "100%", + height: "100%", + borderRadius: 8, + backgroundColor: "#ffffff", + }, + previewMetaLabel: { + fontSize: 13, + fontWeight: "700", + }, + previewMeta: { + fontSize: 13, + }, + urlText: { + fontSize: 12, + }, + emptyText: { + fontSize: 14, + }, + helperText: { + fontSize: 12, + }, + buttonRow: { + gap: 10, + }, + actionButton: { + borderRadius: 10, + paddingVertical: 12, + alignItems: "center", + }, + enabledButtonShadow: { + shadowColor: "#000", + shadowOpacity: 0.14, + shadowOffset: { width: 0, height: 6 }, + shadowRadius: 12, + elevation: 6, + }, + disabledButton: { + opacity: 0.45, + shadowOpacity: 0, + elevation: 0, + }, + actionButtonText: { + fontSize: 15, + fontWeight: "700", + }, + secondaryButton: { + borderWidth: 1, + borderRadius: 10, + paddingVertical: 12, + alignItems: "center", + }, + secondaryButtonText: { + fontSize: 14, + fontWeight: "600", + }, + removeButtonText: { + fontSize: 14, + fontWeight: "700", + }, + fullscreenOverlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + backgroundColor: "rgba(0, 0, 0, 0.82)", + }, + fullscreenBackdrop: { + ...StyleSheet.absoluteFillObject, + }, + fullscreenCard: { + width: "100%", + maxWidth: 900, + height: "82%", + borderWidth: 1, + borderRadius: 16, + padding: 12, + gap: 12, + }, + closeButton: { + alignSelf: "flex-end", + borderWidth: 1, + borderRadius: 999, + paddingHorizontal: 14, + paddingVertical: 8, + }, + closeButtonText: { + fontSize: 14, + fontWeight: "700", + }, + fullscreenImage: { + flex: 1, + width: "100%", + borderRadius: 12, + backgroundColor: "#d7d7d7", + }, +}) + +export const newNoteScreenStyles = StyleSheet.create({ + keyboardAvoider: { flex: 1 }, + container: { flex: 1 }, + formContent: { padding: 16, gap: 12 }, + titleInput: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 22, + fontWeight: "700", + }, + contentInput: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + actions: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + paddingTop: 12, + paddingHorizontal: 16, + }, + actionsBlur: { + ...StyleSheet.absoluteFillObject, + overflow: "hidden", + }, + actionsContent: { + borderTopWidth: 1, + paddingTop: 10, + }, + saveButton: { + flex: 1, + borderRadius: 12, + paddingVertical: 14, + alignItems: "center", + }, + enabledButtonShadow: { + shadowColor: "#000", + shadowOpacity: 0.18, + shadowOffset: { width: 0, height: 8 }, + shadowRadius: 16, + elevation: 8, + }, + disabledButton: { + opacity: 0.45, + shadowOpacity: 0, + elevation: 0, + }, + saveFloatingText: { + fontSize: 16, + fontWeight: "700", + }, + errorText: { + color: "#c62828", + }, +}) + +export const detailScreenStyles = StyleSheet.create({ + keyboardAvoider: { flex: 1 }, + container: { flex: 1 }, + formContent: { padding: 16, gap: 12 }, + title: { fontSize: 22, fontWeight: "700" }, + content: { fontSize: 16 }, + titleInput: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 22, + fontWeight: "700", + }, + signature: { + fontSize: 12, + color: "#666", + }, + contentInput: { + minHeight: 200, + borderWidth: 1, + borderRadius: 8, + padding: 12, + fontSize: 16, + }, + errorText: { + color: "#c62828", + }, + successText: { + color: "#2e7d32", + }, + readOnlyText: { + color: "#666", + }, + actions: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + paddingTop: 12, + paddingHorizontal: 16, + }, + actionsBlur: { + ...StyleSheet.absoluteFillObject, + overflow: "hidden", + }, + actionsContent: { + flexDirection: "row", + gap: 12, + borderTopWidth: 1, + paddingTop: 10, + }, + primaryButton: { + flex: 1, + borderRadius: 12, + paddingVertical: 14, + alignItems: "center", + }, + enabledButtonShadow: { + shadowColor: "#000", + shadowOpacity: 0.18, + shadowOffset: { width: 0, height: 8 }, + shadowRadius: 16, + elevation: 8, + }, + primaryButtonText: { + fontSize: 16, + fontWeight: "700", + }, + deleteButton: { + flex: 1, + borderRadius: 12, + paddingVertical: 14, + alignItems: "center", + }, + disabledButton: { + opacity: 0.45, + shadowOpacity: 0, + elevation: 0, + }, + deleteButtonText: { + color: "#fff", + fontSize: 16, + fontWeight: "700", + }, +}) diff --git a/FastNotes/src/theme/palette.ts b/FastNotes/src/theme/palette.ts index 9031e17..05d8077 100644 --- a/FastNotes/src/theme/palette.ts +++ b/FastNotes/src/theme/palette.ts @@ -14,8 +14,8 @@ const lightPalette = { } const darkPalette = { - background: "#000000", - surface: "#0b0b0b", + background: "#191919", + surface: "#343232", elevated: "#111111", text: "#ffffff", mutedText: "#b8b8b8", diff --git a/FastNotes/supabase/.gitignore b/FastNotes/supabase/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/FastNotes/supabase/config.toml b/FastNotes/supabase/config.toml new file mode 100644 index 0000000..014f7f1 --- /dev/null +++ b/FastNotes/supabase/config.toml @@ -0,0 +1,388 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "FastNotes" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/FastNotes/supabase/functions/push/index.ts b/FastNotes/supabase/functions/push/index.ts new file mode 100644 index 0000000..8cb7c7d --- /dev/null +++ b/FastNotes/supabase/functions/push/index.ts @@ -0,0 +1,164 @@ +import { createClient } from "npm:@supabase/supabase-js@2" + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "" +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "" +const EXPO_ACCESS_TOKEN = Deno.env.get("EXPO_ACCESS_TOKEN") ?? "" + +type NoteRecord = { + id: number | string + created_by: string + title: string +} + +type DatabaseWebhookPayload = { + type: "INSERT" | "UPDATE" | "DELETE" + table: string + schema: string + record?: NoteRecord | null + old_record?: NoteRecord | null +} + +type ExpoPushMessage = { + to: string + title: string + body: string + sound: "default" + data: { + type: "new-note" + noteId: string + title: string + } +} + +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) + +function jsonResponse(status: number, body: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }) +} + +function chunkMessages(items: T[], size: number) { + const chunks: T[][] = [] + + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)) + } + + return chunks +} + +async function loadCreatorEmail(userId: string) { + const { data: profile } = await supabase + .from("profiles") + .select("email") + .eq("id", userId) + .maybeSingle() + + if (profile?.email) { + return profile.email as string + } + + const { data, error } = await supabase.auth.admin.getUserById(userId) + + if (error) { + console.error("Failed to load note creator:", error.message) + return "unknown user" + } + + return data.user.email ?? "unknown user" +} + +async function loadRecipientTokens(userId: string) { + const { data, error } = await supabase + .from("user_push_tokens") + .select("push_token") + .neq("user_id", userId) + .eq("is_active", true) + + if (error) { + throw new Error(error.message) + } + + return Array.from(new Set((data ?? []).map((row) => row.push_token as string).filter(Boolean))) +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + } + + if (EXPO_ACCESS_TOKEN) { + headers.Authorization = `Bearer ${EXPO_ACCESS_TOKEN}` + } + + for (const chunk of chunkMessages(messages, 100)) { + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers, + body: JSON.stringify(chunk), + }) + + if (!response.ok) { + const errorBody = await response.text() + throw new Error(`Expo push request failed with ${response.status}: ${errorBody}`) + } + } +} + +Deno.serve(async (request) => { + if (request.method !== "POST") { + return jsonResponse(405, { error: "Method not allowed" }) + } + + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + return jsonResponse(500, { error: "Missing Supabase environment variables." }) + } + + let payload: DatabaseWebhookPayload + + try { + payload = await request.json() + } catch { + return jsonResponse(400, { error: "Invalid JSON payload." }) + } + + if (payload.type !== "INSERT" || payload.table !== "Notes" || !payload.record) { + return jsonResponse(200, { ignored: true }) + } + + try { + const note = payload.record + const [creatorEmail, recipientTokens] = await Promise.all([ + loadCreatorEmail(note.created_by), + loadRecipientTokens(note.created_by), + ]) + + if (recipientTokens.length === 0) { + return jsonResponse(200, { sent: 0 }) + } + + const body = `New note: "${note.title}" by ${creatorEmail}` + const messages: ExpoPushMessage[] = recipientTokens.map((token) => ({ + to: token, + title: "FastNotes", + body, + sound: "default", + data: { + type: "new-note", + noteId: String(note.id), + title: note.title, + }, + })) + + await sendExpoPushNotifications(messages) + + return jsonResponse(200, { sent: messages.length }) + } catch (error) { + const message = error instanceof Error ? error.message : "Unexpected error." + console.error("Push notification webhook failed:", message) + return jsonResponse(500, { error: message }) + } +}) diff --git a/FastNotes/tempStorage.tsx b/FastNotes/tempStorage.tsx deleted file mode 100644 index fb1c20b..0000000 --- a/FastNotes/tempStorage.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Image } from 'expo-image' -import { StyleSheet } from 'react-native' - -import { HelloWave } from '@/components/hello-wave' -import ParallaxScrollView from '@/components/parallax-scroll-view' -import { ThemedText } from '@/components/themed-text' -import { ThemedView } from '@/components/themed-view' -import SignOutButton from '@/components/social-auth-buttons/sign-out-button' -import { useAuthContext } from '@/hooks/use-auth-context' - -export default function HomeScreen() { - const { profile } = useAuthContext() - - return ( - - } - > - - Welcome! - - - - Username - {profile?.username} - Full name - {profile?.full_name} - - - - ) -} - -const styles = StyleSheet.create({ - titleContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - stepContainer: { - gap: 8, - marginBottom: 8, - }, - reactLogo: { - height: 178, - width: 290, - bottom: 0, - left: 0, - position: 'absolute', - }, -}) \ No newline at end of file From e3e5e625c9795956b18c248bca16fe90d753357b Mon Sep 17 00:00:00 2001 From: Christopher Sanden Date: Mon, 16 Mar 2026 17:43:44 +0100 Subject: [PATCH 3/6] finished assignment 3 - 100% --- FastNotes/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/FastNotes/.gitignore b/FastNotes/.gitignore index a30b89a..f9a0aab 100644 --- a/FastNotes/.gitignore +++ b/FastNotes/.gitignore @@ -46,3 +46,4 @@ yarn-error.* .env.keys .env.local .env.*.local +.env From b5d1eca94d84cf8f17e9b63af3e61aacf673f6d0 Mon Sep 17 00:00:00 2001 From: Chris Sanden Date: Mon, 16 Mar 2026 17:46:20 +0100 Subject: [PATCH 4/6] Delete FastNotes/.env --- FastNotes/.env | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 FastNotes/.env diff --git a/FastNotes/.env b/FastNotes/.env deleted file mode 100644 index e440fd2..0000000 --- a/FastNotes/.env +++ /dev/null @@ -1,2 +0,0 @@ -EXPO_PUBLIC_SUPABASE_URL=https://mogieparkvgcobaukpsr.supabase.co -EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_V_59BIi7RykQTnH7HNMWbg_dWN9hlbS \ No newline at end of file From cd19355b7847d31aaf45796e2e5d8ec25eafd042 Mon Sep 17 00:00:00 2001 From: Chris Sanden Date: Mon, 16 Mar 2026 17:47:02 +0100 Subject: [PATCH 5/6] Delete FastNotes/supabase/.gitignore --- FastNotes/supabase/.gitignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 FastNotes/supabase/.gitignore diff --git a/FastNotes/supabase/.gitignore b/FastNotes/supabase/.gitignore deleted file mode 100644 index e69de29..0000000 From 70b5247193c2c7f7dab84558e4160b15745e8133 Mon Sep 17 00:00:00 2001 From: Christopher Sanden Date: Tue, 17 Mar 2026 15:11:07 +0100 Subject: [PATCH 6/6] updated gitignore --- FastNotes/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FastNotes/.gitignore b/FastNotes/.gitignore index f9a0aab..b08039b 100644 --- a/FastNotes/.gitignore +++ b/FastNotes/.gitignore @@ -30,7 +30,6 @@ yarn-error.* .DS_Store *.pem - # typescript *.tsbuildinfo @@ -41,6 +40,7 @@ yarn-error.* # Supabase .branches .temp +config.toml # dotenvx .env.keys