diff --git a/mobile/app/(app)/(tabs)/explore.tsx b/mobile/app/(app)/(tabs)/explore.tsx index 44dc0ec..40f3195 100644 --- a/mobile/app/(app)/(tabs)/explore.tsx +++ b/mobile/app/(app)/(tabs)/explore.tsx @@ -184,13 +184,13 @@ function makeStyles(colors: ColorTokens) { center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, backgroundColor: colors.porchWhite }, errorText: { ...typography.body, color: colors.alertRed }, name: { ...typography.body, fontFamily: typography.title.fontFamily }, - followers: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + followers: { ...typography.caption }, footer: { marginVertical: 16 }, filters: { gap: 10, marginBottom: 12 }, speciesTabs: { gap: 8 }, tab: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray }, tabActive: { backgroundColor: colors.marigoldOrange }, - tabText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink }, + tabText: { ...typography.caption, color: colors.ink }, tabTextActive: { color: colors.pureWhite }, breedFilter: { width: 220 }, }); diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index cc1b1ff..c4063ec 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -229,7 +229,7 @@ function makeStyles(colors: ColorTokens) { row: { flexDirection: "row", gap: 12, padding: 12, borderRadius: 12 }, rowUnread: { backgroundColor: colors.marigoldTint }, rowText: { flex: 1, gap: 2 }, - timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + timestamp: { ...typography.caption }, footer: { marginVertical: 16 }, selfIcon: { width: 40, diff --git a/mobile/app/(app)/lost/[petId].tsx b/mobile/app/(app)/lost/[petId].tsx index e0e2e47..3ce3731 100644 --- a/mobile/app/(app)/lost/[petId].tsx +++ b/mobile/app/(app)/lost/[petId].tsx @@ -205,7 +205,7 @@ function makeStyles(colors: ColorTokens) { fact: { flexDirection: "row", gap: 10 }, factIcon: { paddingTop: 2 }, flex: { flex: 1 }, - factLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + factLabel: { ...typography.caption }, factValue: { ...typography.body }, contactCard: { gap: 8, padding: 14, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, backgroundColor: colors.alertRedTint }, contactHeading: { ...typography.title, fontSize: 16 }, @@ -221,7 +221,7 @@ function makeStyles(colors: ColorTokens) { borderColor: colors.hairlineGray, }, contactButtonText: { ...typography.body, fontFamily: typography.title.fontFamily, flexShrink: 1 }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 }, + hint: { ...typography.caption, marginTop: 4 }, section: { gap: 8 }, sectionTitle: { ...typography.title, fontSize: 15 }, grid: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, diff --git a/mobile/app/(app)/messages/[conversationId].tsx b/mobile/app/(app)/messages/[conversationId].tsx index bf5e529..32eea82 100644 --- a/mobile/app/(app)/messages/[conversationId].tsx +++ b/mobile/app/(app)/messages/[conversationId].tsx @@ -132,7 +132,7 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) { bubbleOwn: { backgroundColor: colors.marigoldOrange, borderBottomRightRadius: 4 }, bubbleText: { ...typography.body }, bubbleTextOwn: { color: colors.pureWhite }, - timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10 }, + timestamp: { ...typography.caption, fontSize: 10 }, inputRow: { flexDirection: "row", alignItems: "center", diff --git a/mobile/app/(app)/messages/index.tsx b/mobile/app/(app)/messages/index.tsx index 8046daa..a08b842 100644 --- a/mobile/app/(app)/messages/index.tsx +++ b/mobile/app/(app)/messages/index.tsx @@ -100,7 +100,7 @@ function makeStyles(colors: ColorTokens) { nameRow: { flexDirection: "row", alignItems: "center", gap: 6 }, name: { ...typography.body, fontFamily: typography.title.fontFamily }, preview: { ...typography.body, color: colors.quietGray }, - timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + timestamp: { ...typography.caption }, unreadDot: { backgroundColor: colors.marigoldOrange, borderRadius: 8, diff --git a/mobile/app/(app)/milestone-new.tsx b/mobile/app/(app)/milestone-new.tsx index 077266e..ee985d2 100644 --- a/mobile/app/(app)/milestone-new.tsx +++ b/mobile/app/(app)/milestone-new.tsx @@ -8,8 +8,7 @@ import { ImagePlus } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { useTheme } from "../../src/context/ThemeContext"; -import { uploadImageToSupabase } from "../../src/lib/upload"; -import { resizeForUpload } from "../../src/lib/resize-image"; +import { uploadPickedImage } from "../../src/lib/upload"; import { MILESTONE_META, USER_INITIATED_MILESTONE_TYPES, type CreatableMilestoneType } from "../../src/lib/milestone-meta"; import { TextField } from "../../src/components/TextField"; import { Button } from "../../src/components/Button"; @@ -62,13 +61,7 @@ export default function NewMilestoneScreen() { try { let imageKey: string | undefined; if (asset) { - const resized = await resizeForUpload(asset.uri, asset.width, asset.height); - const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ - petId: activePetId, - contentType: resized.mimeType, - fileSize: resized.fileSize, - }); - await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); + const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file })); imageKey = key; } createMutation.mutate({ @@ -142,7 +135,7 @@ function makeStyles(colors: ColorTokens) { const typography = getTypography(colors); return StyleSheet.create({ container: { padding: 16, gap: 14, backgroundColor: colors.porchWhite, flexGrow: 1 }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + label: { ...typography.caption }, chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, chip: { flexDirection: "row", @@ -168,7 +161,7 @@ function makeStyles(colors: ColorTokens) { }, pickerText: { ...typography.body, color: colors.ink }, preview: { width: "100%", height: 220, borderRadius: radii.md, backgroundColor: colors.hairlineGray }, - previewHint: { ...typography.label, textTransform: "none", letterSpacing: 0, textAlign: "center", marginTop: 6 }, + previewHint: { ...typography.caption, textAlign: "center", marginTop: 6 }, notes: { height: 88, textAlignVertical: "top", paddingTop: 12 }, error: { ...typography.body, color: colors.alertRed }, }); diff --git a/mobile/app/(app)/pets/[petId]/edit.tsx b/mobile/app/(app)/pets/[petId]/edit.tsx index 48e35ff..bc5c602 100644 --- a/mobile/app/(app)/pets/[petId]/edit.tsx +++ b/mobile/app/(app)/pets/[petId]/edit.tsx @@ -214,7 +214,7 @@ function makeStyles(colors: ColorTokens) { return StyleSheet.create({ container: { padding: 16, gap: 8, backgroundColor: colors.porchWhite, flexGrow: 1 }, center: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.porchWhite }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 12 }, + label: { ...typography.caption, marginTop: 12 }, multiline: { height: 88, textAlignVertical: "top", paddingTop: 12 }, error: { ...typography.body, color: colors.alertRed, marginTop: 8 }, deleteRow: { diff --git a/mobile/app/(app)/pets/[petId]/index.tsx b/mobile/app/(app)/pets/[petId]/index.tsx index 34432c9..dc1c053 100644 --- a/mobile/app/(app)/pets/[petId]/index.tsx +++ b/mobile/app/(app)/pets/[petId]/index.tsx @@ -291,10 +291,10 @@ function makeStyles(colors: ColorTokens) { statsRow: { flexDirection: "row", gap: 32, marginVertical: 12 }, stat: { alignItems: "center" }, statCount: { ...typography.title, fontSize: 18 }, - statLabel: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + statLabel: { ...typography.caption }, iconRow: { flexDirection: "row", gap: 20, marginTop: 14 }, iconButton: { alignItems: "center", gap: 4 }, - iconButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, + iconButtonText: { ...typography.caption, fontSize: 11 }, section: { ...typography.body, alignSelf: "stretch", marginTop: 12 }, sectionTitle: { ...typography.title, fontSize: 15, alignSelf: "stretch", marginTop: 16 }, divider: { height: 1, backgroundColor: colors.hairlineGray, alignSelf: "stretch", marginTop: 16 }, diff --git a/mobile/app/(app)/pets/[petId]/milestones.tsx b/mobile/app/(app)/pets/[petId]/milestones.tsx index 09d954a..8840a1d 100644 --- a/mobile/app/(app)/pets/[petId]/milestones.tsx +++ b/mobile/app/(app)/pets/[petId]/milestones.tsx @@ -104,7 +104,7 @@ function makeStyles(colors: ColorTokens) { }, rowText: { flex: 1, gap: 2 }, rowTitle: { ...typography.body, fontFamily: typography.title.fontFamily }, - rowDate: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + rowDate: { ...typography.caption }, rowNotes: { ...typography.body, fontSize: 13 }, thumb: { width: 48, height: 48, borderRadius: radii.md }, addButton: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 8, height: 48, marginTop: 8 }, diff --git a/mobile/app/(app)/post-new.tsx b/mobile/app/(app)/post-new.tsx index 494f345..50acc1e 100644 --- a/mobile/app/(app)/post-new.tsx +++ b/mobile/app/(app)/post-new.tsx @@ -8,8 +8,7 @@ import { X, ImagePlus, Camera } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { useTheme } from "../../src/context/ThemeContext"; -import { uploadImageToSupabase } from "../../src/lib/upload"; -import { resizeForUpload } from "../../src/lib/resize-image"; +import { uploadPickedImage } from "../../src/lib/upload"; import { TextField } from "../../src/components/TextField"; import { Button } from "../../src/components/Button"; import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens"; @@ -64,13 +63,7 @@ export default function NewPostScreen() { const uploadAsset = useCallback( async (asset: ImagePicker.ImagePickerAsset, index: number) => { try { - const resized = await resizeForUpload(asset.uri, asset.width, asset.height); - const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ - petId: activePetId!, - contentType: resized.mimeType, - fileSize: resized.fileSize, - }); - await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); + const { key } = await uploadPickedImage(asset, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId!, ...file })); setImages((prev) => { const updated = [...prev]; updated[index] = { ...updated[index], status: "done", key }; diff --git a/mobile/app/(app)/posts/[postId]/comments.tsx b/mobile/app/(app)/posts/[postId]/comments.tsx index cf00658..8987e98 100644 --- a/mobile/app/(app)/posts/[postId]/comments.tsx +++ b/mobile/app/(app)/posts/[postId]/comments.tsx @@ -187,8 +187,8 @@ function makeStyles(colors: ColorTokens, insetsBottom: number) { rowHeader: { flexDirection: "row", alignItems: "baseline", gap: 8 }, bodyText: { ...typography.body }, petName: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 }, - timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, - pendingLabel: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, + timestamp: { ...typography.caption, fontSize: 11 }, + pendingLabel: { ...typography.caption, fontSize: 11 }, deleteButton: { alignSelf: "flex-start", padding: 4 }, inputRow: { flexDirection: "row", diff --git a/mobile/app/(app)/search.tsx b/mobile/app/(app)/search.tsx index 0dd17c5..5a207a6 100644 --- a/mobile/app/(app)/search.tsx +++ b/mobile/app/(app)/search.tsx @@ -248,7 +248,7 @@ function makeStyles(colors: ColorTokens) { tabs: { flexDirection: "row", gap: 8, paddingHorizontal: 16, paddingVertical: 12 }, tabButton: { paddingHorizontal: 14, height: 36, borderRadius: radii.lg, justifyContent: "center", backgroundColor: colors.whisperGray }, tabButtonActive: { backgroundColor: colors.marigoldOrange }, - tabButtonText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink }, + tabButtonText: { ...typography.caption, color: colors.ink }, tabButtonTextActive: { color: colors.pureWhite }, list: { padding: 16, flexGrow: 1 }, center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, @@ -258,7 +258,7 @@ function makeStyles(colors: ColorTokens) { petRowMain: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10 }, petRowText: { flex: 1 }, petName: { ...typography.body, fontFamily: typography.title.fontFamily }, - petSpecies: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + petSpecies: { ...typography.caption }, ownerBlock: { marginBottom: 16 }, ownerName: { ...typography.body, fontFamily: typography.title.fontFamily, marginBottom: 4 }, tagGrid: { padding: 2 }, diff --git a/mobile/app/(app)/story-editor.tsx b/mobile/app/(app)/story-editor.tsx index a42f69d..65f56d0 100644 --- a/mobile/app/(app)/story-editor.tsx +++ b/mobile/app/(app)/story-editor.tsx @@ -5,8 +5,7 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; -import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload"; -import { resizeForUpload } from "../../src/lib/resize-image"; +import { uploadFileToUrl, uploadPickedImage } from "../../src/lib/upload"; import { StoryEditor, type EditorMedia, type EditorResult } from "../../src/components/story-editor/StoryEditor"; import { fontFamily } from "../../src/theme/tokens"; @@ -95,17 +94,11 @@ export default function StoryEditorScreen() { } setIsUploading(true); - const resized = await resizeForUpload(media.uri, media.width, media.height); - const size = await imageSize(resized.uri).catch(() => ({ width: media.width, height: media.height })); - const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ - petId: activePetId, - contentType: resized.mimeType, - fileSize: resized.fileSize, - }); - await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); + const uploaded = await uploadPickedImage(media, (file) => getPresignedUrlMutation.mutateAsync({ petId: activePetId, ...file })); + const size = await imageSize(uploaded.uri).catch(() => ({ width: media.width, height: media.height })); await createMutation.mutateAsync({ petId: activePetId, - storageKey: key, + storageKey: uploaded.key, blurhash: null, ...editorFields, mediaWidth: size.width, diff --git a/mobile/src/api/types.ts b/mobile/src/api/types.ts index fc62bfa..0056304 100644 --- a/mobile/src/api/types.ts +++ b/mobile/src/api/types.ts @@ -12,4 +12,3 @@ export type PostComment = RouterOutputs["comments"]["listByPost"]["comments"][nu export type ChatMessage = RouterOutputs["messages"]["list"]["messages"][number]; export type MilestonePost = RouterOutputs["milestones"]["listByPet"][number]; export type TrendingPost = RouterOutputs["explore"]["getTrending"]["posts"][number]; -export type PostDetail = RouterOutputs["posts"]["getById"]; diff --git a/mobile/src/components/PawButton.tsx b/mobile/src/components/PawButton.tsx index 8fdcff9..05c722f 100644 --- a/mobile/src/components/PawButton.tsx +++ b/mobile/src/components/PawButton.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; import { Pressable, Text, StyleSheet } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { PawPrint } from "lucide-react-native"; import { useTRPC } from "../api/trpc"; +import { usePawToggle } from "../hooks/usePawToggle"; import { colors, fontFamily } from "../theme/tokens"; type Props = { @@ -16,37 +16,12 @@ type Props = { * `false` on mount — the web app's feed response doesn't tell the client * whether the viewer already reacted (postInclude has no reactions relation), * an acknowledged simplification on web that this mirrors rather than fixes. - * Same optimistic-update shape: flip locally in onMutate, reconcile the exact - * count from the server's authoritative `reacted` in onSuccess, roll back in - * onError. + * Optimistic toggle via usePawToggle (shared with StoryPawButton). */ export function PawButton({ postId, petId, initialCount }: Props) { const trpc = useTRPC(); - const [reacted, setReacted] = useState(false); - const [count, setCount] = useState(initialCount); - - const toggleMutation = useMutation( - trpc.reactions.toggle.mutationOptions({ - onMutate: () => { - const prevReacted = reacted; - const prevCount = count; - setReacted(!reacted); - return { prevReacted, prevCount }; - }, - onSuccess: (data, _vars, context) => { - setReacted(data.reacted); - if (context) { - setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1)); - } - }, - onError: (_err, _vars, context) => { - if (context) { - setReacted(context.prevReacted); - setCount(context.prevCount); - } - }, - }) - ); + const paw = usePawToggle(false, initialCount); + const toggleMutation = useMutation(trpc.reactions.toggle.mutationOptions(paw.callbacks)); return ( - {count} + {paw.count} ); } diff --git a/mobile/src/components/PostCard.tsx b/mobile/src/components/PostCard.tsx index bad2776..d108281 100644 --- a/mobile/src/components/PostCard.tsx +++ b/mobile/src/components/PostCard.tsx @@ -312,8 +312,8 @@ function makeStyles(colors: ColorTokens) { borderRadius: radii.sm, backgroundColor: colors.marigoldTint, }, - trendingBadgeText: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 10, color: colors.marigoldDeep }, - timestamp: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + trendingBadgeText: { ...typography.caption, fontSize: 10, color: colors.marigoldDeep }, + timestamp: { ...typography.caption }, caption: { ...typography.body }, actions: { flexDirection: "row", alignItems: "center", gap: 20 }, actionGroup: { flexDirection: "row", alignItems: "center", gap: 6 }, diff --git a/mobile/src/components/RepostCard.tsx b/mobile/src/components/RepostCard.tsx index 00577c5..b41cf87 100644 --- a/mobile/src/components/RepostCard.tsx +++ b/mobile/src/components/RepostCard.tsx @@ -81,7 +81,7 @@ function makeStyles(colors: ColorTokens) { return StyleSheet.create({ card: { gap: 8 }, attribution: { flexDirection: "row", alignItems: "center", gap: 6 }, - attributionText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray }, + attributionText: { ...typography.caption, color: colors.quietGray }, header: { flexDirection: "row", alignItems: "center", gap: 8 }, name: { ...typography.body, fontFamily: typography.title.fontFamily }, caption: { ...typography.body }, diff --git a/mobile/src/components/VideoPlayer.tsx b/mobile/src/components/VideoPlayer.tsx index f1f0e0e..c4269bf 100644 --- a/mobile/src/components/VideoPlayer.tsx +++ b/mobile/src/components/VideoPlayer.tsx @@ -96,5 +96,5 @@ const styles = StyleSheet.create({ justifyContent: "center", gap: 8, }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.pureWhite }, + label: { ...typography.caption, color: colors.pureWhite }, }); diff --git a/mobile/src/components/caretaker/CaretakerRedeem.tsx b/mobile/src/components/caretaker/CaretakerRedeem.tsx index 8ffb54b..9d96a31 100644 --- a/mobile/src/components/caretaker/CaretakerRedeem.tsx +++ b/mobile/src/components/caretaker/CaretakerRedeem.tsx @@ -94,7 +94,7 @@ function makeStyles(colors: ColorTokens) { request: { gap: 8 }, requestRow: { flexDirection: "row", alignItems: "center", gap: 8 }, body: { ...typography.body, flex: 1 }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + label: { ...typography.caption, marginTop: 4 }, + hint: { ...typography.caption }, }); } diff --git a/mobile/src/components/caretaker/CaretakerSettings.tsx b/mobile/src/components/caretaker/CaretakerSettings.tsx index 895e18c..512fb4c 100644 --- a/mobile/src/components/caretaker/CaretakerSettings.tsx +++ b/mobile/src/components/caretaker/CaretakerSettings.tsx @@ -169,7 +169,7 @@ function makeStyles(colors: ColorTokens) { section: { marginTop: 24, gap: 8 }, headingRow: { flexDirection: "row", alignItems: "center", gap: 6 }, heading: { ...typography.title, fontSize: 16 }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + hint: { ...typography.caption }, body: { ...typography.body }, code: { ...typography.title, diff --git a/mobile/src/components/health/HealthConsentGate.tsx b/mobile/src/components/health/HealthConsentGate.tsx index 65904b3..8faf088 100644 --- a/mobile/src/components/health/HealthConsentGate.tsx +++ b/mobile/src/components/health/HealthConsentGate.tsx @@ -147,7 +147,7 @@ function makeStyles(colors: ColorTokens) { }, checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange }, checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 }, - privacyLink: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 20 }, + privacyLink: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 20 }, ctaRow: { flexDirection: "row", gap: 10 }, ctaButton: { flex: 1 }, manageBox: { @@ -163,7 +163,7 @@ function makeStyles(colors: ColorTokens) { }, manageText: { flex: 1 }, manageTitle: { ...typography.body, fontFamily: typography.title.fontFamily, fontSize: 13 }, - manageDesc: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 2 }, + manageDesc: { ...typography.caption, marginTop: 2 }, revokeLink: { ...typography.body, color: colors.alertRed, fontFamily: typography.title.fontFamily, fontSize: 13 }, }); } diff --git a/mobile/src/components/health/VaccineSection.tsx b/mobile/src/components/health/VaccineSection.tsx index 779314a..440a2b9 100644 --- a/mobile/src/components/health/VaccineSection.tsx +++ b/mobile/src/components/health/VaccineSection.tsx @@ -9,6 +9,7 @@ import { TextField } from "../TextField"; import { DateField } from "../DateField"; import { Button } from "../Button"; import { Card } from "../Card"; +import { healthSectionStyles } from "./healthSectionStyles"; import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; import { addYearsToDisplayDate, displayDateToIso, todayAsDisplayDate } from "../../lib/date-format"; @@ -143,21 +144,14 @@ export function VaccineSection({ petId }: { petId: string }) { function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); + const base = healthSectionStyles(colors); return StyleSheet.create({ - section: { gap: 10 }, - sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, - sectionTitle: { ...typography.title, fontSize: 16 }, - form: { gap: 10 }, - empty: { ...typography.body, color: colors.quietGray }, - row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, - rowText: { flex: 1, gap: 2 }, - rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily }, - rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + ...base, overdue: { color: colors.alertRed }, presetRow: { flexDirection: "row", gap: 8 }, preset: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: colors.marigoldTint }, presetDisabled: { opacity: 0.4 }, - presetText: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.ink }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.quietGray }, + presetText: { ...typography.caption, color: colors.ink }, + hint: { ...typography.caption, color: colors.quietGray }, }); } diff --git a/mobile/src/components/health/VetVisitSection.tsx b/mobile/src/components/health/VetVisitSection.tsx index 5093c74..2bbdd11 100644 --- a/mobile/src/components/health/VetVisitSection.tsx +++ b/mobile/src/components/health/VetVisitSection.tsx @@ -9,6 +9,7 @@ import { TextField } from "../TextField"; import { DateField } from "../DateField"; import { Button } from "../Button"; import { Card } from "../Card"; +import { healthSectionStyles } from "./healthSectionStyles"; import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format"; @@ -112,16 +113,9 @@ export function VetVisitSection({ petId }: { petId: string }) { function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); + const base = healthSectionStyles(colors); return StyleSheet.create({ - section: { gap: 10 }, - sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, - sectionTitle: { ...typography.title, fontSize: 16 }, - form: { gap: 10 }, - empty: { ...typography.body, color: colors.quietGray }, - row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, - rowText: { flex: 1, gap: 2 }, - rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily }, - rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + ...base, rowNotes: { ...typography.body, fontSize: 13, marginTop: 2 }, }); } diff --git a/mobile/src/components/health/WeightChart.tsx b/mobile/src/components/health/WeightChart.tsx index 7446185..e04eeb0 100644 --- a/mobile/src/components/health/WeightChart.tsx +++ b/mobile/src/components/health/WeightChart.tsx @@ -193,10 +193,10 @@ function makeStyles(colors: ColorTokens) { header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }, eyebrow: { ...typography.label }, readout: { ...typography.title, fontSize: 20, marginTop: 2 }, - readoutDate: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 12 }, + readoutDate: { ...typography.caption, fontSize: 12 }, deltaChip: { alignItems: "flex-end" }, deltaText: { ...typography.body, fontFamily: typography.title.fontFamily }, - deltaCaption: { ...typography.label, textTransform: "none", letterSpacing: 0, fontSize: 11 }, + deltaCaption: { ...typography.caption, fontSize: 11 }, plot: { height: CHART_HEIGHT }, }); } diff --git a/mobile/src/components/health/WeightLogSection.tsx b/mobile/src/components/health/WeightLogSection.tsx index 7fb9389..72778c1 100644 --- a/mobile/src/components/health/WeightLogSection.tsx +++ b/mobile/src/components/health/WeightLogSection.tsx @@ -9,8 +9,9 @@ import { TextField } from "../TextField"; import { DateField } from "../DateField"; import { Button } from "../Button"; import { Card } from "../Card"; +import { healthSectionStyles } from "./healthSectionStyles"; import { WeightChart } from "./WeightChart"; -import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; +import type { ColorTokens } from "../../theme/tokens"; import { displayDateToIso, todayAsDisplayDate } from "../../lib/date-format"; /** Port of web's health.ts weightLog CRUD — see src/app/(app)/pets/[petId]/health/page.tsx's Gewicht tab. */ @@ -106,16 +107,9 @@ export function WeightLogSection({ petId }: { petId: string }) { } function makeStyles(colors: ColorTokens) { - const typography: Typography = getTypography(colors); + const base = healthSectionStyles(colors); return StyleSheet.create({ - section: { gap: 10 }, - sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, - sectionTitle: { ...typography.title, fontSize: 16 }, - form: { gap: 10 }, - empty: { ...typography.body, color: colors.quietGray }, - row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, - rowText: { flex: 1, gap: 2 }, - rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily }, - rowSecondary: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + ...base, + row: { ...base.row, alignItems: "center" }, }); } diff --git a/mobile/src/components/health/healthSectionStyles.ts b/mobile/src/components/health/healthSectionStyles.ts new file mode 100644 index 0000000..64c628f --- /dev/null +++ b/mobile/src/components/health/healthSectionStyles.ts @@ -0,0 +1,18 @@ +import { StyleSheet } from "react-native"; +import { getTypography, type ColorTokens } from "../../theme/tokens"; + +/** Shared chrome of the health sections (vaccines, vet visits, weight): header, form, list rows. */ +export function healthSectionStyles(colors: ColorTokens) { + const typography = getTypography(colors); + return StyleSheet.create({ + section: { gap: 10 }, + sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + sectionTitle: { ...typography.title, fontSize: 16 }, + form: { gap: 10 }, + empty: { ...typography.body, color: colors.quietGray }, + row: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: colors.hairlineGray }, + rowText: { flex: 1, gap: 2 }, + rowPrimary: { ...typography.body, fontFamily: typography.title.fontFamily }, + rowSecondary: { ...typography.caption }, + }); +} diff --git a/mobile/src/components/lost/LostReportSettings.tsx b/mobile/src/components/lost/LostReportSettings.tsx index 32aaacc..9bb227e 100644 --- a/mobile/src/components/lost/LostReportSettings.tsx +++ b/mobile/src/components/lost/LostReportSettings.tsx @@ -266,7 +266,7 @@ function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); return StyleSheet.create({ teaser: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.hairlineGray, marginBottom: 8 }, - teaserText: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + teaserText: { ...typography.caption }, teaserButton: { flexDirection: "row", alignItems: "center", @@ -281,8 +281,8 @@ function makeStyles(colors: ColorTokens) { card: { gap: 8, padding: 12, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.alertRedTint, marginBottom: 8 }, headingRow: { flexDirection: "row", alignItems: "center", gap: 6 }, heading: { ...typography.title, fontSize: 16, flexShrink: 1 }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0 }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 }, + hint: { ...typography.caption }, + label: { ...typography.caption, marginTop: 4 }, link: { ...typography.body, textDecorationLine: "underline" }, row: { flexDirection: "row", gap: 8 }, flex: { flex: 1 }, diff --git a/mobile/src/components/onboarding/ArachnophobiaOptIn.tsx b/mobile/src/components/onboarding/ArachnophobiaOptIn.tsx index c0b32fc..d4b4267 100644 --- a/mobile/src/components/onboarding/ArachnophobiaOptIn.tsx +++ b/mobile/src/components/onboarding/ArachnophobiaOptIn.tsx @@ -84,7 +84,7 @@ function makeStyles(colors: ColorTokens) { }, checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange }, checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 16 }, + hint: { ...typography.caption, marginBottom: 16 }, cta: { marginTop: 4 }, }); } diff --git a/mobile/src/components/onboarding/AvatarStep.tsx b/mobile/src/components/onboarding/AvatarStep.tsx index 0b8c312..8a96be4 100644 --- a/mobile/src/components/onboarding/AvatarStep.tsx +++ b/mobile/src/components/onboarding/AvatarStep.tsx @@ -5,8 +5,7 @@ import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { ImagePlus, Camera } from "lucide-react-native"; import { useTRPC } from "../../api/trpc"; -import { uploadImageToSupabase } from "../../lib/upload"; -import { resizeForUpload } from "../../lib/resize-image"; +import { uploadPickedImage } from "../../lib/upload"; import { PetAvatar } from "../PetAvatar"; import { Button } from "../Button"; import { radii, getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; @@ -46,13 +45,7 @@ export function AvatarStep({ petId, petName, onDone }: Props) { setError(null); try { - const resized = await resizeForUpload(picked.uri, picked.width, picked.height); - const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ - petId, - contentType: resized.mimeType, - fileSize: resized.fileSize, - }); - await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); + const { key } = await uploadPickedImage(picked, (file) => getPresignedUrlMutation.mutateAsync({ petId, ...file })); await confirmUploadMutation.mutateAsync({ petId, key }); } catch { setError(t("onboarding.uploadError")); diff --git a/mobile/src/components/onboarding/OnboardingGuidelines.tsx b/mobile/src/components/onboarding/OnboardingGuidelines.tsx index 2c556ef..1bda3c4 100644 --- a/mobile/src/components/onboarding/OnboardingGuidelines.tsx +++ b/mobile/src/components/onboarding/OnboardingGuidelines.tsx @@ -101,7 +101,7 @@ function makeStyles(colors: ColorTokens) { marginBottom: 16, }, summary: { ...typography.body }, - link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 8 }, + link: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 8 }, cta: { marginTop: 12 }, }); } diff --git a/mobile/src/components/onboarding/PetCreateForm.tsx b/mobile/src/components/onboarding/PetCreateForm.tsx index b5de9d6..b3f3252 100644 --- a/mobile/src/components/onboarding/PetCreateForm.tsx +++ b/mobile/src/components/onboarding/PetCreateForm.tsx @@ -144,7 +144,7 @@ function makeStyles(colors: ColorTokens) { container: { padding: 24, gap: 6 }, title: { ...typography.title, fontSize: 18, marginBottom: 4 }, subtitle: { ...typography.body, color: colors.quietGray, marginBottom: 16 }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 10 }, + label: { ...typography.caption, marginTop: 10 }, multiline: { height: 80, textAlignVertical: "top", paddingTop: 12 }, error: { ...typography.body, color: colors.alertRed, marginTop: 8 }, cta: { marginTop: 16 }, diff --git a/mobile/src/components/onboarding/UploadConsent.tsx b/mobile/src/components/onboarding/UploadConsent.tsx index 741cb4c..8a2b2cf 100644 --- a/mobile/src/components/onboarding/UploadConsent.tsx +++ b/mobile/src/components/onboarding/UploadConsent.tsx @@ -129,7 +129,7 @@ function makeStyles(colors: ColorTokens) { }, checkboxChecked: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange }, checkboxLabel: { ...typography.body, flex: 1, fontSize: 13, lineHeight: 19 }, - link: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 }, + link: { ...typography.caption, color: colors.marigoldDeep, marginBottom: 16, marginTop: 4 }, cta: { marginTop: 4 }, }); } diff --git a/mobile/src/components/pet/MemorialSettings.tsx b/mobile/src/components/pet/MemorialSettings.tsx index 49b910a..4a09634 100644 --- a/mobile/src/components/pet/MemorialSettings.tsx +++ b/mobile/src/components/pet/MemorialSettings.tsx @@ -108,8 +108,8 @@ function makeStyles(colors: ColorTokens) { section: { marginTop: 24, gap: 8 }, headingRow: { flexDirection: "row", alignItems: "center", gap: 6 }, heading: { ...typography.title, fontSize: 16 }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0 }, - label: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 }, + hint: { ...typography.caption }, + label: { ...typography.caption, marginTop: 4 }, error: { ...typography.body, color: colors.alertRed }, }); } diff --git a/mobile/src/components/pet/PrivacySettings.tsx b/mobile/src/components/pet/PrivacySettings.tsx index ec6a01f..9ac6ac6 100644 --- a/mobile/src/components/pet/PrivacySettings.tsx +++ b/mobile/src/components/pet/PrivacySettings.tsx @@ -137,6 +137,6 @@ function makeStyles(colors: ColorTokens) { optionDivider: { borderTopWidth: 1, borderTopColor: colors.hairlineGray }, optionText: { ...typography.body }, optionTextSelected: { fontFamily: typography.title.fontFamily, color: colors.marigoldOrange }, - hint: { ...typography.label, textTransform: "none", letterSpacing: 0 }, + hint: { ...typography.caption }, }); } diff --git a/mobile/src/components/settings/AppearanceSection.tsx b/mobile/src/components/settings/AppearanceSection.tsx index 374387d..9fcfb91 100644 --- a/mobile/src/components/settings/AppearanceSection.tsx +++ b/mobile/src/components/settings/AppearanceSection.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { Sun, Moon, SunMoon } from "lucide-react-native"; import { useTheme } from "../../context/ThemeContext"; import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; -import { sectionTitleStyle } from "./sectionTitleStyle"; /** * Darstellung (light/dark/auto) toggle. Extracted from pets.tsx's former @@ -49,7 +48,7 @@ export function AppearanceSection() { function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); return StyleSheet.create({ - sectionTitle: sectionTitleStyle(colors), + sectionTitle: { ...typography.caption, marginBottom: 10 }, row: { flexDirection: "row", gap: 8 }, option: { flex: 1, diff --git a/mobile/src/components/settings/InviteFriendsSection.tsx b/mobile/src/components/settings/InviteFriendsSection.tsx index be7d9c7..f11251d 100644 --- a/mobile/src/components/settings/InviteFriendsSection.tsx +++ b/mobile/src/components/settings/InviteFriendsSection.tsx @@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next"; import { ChevronRight, Gift } from "lucide-react-native"; import { useTheme } from "../../context/ThemeContext"; import { getTypography, type ColorTokens } from "../../theme/tokens"; -import { sectionTitleStyle } from "./sectionTitleStyle"; /** Einstieg zum Empfehlungsprogramm (Bildschirm /invite) aus den Einstellungen. */ export function InviteFriendsSection() { @@ -29,7 +28,7 @@ export function InviteFriendsSection() { function makeStyles(colors: ColorTokens) { const typography = getTypography(colors); return StyleSheet.create({ - sectionTitle: sectionTitleStyle(colors), + sectionTitle: { ...typography.caption, marginBottom: 10 }, row: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 6 }, label: { ...typography.body, flex: 1 }, }); diff --git a/mobile/src/components/settings/LanguageSection.tsx b/mobile/src/components/settings/LanguageSection.tsx index 16af3f6..aa8b005 100644 --- a/mobile/src/components/settings/LanguageSection.tsx +++ b/mobile/src/components/settings/LanguageSection.tsx @@ -3,8 +3,7 @@ import { View, Text, StyleSheet } from "react-native"; import { useTranslation } from "react-i18next"; import { useTheme } from "../../context/ThemeContext"; import { LanguageSwitcher } from "../LanguageSwitcher"; -import type { ColorTokens } from "../../theme/tokens"; -import { sectionTitleStyle } from "./sectionTitleStyle"; +import { getTypography, type ColorTokens } from "../../theme/tokens"; /** * Section chrome (title + spacing) around the shared LanguageSwitcher — see @@ -26,6 +25,6 @@ export function LanguageSection() { function makeStyles(colors: ColorTokens) { return StyleSheet.create({ - sectionTitle: sectionTitleStyle(colors), + sectionTitle: { ...getTypography(colors).caption, marginBottom: 10 }, }); } diff --git a/mobile/src/components/settings/LegalSection.tsx b/mobile/src/components/settings/LegalSection.tsx index aee39ae..f59d947 100644 --- a/mobile/src/components/settings/LegalSection.tsx +++ b/mobile/src/components/settings/LegalSection.tsx @@ -69,6 +69,6 @@ function makeStyles(colors: ColorTokens) { return StyleSheet.create({ section: { gap: 12, alignItems: "center" }, legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep }, - versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 }, + versionText: { ...typography.caption, marginTop: 4 }, }); } diff --git a/mobile/src/components/settings/PetSuggestionsSection.tsx b/mobile/src/components/settings/PetSuggestionsSection.tsx index b09920c..85046d2 100644 --- a/mobile/src/components/settings/PetSuggestionsSection.tsx +++ b/mobile/src/components/settings/PetSuggestionsSection.tsx @@ -5,7 +5,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "../../api/trpc"; import { useTheme } from "../../context/ThemeContext"; import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens"; -import { sectionTitleStyle } from "./sectionTitleStyle"; /** * Opt-out for the PET_SUGGESTION cron notification @@ -53,7 +52,7 @@ export function PetSuggestionsSection() { function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); return StyleSheet.create({ - sectionTitle: sectionTitleStyle(colors), + sectionTitle: { ...typography.caption, marginBottom: 10 }, toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 }, toggleText: { flex: 1 }, toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 }, diff --git a/mobile/src/components/settings/sectionTitleStyle.ts b/mobile/src/components/settings/sectionTitleStyle.ts deleted file mode 100644 index 18f0d07..0000000 --- a/mobile/src/components/settings/sectionTitleStyle.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getTypography, type ColorTokens } from "../../theme/tokens"; - -/** - * Shared "section label" chrome (the small uppercase-ish title above a - * Settings-screen section, e.g. "DARSTELLUNG"/"SPRACHE"). Extracted so - * AppearanceSection/LanguageSection/PetSuggestionsSection can't drift out of - * sync on this — they all had the exact same style object copy-pasted - * before this file existed (found during code review of the Settings-screen - * extraction, see mobile/found.md). - */ -export function sectionTitleStyle(colors: ColorTokens) { - const typography = getTypography(colors); - return { ...typography.label, textTransform: "none" as const, letterSpacing: 0, marginBottom: 10 }; -} diff --git a/mobile/src/components/stories/StoryPawButton.tsx b/mobile/src/components/stories/StoryPawButton.tsx index 029a805..e1ab894 100644 --- a/mobile/src/components/stories/StoryPawButton.tsx +++ b/mobile/src/components/stories/StoryPawButton.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; import { Pressable, Text, View, StyleSheet } from "react-native"; import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { PawPrint } from "lucide-react-native"; import { useTRPC } from "../../api/trpc"; +import { usePawToggle } from "../../hooks/usePawToggle"; import { colors } from "../../theme/tokens"; type Props = { @@ -14,53 +14,29 @@ type Props = { }; /** - * Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch: Zustand - * und Zähler springen sofort um und werden bei einem Fehler zurückgesetzt. - * Der Viewer setzt `key={story.id}`, damit der Zustand pro Story neu startet. + * Port of web's StoryPawButton — Pfote auf einer Story. Optimistisch über + * usePawToggle (gemeinsam mit PawButton). Der Viewer setzt `key={story.id}`, + * damit der Zustand pro Story neu startet. */ export function StoryPawButton({ storyId, petId, initialCount, initialReacted }: Props) { const { t } = useTranslation(); const trpc = useTRPC(); - const [reacted, setReacted] = useState(initialReacted); - const [count, setCount] = useState(initialCount); - - const toggle = useMutation( - trpc.stories.toggleReaction.mutationOptions({ - onMutate: () => { - const previous = { reacted, count }; - setReacted(!reacted); - setCount(reacted ? Math.max(0, count - 1) : count + 1); - return previous; - }, - // Der Server ist maßgeblich: Zähler aus dem Stand vor dem Tippen + tatsächlicher Änderung. - onSuccess: (data, _vars, previous) => { - setReacted(data.reacted); - if (!previous) return; - const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1; - setCount(Math.max(0, previous.count + delta)); - }, - onError: (_err, _vars, previous) => { - if (previous) { - setReacted(previous.reacted); - setCount(previous.count); - } - }, - }) - ); + const paw = usePawToggle(initialReacted, initialCount); + const toggle = useMutation(trpc.stories.toggleReaction.mutationOptions(paw.callbacks)); return ( !toggle.isPending && toggle.mutate({ storyId, petId })} accessibilityRole="button" - accessibilityLabel={reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")} - accessibilityState={{ selected: reacted }} + accessibilityLabel={paw.reacted ? t("storyViewer.removePaw") : t("storyViewer.paw")} + accessibilityState={{ selected: paw.reacted }} hitSlop={8} style={styles.wrap} > - + - {count > 0 && {count}} + {paw.count > 0 && {paw.count}} ); } diff --git a/mobile/src/hooks/usePawToggle.ts b/mobile/src/hooks/usePawToggle.ts new file mode 100644 index 0000000..38dda0a --- /dev/null +++ b/mobile/src/hooks/usePawToggle.ts @@ -0,0 +1,30 @@ +import { useState } from "react"; + +type PawState = { reacted: boolean; count: number }; + +/** + * Optimistic paw toggle shared by PawButton (posts) and StoryPawButton: + * state and count flip immediately, the server's `reacted` decides the final + * count (state before the tap + actual change), errors roll back. Pass + * `callbacks` into the tRPC `mutationOptions`. + */ +export function usePawToggle(initialReacted: boolean, initialCount: number) { + const [state, setState] = useState({ reacted: initialReacted, count: initialCount }); + + const callbacks = { + onMutate: (): PawState => { + setState({ reacted: !state.reacted, count: state.reacted ? Math.max(0, state.count - 1) : state.count + 1 }); + return state; + }, + onSuccess: (data: { reacted: boolean }, _vars: unknown, previous: PawState | undefined) => { + if (!previous) return; + const delta = data.reacted === previous.reacted ? 0 : data.reacted ? 1 : -1; + setState({ reacted: data.reacted, count: Math.max(0, previous.count + delta) }); + }, + onError: (_err: unknown, _vars: unknown, previous: PawState | undefined) => { + if (previous) setState(previous); + }, + }; + + return { ...state, callbacks }; +} diff --git a/mobile/src/lib/upload.ts b/mobile/src/lib/upload.ts index 16c967c..94c6996 100644 --- a/mobile/src/lib/upload.ts +++ b/mobile/src/lib/upload.ts @@ -1,32 +1,13 @@ import { File } from "expo-file-system"; +import { resizeForUpload, type ResizedImage } from "./resize-image"; /** - * PUTs a local picked-image file directly to the Supabase Storage presigned - * URL returned by `posts.getPresignedUrl` — mirrors web's XHR PUT in - * `src/lib/upload-xhr.ts`, using expo-file-system's `File.upload` instead - * since RN has no XHR-with-progress primitive by default. Content-Type must - * exactly match what was passed to `getPresignedUrl` or Supabase responds - * with 400 SignatureDoesNotMatch. - */ -export async function uploadImageToSupabase(uri: string, presignedUrl: string, mimeType: string): Promise { - const file = new File(uri); - const result = await file.upload(presignedUrl, { - httpMethod: "PUT", - mimeType, - headers: { "Content-Type": mimeType }, - }); - - if (result.status < 200 || result.status >= 300) { - throw new Error(`Upload fehlgeschlagen (Status ${result.status})`); - } -} - -/** - * PUTs a local video file directly to a Mux direct-upload URL (from - * `stories.createVideoUpload`). Separate function rather than reusing - * `uploadImageToSupabase` above — same `File.upload` primitive, but a - * different destination (Mux, not Supabase) and no signature-matching - * requirement on Content-Type, so keeping the working photo path untouched. + * PUTs a local file directly to a presigned upload URL — Supabase Storage + * (photos, mirrors web's XHR PUT in `src/lib/upload-xhr.ts`) or Mux (story + * videos). Uses expo-file-system's `File.upload` since RN has no + * XHR-with-progress primitive by default. For Supabase, Content-Type must + * exactly match what was passed to `getPresignedUrl` or it responds with + * 400 SignatureDoesNotMatch. */ export async function uploadFileToUrl( uri: string, @@ -47,6 +28,21 @@ export async function uploadFileToUrl( }); if (result.status < 200 || result.status >= 300) { - throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`); + throw new Error(`Upload fehlgeschlagen (Status ${result.status})`); } } + +/** + * Picked photo → downscaled JPEG → presigned URL → Supabase PUT. `getUploadUrl` + * is the caller's `*.getPresignedUrl` mutation (posts, stories or media), with + * the petId already bound. Returns the storage key and the uploaded file's uri. + */ +export async function uploadPickedImage( + asset: { uri: string; width: number; height: number }, + getUploadUrl: (file: { contentType: ResizedImage["mimeType"]; fileSize: number }) => Promise<{ presignedUrl: string; key: string }> +): Promise<{ key: string; uri: string }> { + const resized = await resizeForUpload(asset.uri, asset.width, asset.height); + const { presignedUrl, key } = await getUploadUrl({ contentType: resized.mimeType, fileSize: resized.fileSize }); + await uploadFileToUrl(resized.uri, presignedUrl, resized.mimeType); + return { key, uri: resized.uri }; +} diff --git a/mobile/src/theme/tokens.ts b/mobile/src/theme/tokens.ts index 1ccfa19..cb11f88 100644 --- a/mobile/src/theme/tokens.ts +++ b/mobile/src/theme/tokens.ts @@ -138,6 +138,13 @@ export function getTypography(theme: ColorTokens) { textTransform: "uppercase" as const, color: theme.quietGray, }, + /** Like `label`, but as written (no uppercase, no extra tracking). */ + caption: { + fontFamily: fontFamily.semiBold, + fontSize: 12, + lineHeight: 16, + color: theme.quietGray, + }, }; }