diff --git a/mobile/app.json b/mobile/app.json index 20a878e..8edc4cf 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -31,7 +31,14 @@ "expo-font", "expo-dev-client", "expo-image", - "expo-system-ui" + "expo-system-ui", + [ + "expo-image-picker", + { + "photosPermission": "Erlaube PawFeed den Zugriff auf deine Fotos, um sie in einem Beitrag zu teilen.", + "cameraPermission": "Erlaube PawFeed den Zugriff auf die Kamera, um ein Foto für deinen Beitrag aufzunehmen." + } + ] ] } } diff --git a/mobile/app/(app)/(tabs)/_layout.tsx b/mobile/app/(app)/(tabs)/_layout.tsx index 38429dd..a6410a6 100644 --- a/mobile/app/(app)/(tabs)/_layout.tsx +++ b/mobile/app/(app)/(tabs)/_layout.tsx @@ -1,7 +1,7 @@ import { View, Text, Pressable, StyleSheet } from "react-native"; import { Tabs, useRouter } from "expo-router"; import { useQuery } from "@tanstack/react-query"; -import { Home, Compass, Bell, Heart, MessageCircle, PawPrint } from "lucide-react-native"; +import { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus } from "lucide-react-native"; import { useTRPC } from "../../../src/api/trpc"; import { useActivePet } from "../../../src/context/ActivePetContext"; import { colors, fontFamily } from "../../../src/theme/tokens"; @@ -31,6 +31,12 @@ export default function TabsLayout() { name="index" options={{ headerTitle: () => , + headerRight: () => ( + + + + + ), tabBarLabel: "Feed", tabBarIcon: ({ color, size }) => , }} @@ -61,6 +67,15 @@ function Wordmark() { ); } +function NewPostHeaderButton() { + const router = useRouter(); + return ( + router.push("/post-new")} hitSlop={12}> + + + ); +} + function MessagesHeaderButton() { const router = useRouter(); return ( @@ -92,6 +107,7 @@ function NotificationsTabIcon({ color, size }: { color: string; size: number }) } const styles = StyleSheet.create({ + headerButtons: { flexDirection: "row", alignItems: "center", gap: 16 }, headerButton: { marginRight: 16 }, wordmark: { flexDirection: "row", alignItems: "center", gap: 8 }, wordmarkText: { fontFamily: fontFamily.bold, fontSize: 20, color: colors.ink }, diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 1ba7ed2..aca14e7 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -1,4 +1,6 @@ -import { Stack } from "expo-router"; +import { Pressable } from "react-native"; +import { Stack, useRouter } from "expo-router"; +import { X } from "lucide-react-native"; import { colors, fontFamily } from "../../src/theme/tokens"; const headerOptions = { @@ -7,6 +9,15 @@ const headerOptions = { headerTitleStyle: { fontFamily: fontFamily.bold }, } as const; +function CloseModalButton() { + const router = useRouter(); + return ( + router.back()} hitSlop={12} style={{ marginLeft: 8 }}> + + + ); +} + export default function AppLayout() { return ( @@ -16,6 +27,16 @@ export default function AppLayout() { + + , + }} + /> ([]); + const [caption, setCaption] = useState(""); + const [error, setError] = useState(null); + + const getPresignedUrlMutation = useMutation(trpc.posts.getPresignedUrl.mutationOptions()); + + const createMutation = useMutation( + trpc.posts.create.mutationOptions({ + onSuccess: async () => { + await queryClient.invalidateQueries(trpc.feed.pathFilter()); + router.back(); + }, + onError: (err) => setError(err.message), + }) + ); + + const uploadAsset = useCallback( + async (asset: ImagePicker.ImagePickerAsset, index: number) => { + const mimeType = asset.mimeType ?? "image/jpeg"; + try { + const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ + petId: activePetId!, + contentType: mimeType as "image/jpeg" | "image/png" | "image/webp", + fileSize: asset.fileSize ?? 0, + }); + await uploadImageToSupabase(asset.uri, presignedUrl, mimeType); + setImages((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], status: "done", key }; + return updated; + }); + } catch { + setImages((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], status: "error" }; + return updated; + }); + } + }, + [activePetId, getPresignedUrlMutation] + ); + + const addAssets = useCallback( + (assets: ImagePicker.ImagePickerAsset[]) => { + const available = MAX_IMAGES - images.length; + if (available <= 0) { + Alert.alert("Maximal 10 Fotos pro Beitrag."); + return; + } + + const accepted = assets + .filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg")) + .slice(0, available); + + if (accepted.length === 0) return; + + const startIndex = images.length; + const newImages: PickedImage[] = accepted.map((asset) => ({ + uri: asset.uri, + mimeType: asset.mimeType ?? "image/jpeg", + key: null, + status: "uploading", + })); + setImages((prev) => [...prev, ...newImages]); + accepted.forEach((asset, i) => uploadAsset(asset, startIndex + i)); + }, + [images.length, uploadAsset] + ); + + const pickFromGallery = useCallback(async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + Alert.alert("Zugriff auf Fotos wurde nicht erlaubt."); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: MAX_IMAGES - images.length, + quality: 0.8, + }); + if (!result.canceled) addAssets(result.assets); + }, [images.length, addAssets]); + + const takePhoto = useCallback(async () => { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + Alert.alert("Zugriff auf die Kamera wurde nicht erlaubt."); + return; + } + const result = await ImagePicker.launchCameraAsync({ quality: 0.8 }); + if (!result.canceled) addAssets(result.assets); + }, [addAssets]); + + const removeImage = useCallback((index: number) => { + setImages((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const doneImages = images.filter((img) => img.status === "done" && img.key); + const isUploading = images.some((img) => img.status === "uploading"); + const canSubmit = doneImages.length > 0 && !isUploading && !createMutation.isPending; + + const handleSubmit = () => { + if (!activePetId || doneImages.length === 0) return; + setError(null); + createMutation.mutate({ + petId: activePetId, + caption: caption.trim() || null, + images: doneImages.map((img) => ({ key: img.key as string, blurhash: null })), + }); + }; + + return ( + + {images.length > 0 && ( + + {images.map((img, i) => ( + + + {img.status === "uploading" && ( + + + + )} + {img.status === "error" && ( + + Fehler + + )} + {img.status !== "uploading" && ( + removeImage(i)} hitSlop={8}> + + + )} + + ))} + + )} + + {images.length < MAX_IMAGES && ( + + + + Galerie + + + + Kamera + + + )} + + + + {error ? {error} : null} + +