finished assignment 3 - 100%
This commit is contained in:
@@ -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<StagedNoteImage | null>(null)
|
||||
const [imageChange, setImageChange] = useState<NoteImageChange>({ type: "keep" })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(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 (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={headerHeight}
|
||||
@@ -131,7 +204,10 @@ export default function DetailScreen()
|
||||
<TextInput
|
||||
editable={canEdit}
|
||||
onChangeText={setTitle}
|
||||
style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
|
||||
style={[
|
||||
styles.titleInput,
|
||||
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
|
||||
]}
|
||||
value={title}
|
||||
placeholderTextColor={palette.mutedText}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<NoteImagePanel
|
||||
canEdit={Boolean(canEdit)}
|
||||
isBusy={imageActionsDisabled}
|
||||
currentImageUrl={currentImageUrl}
|
||||
currentImageMimeType={currentImageMimeType}
|
||||
currentImageSizeBytes={currentImageSizeBytes}
|
||||
stagedImage={stagedImage}
|
||||
helperText={
|
||||
canEdit
|
||||
? "Replacing or removing the image only takes effect after you save changes."
|
||||
: "This image is attached to the note and stored in Supabase."
|
||||
}
|
||||
palette={palette}
|
||||
primaryTextColor={colorScheme === "dark" ? "#000" : "#fff"}
|
||||
onTakePhoto={() => {
|
||||
void attachFromCamera()
|
||||
}}
|
||||
onChooseFromLibrary={() => {
|
||||
void attachFromGallery()
|
||||
}}
|
||||
onRemoveImage={clearImage}
|
||||
/>
|
||||
|
||||
{uploadProgress !== null ? <UploadProgressBar progress={uploadProgress} palette={palette} /> : null}
|
||||
|
||||
{!canEdit ? (
|
||||
<Text style={[styles.readOnlyText, { color: palette.mutedText }]}>
|
||||
Only the creator of this note can update or delete it.
|
||||
@@ -158,95 +263,37 @@ export default function DetailScreen()
|
||||
{statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null}
|
||||
</ScrollView>
|
||||
{canEdit ? (
|
||||
<View style={[styles.actions, { bottom: insets.bottom + 16 }]}>
|
||||
<Pressable disabled={isSaving} onPress={onSave} style={[styles.primaryButton, { backgroundColor: palette.accent }]}>
|
||||
<Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
|
||||
{isSaving ? "Saving..." : "Save changes"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable disabled={isDeleting} onPress={confirmDelete} style={[styles.deleteButton, { backgroundColor: palette.destructive }]}>
|
||||
<Text style={styles.deleteButtonText}>
|
||||
{isDeleting ? "Deleting..." : "Delete note"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={[styles.actions, { paddingBottom: insets.bottom + 16 }]}>
|
||||
<BlurView
|
||||
intensity={22}
|
||||
tint={colorScheme}
|
||||
style={styles.actionsBlur}
|
||||
/>
|
||||
<View style={[styles.actionsContent, { borderColor: palette.border }]}>
|
||||
<Pressable
|
||||
disabled={saveDisabled}
|
||||
onPress={() => {
|
||||
void onSave()
|
||||
}}
|
||||
style={[styles.primaryButton, primaryButtonStyle, { backgroundColor: palette.accent }]}
|
||||
>
|
||||
<Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
|
||||
{isSaving ? "Saving..." : "Save changes"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
disabled={deleteDisabled}
|
||||
onPress={confirmDelete}
|
||||
style={[styles.deleteButton, deleteButtonStyle, { backgroundColor: palette.destructive }]}
|
||||
>
|
||||
<Text style={styles.deleteButtonText}>
|
||||
{isDeleting ? "Deleting..." : "Delete note"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user