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,
+ },
};
}