mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare, catalogue or custom. The recipient captures and uploads the proof as a new couple-scoped 'dare_proof' media kind and attaches it on submit; the server enforces ownership, kind, couple, and MIME-type match. The proof renders inline on the dare card for the sender to review before confirming completion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f22d4a3649
commit
504eddaf68
21 files changed
+668
-28
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Audio } from "expo-av";
|
||||
import { getMediaUrl } from "../../../lib/mediaApi";
|
||||
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
|
||||
import type { DareProofType } from "../types";
|
||||
|
||||
const ACCENT = featureColors.dares.accent;
|
||||
|
||||
export interface DareProofViewProps {
|
||||
proofMediaId: string;
|
||||
proofType: DareProofType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the attached proof inline on a dare card: the photo itself, or a
|
||||
* play button for audio. Signed URLs are short-lived, so the URL is resolved
|
||||
* lazily on first render (photo) or first play (audio).
|
||||
*/
|
||||
export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [sound, setSound] = useState<Audio.Sound | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [loadingAudio, setLoadingAudio] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (proofType !== "photo") return;
|
||||
let cancelled = false;
|
||||
getMediaUrl(proofMediaId)
|
||||
.then((result) => { if (!cancelled) setUrl(result.url); })
|
||||
.catch(() => { if (!cancelled) setFailed(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [proofMediaId, proofType]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (sound) void sound.unloadAsync(); };
|
||||
}, [sound]);
|
||||
|
||||
const togglePlayback = useCallback(async () => {
|
||||
try {
|
||||
if (sound) {
|
||||
const status = await sound.getStatusAsync();
|
||||
if (status.isLoaded && status.isPlaying) {
|
||||
await sound.pauseAsync();
|
||||
setPlaying(false);
|
||||
} else {
|
||||
await sound.replayAsync();
|
||||
setPlaying(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setLoadingAudio(true);
|
||||
const { url: signedUrl } = await getMediaUrl(proofMediaId);
|
||||
const created = await Audio.Sound.createAsync(
|
||||
{ uri: signedUrl },
|
||||
{ shouldPlay: true },
|
||||
(status) => {
|
||||
if (status.isLoaded && status.didJustFinish) setPlaying(false);
|
||||
},
|
||||
);
|
||||
setSound(created.sound);
|
||||
setPlaying(true);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoadingAudio(false);
|
||||
}
|
||||
}, [sound, proofMediaId]);
|
||||
|
||||
if (failed) {
|
||||
return <Text style={styles.failed}>Proof could not be loaded.</Text>;
|
||||
}
|
||||
|
||||
if (proofType === "photo") {
|
||||
return url ? (
|
||||
<Image source={{ uri: url }} style={styles.photo} resizeMode="cover" />
|
||||
) : (
|
||||
<ActivityIndicator size="small" color={ACCENT} style={styles.loader} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={styles.audioRow} onPress={() => void togglePlayback()} disabled={loadingAudio}>
|
||||
{loadingAudio ? (
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
) : (
|
||||
<Ionicons name={playing ? "pause-circle" : "play-circle"} size={28} color={ACCENT} />
|
||||
)}
|
||||
<Text style={styles.audioLabel}>{playing ? "Playing proof…" : "Play audio proof"}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
photo: {
|
||||
width: "100%", height: 180, marginTop: spacing.sm,
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
||||
},
|
||||
loader: { marginTop: spacing.sm, alignSelf: "flex-start" },
|
||||
audioRow: {
|
||||
flexDirection: "row", alignItems: "center", gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
audioLabel: { ...typography.subhead, color: colors.text, fontWeight: "600" },
|
||||
failed: { ...typography.footnote, color: colors.textTertiary, marginTop: spacing.sm },
|
||||
});
|
||||
Reference in new issue
Block a user