Mobile: card back template, single QR, video face, QR crash/seam fix

- CardBackTemplate: organized back — identity header, one hero "Scan to view
  everything" QR to the profile URL, and a labeled link list (domain, GitHub,
  publications, services). One QR per card, not per link.
- QRCodeView: snap modules to integer pixels + center the grid (kills the white
  sub-pixel seams), and guard empty/invalid input (the qrcode lib throws
  "No input text" on "" — that crashed the whole render). Renders a blank box
  instead, wrapped in try/catch.
- CardFace: video background support (expo-av, looping/muted/cover) via a new
  "video" BackgroundConfig type.
- CardViewer: optional backContent override for the rich back; CardFlip:
  onSideChange so the viewer can show controls only on the back.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-05 12:56:30 -05:00
co-authored by Claude Opus 4.8
parent 4be1cf5fa3
commit 48a8728bbd
6 changed files with 208 additions and 23 deletions
@@ -0,0 +1,120 @@
// Organized back-of-card theme (PRD §6.3): identity header, a primary "scan to
// connect" QR, and a list of labeled sub-QR links (domain, GitHub, publications,
// any service) people can quickly scan.
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { CardLink, LinkKind } from "../../stores/localCardsStore";
import { QRCodeView } from "./QRCodeView";
const KIND_ICON: Record<LinkKind, keyof typeof MaterialCommunityIcons.glyphMap> = {
domain: "web",
github: "github",
publication: "book-open-page-variant",
link: "link-variant",
};
/** Strip the scheme/trailing slash for a compact display URL. */
function prettyUrl(url: string): string {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
export function CardBackTemplate({
name,
title,
profileUrl,
links,
}: {
name: string;
title: string;
profileUrl: string;
links: CardLink[];
}) {
// Only show links that actually have a URL (empty ones would be blank QRs).
const shown = links.filter((l) => l.url.trim());
return (
<View style={styles.root}>
<View style={styles.header}>
<Text style={styles.name} numberOfLines={1}>
{name}
</Text>
{!!title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
</View>
<View style={styles.hero}>
<View style={styles.heroQr}>
<QRCodeView value={profileUrl} size={132} />
</View>
<Text style={styles.heroCaption}>Scan to view everything</Text>
<Text style={styles.heroUrl} numberOfLines={1}>
{prettyUrl(profileUrl)}
</Text>
</View>
<View style={styles.divider} />
<ScrollView
style={styles.links}
contentContainerStyle={styles.linksContent}
showsVerticalScrollIndicator={false}
>
{shown.map((link) => (
<View key={link.id} style={styles.row}>
<View style={styles.iconWrap}>
<MaterialCommunityIcons name={KIND_ICON[link.kind]} size={20} color="#f5f5f7" />
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{link.label}
</Text>
<Text style={styles.rowUrl} numberOfLines={1}>
{prettyUrl(link.url)}
</Text>
</View>
</View>
))}
{shown.length === 0 && (
<Text style={styles.empty}>Add links in the editor to show scannable codes here.</Text>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#0e0e12", paddingHorizontal: 22, paddingVertical: 28 },
header: { marginBottom: 16 },
name: { color: "#ffffff", fontSize: 24, fontWeight: "800" },
title: { color: "#9a9aa0", fontSize: 15, marginTop: 2 },
hero: { alignItems: "center", marginBottom: 18 },
heroQr: { backgroundColor: "#ffffff", borderRadius: 14, padding: 10 },
heroCaption: { color: "#f5f5f7", fontSize: 15, fontWeight: "600", marginTop: 10 },
heroUrl: { color: "#6b6b70", fontSize: 12, marginTop: 2 },
divider: { height: StyleSheet.hairlineWidth, backgroundColor: "#2a2a30", marginBottom: 6 },
links: { flex: 1 },
linksContent: { gap: 10, paddingVertical: 8 },
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
backgroundColor: "#16161c",
borderRadius: 14,
padding: 10,
},
iconWrap: {
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: "#26262e",
alignItems: "center",
justifyContent: "center",
},
rowText: { flex: 1 },
rowLabel: { color: "#f5f5f7", fontSize: 15, fontWeight: "700" },
rowUrl: { color: "#8a8a90", fontSize: 12, marginTop: 1 },
empty: { color: "#6b6b70", fontSize: 13, textAlign: "center", paddingVertical: 24 },
});
@@ -2,6 +2,7 @@
// layer using its fractional geometry. Background / text / logo / contact layers // layer using its fractional geometry. Background / text / logo / contact layers
// are supported in Phase 1 (Skia is reserved for the Pro shader/particle layers). // are supported in Phase 1 (Skia is reserved for the Pro shader/particle layers).
import { ResizeMode, Video } from "expo-av";
import { Image, StyleSheet, Text, View } from "react-native"; import { Image, StyleSheet, Text, View } from "react-native";
import { import {
BackgroundConfig, BackgroundConfig,
@@ -49,6 +50,15 @@ function backgroundImageUri(bg: BackgroundConfig): string | null {
return null; return null;
} }
/// Resolve a video background to a displayable URI (a local file:// for the demo
/// or an asset key). Rendered as a looping, muted, cover-fit clip.
function backgroundVideoUri(bg: BackgroundConfig): string | null {
if (bg.type !== "video") return null;
if (bg.value) return bg.value;
if (bg.r2Key) return assetUri(bg.r2Key);
return null;
}
function LayerView({ function LayerView({
layer, layer,
width, width,
@@ -152,11 +162,22 @@ function LayerView({
export function CardFace({ side, width, height, borderRadius = 24, profileUrl }: Props) { export function CardFace({ side, width, height, borderRadius = 24, profileUrl }: Props) {
const ordered = [...side.layers].sort((a, b) => a.zIndex - b.zIndex); const ordered = [...side.layers].sort((a, b) => a.zIndex - b.zIndex);
const bgImage = backgroundImageUri(side.background); const bgImage = backgroundImageUri(side.background);
const bgVideo = backgroundVideoUri(side.background);
return ( return (
<View style={[styles.face, { width, height, borderRadius }, backgroundStyle(side.background)]}> <View style={[styles.face, { width, height, borderRadius }, backgroundStyle(side.background)]}>
{bgImage && ( {bgImage && (
<Image source={{ uri: bgImage }} style={StyleSheet.absoluteFill} resizeMode="cover" /> <Image source={{ uri: bgImage }} style={StyleSheet.absoluteFill} resizeMode="cover" />
)} )}
{bgVideo && (
<Video
source={{ uri: bgVideo }}
style={StyleSheet.absoluteFill}
resizeMode={ResizeMode.COVER}
isLooping
shouldPlay
isMuted
/>
)}
{ordered.map((layer) => ( {ordered.map((layer) => (
<LayerView <LayerView
key={layer.id} key={layer.id}
@@ -26,6 +26,8 @@ interface Props {
durationMs?: number; durationMs?: number;
hapticEnabled?: boolean; hapticEnabled?: boolean;
gesture?: "swipe" | "doubleTap" | "both"; gesture?: "swipe" | "doubleTap" | "both";
/** Fired (on the JS thread) when the visible side changes; true = showing back. */
onSideChange?: (isBack: boolean) => void;
} }
export function CardFlip({ export function CardFlip({
@@ -36,6 +38,7 @@ export function CardFlip({
durationMs = 400, durationMs = 400,
hapticEnabled = true, hapticEnabled = true,
gesture = "both", gesture = "both",
onSideChange,
}: Props) { }: Props) {
// 0 = front, 1 = back. // 0 = front, 1 = back.
const progress = useSharedValue(0); const progress = useSharedValue(0);
@@ -44,12 +47,18 @@ export function CardFlip({
if (hapticEnabled) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); if (hapticEnabled) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}, [hapticEnabled]); }, [hapticEnabled]);
const notifySide = useCallback(
(isBack: boolean) => onSideChange?.(isBack),
[onSideChange],
);
const toggle = useCallback(() => { const toggle = useCallback(() => {
"worklet"; "worklet";
const next = progress.value < 0.5 ? 1 : 0; const next = progress.value < 0.5 ? 1 : 0;
progress.value = withTiming(next, { duration: durationMs, easing: FLIP_EASING }); progress.value = withTiming(next, { duration: durationMs, easing: FLIP_EASING });
runOnJS(fireHaptic)(); runOnJS(fireHaptic)();
}, [durationMs, fireHaptic, progress]); runOnJS(notifySide)(next === 1);
}, [durationMs, fireHaptic, notifySide, progress]);
const tap = Gesture.Tap().onEnd(toggle); const tap = Gesture.Tap().onEnd(toggle);
const swipe = Gesture.Fling() const swipe = Gesture.Fling()
@@ -5,7 +5,7 @@
// Sizing: by default the card is a centered, rounded artifact. In `fullScreen` // Sizing: by default the card is a centered, rounded artifact. In `fullScreen`
// mode it fills the entire device window (PRD §5.2 "Full-bleed always"). // mode it fills the entire device window (PRD §5.2 "Full-bleed always").
import { useEffect } from "react"; import { ReactNode, useEffect } from "react";
import { StyleSheet, useWindowDimensions, View } from "react-native"; import { StyleSheet, useWindowDimensions, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -25,12 +25,18 @@ export function CardViewer({
card, card,
profileUrl: profileUrlOverride, profileUrl: profileUrlOverride,
fullScreen = false, fullScreen = false,
onSideChange,
backContent,
}: { }: {
card: CardDefinition; card: CardDefinition;
/** Override the QR target; defaults to the card's public profile URL. */ /** Override the QR target; defaults to the card's public profile URL. */
profileUrl?: string; profileUrl?: string;
/** Fill the entire device window instead of a centered, rounded card. */ /** Fill the entire device window instead of a centered, rounded card. */
fullScreen?: boolean; fullScreen?: boolean;
/** Fired when the visible side changes; true = showing back. */
onSideChange?: (isBack: boolean) => void;
/** Custom back face (e.g. the structured back template); replaces card.back. */
backContent?: ReactNode;
}) { }) {
// useWindowDimensions is the live size of the app's drawable area; it updates // useWindowDimensions is the live size of the app's drawable area; it updates
// on rotation/resize. Full-screen cards use it verbatim. // on rotation/resize. Full-screen cards use it verbatim.
@@ -78,6 +84,7 @@ export function CardViewer({
durationMs={card.settings.flipDurationMs} durationMs={card.settings.flipDurationMs}
hapticEnabled={card.settings.hapticEnabled} hapticEnabled={card.settings.hapticEnabled}
gesture={card.settings.flipGesture} gesture={card.settings.flipGesture}
onSideChange={onSideChange}
front={ front={
<CardFace <CardFace
side={card.face} side={card.face}
@@ -88,13 +95,26 @@ export function CardViewer({
/> />
} }
back={ back={
<CardFace backContent ? (
side={card.back} <View
width={cardWidth} style={{
height={cardHeight} width: cardWidth,
borderRadius={radius} height: cardHeight,
profileUrl={profileUrl} borderRadius: radius,
/> overflow: "hidden",
}}
>
{backContent}
</View>
) : (
<CardFace
side={card.back}
width={cardWidth}
height={cardHeight}
borderRadius={radius}
profileUrl={profileUrl}
/>
)
} }
/> />
</Animated.View> </Animated.View>
@@ -21,34 +21,49 @@ export function QRCodeView({
color = "#000000", color = "#000000",
background = "#ffffff", background = "#ffffff",
}: Props) { }: Props) {
const matrix = useMemo(() => qrMatrix(value), [value]); // Guard empty/invalid input: the qrcode library throws "No input text" on an
// empty string, which would crash the whole render. Render a blank box instead.
const matrix = useMemo(() => {
try {
return value && value.trim() ? qrMatrix(value) : [];
} catch {
return [];
}
}, [value]);
if (matrix.length === 0) {
return <View style={[styles.root, { width: size, height: size, backgroundColor: background }]} />;
}
const modules = matrix.length + quietZone * 2; const modules = matrix.length + quietZone * 2;
const cell = size / modules; // Integer cell size so modules tile exactly — fractional cells leave
// sub-pixel seams that show as white lines through the code. Center the
// (slightly smaller) grid inside the requested size.
const cell = Math.max(1, Math.floor(size / modules));
const rendered = cell * modules;
const pad = Math.round((size - rendered) / 2);
return ( return (
<View style={[styles.root, { width: size, height: size, backgroundColor: background }]}> <View style={[styles.root, { width: size, height: size, backgroundColor: background }]}>
{matrix.map((row, r) => ( {matrix.map((row, r) =>
<View key={r} style={styles.row}> row.map((dark, c) =>
{row.map((dark, c) => ( dark ? (
<View <View
key={c} key={`${r}-${c}`}
style={{ style={{
position: "absolute", position: "absolute",
left: (c + quietZone) * cell, left: pad + (c + quietZone) * cell,
top: (r + quietZone) * cell, top: pad + (r + quietZone) * cell,
width: cell, width: cell,
height: cell, height: cell,
backgroundColor: dark ? color : "transparent", backgroundColor: color,
}} }}
/> />
))} ) : null,
</View> ),
))} )}
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
root: { borderRadius: 8, overflow: "hidden" }, root: { borderRadius: 8, overflow: "hidden" },
row: { ...StyleSheet.absoluteFillObject },
}); });
+1 -1
View File
@@ -84,7 +84,7 @@ export interface GradientStop {
} }
export interface BackgroundConfig { export interface BackgroundConfig {
type: "solid" | "gradient" | "image"; type: "solid" | "gradient" | "image" | "video";
value?: string; value?: string;
r2Key?: string; r2Key?: string;
stops?: GradientStop[]; stops?: GradientStop[];