refactor(mobile): Audit O1 – doppelte Helfer zusammengelegt (M1–M3, M5–M7)

- M1: typography.caption statt 44× label ohne Großschreibung; sectionTitleStyle.ts entfällt
- M2: uploadPickedImage() für verkleinern → presign → PUT (Post, Meilenstein, Story, Avatar)
- M3: uploadImageToSupabase in uploadFileToUrl aufgegangen
- M5: usePawToggle für PawButton + StoryPawButton (Beitrags-Pfote zählt jetzt sofort mit)
- M6: healthSectionStyles für Impfung, Tierarzt, Gewicht
- M7: unbenutzten Typ PostDetail entfernt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 18:18:44 +02:00
1 parent 1c6c95955e
commit 9f34f0e5c3
44 files changed
+162 -225

No files matched your search

+2 -2
View File
@@ -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 },
});
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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 },
@@ -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",
+1 -1
View File
@@ -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,
+4 -11
View File
@@ -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 },
});
+1 -1
View File
@@ -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: {
+2 -2
View File
@@ -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 },
+1 -1
View File
@@ -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 },
+2 -9
View File
@@ -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 };
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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 },
+4 -11
View File
@@ -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,
-1
View File
@@ -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"];
+7 -32
View File
@@ -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 (
<Pressable
@@ -56,10 +31,10 @@ export function PawButton({ postId, petId, initialCount }: Props) {
>
<PawPrint
size={20}
color={reacted ? colors.marigoldOrange : colors.quietGray}
fill={reacted ? colors.marigoldOrange : "transparent"}
color={paw.reacted ? colors.marigoldOrange : colors.quietGray}
fill={paw.reacted ? colors.marigoldOrange : "transparent"}
/>
<Text style={styles.count}>{count}</Text>
<Text style={styles.count}>{paw.count}</Text>
</Pressable>
);
}
+2 -2
View File
@@ -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 },
+1 -1
View File
@@ -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 },
+1 -1
View File
@@ -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 },
});
@@ -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 },
});
}
@@ -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,
@@ -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 },
});
}
@@ -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 },
});
}
@@ -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 },
});
}
+2 -2
View File
@@ -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 },
});
}
@@ -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" },
});
}
@@ -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 },
});
}
@@ -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 },
@@ -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 },
});
}
@@ -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"));
@@ -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 },
});
}
@@ -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 },
@@ -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 },
});
}
@@ -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 },
});
}
@@ -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 },
});
}
@@ -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,
@@ -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 },
});
@@ -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 },
});
}
@@ -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 },
});
}
@@ -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 },
@@ -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 };
}
@@ -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 (
<Pressable
onPress={() => !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}
>
<View style={styles.circle}>
<PawPrint size={20} color={reacted ? colors.marigoldOrange : colors.pureWhite} fill={reacted ? colors.marigoldOrange : "transparent"} />
<PawPrint size={20} color={paw.reacted ? colors.marigoldOrange : colors.pureWhite} fill={paw.reacted ? colors.marigoldOrange : "transparent"} />
</View>
{count > 0 && <Text style={styles.count}>{count}</Text>}
{paw.count > 0 && <Text style={styles.count}>{paw.count}</Text>}
</Pressable>
);
}
+30
View File
@@ -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<PawState>({ 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 };
}
+23 -27
View File
@@ -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<void> {
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 };
}
+7
View File
@@ -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,
},
};
}