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:
co-authored by
Claude Opus 4.8
parent
4be1cf5fa3
commit
48a8728bbd
@@ -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,6 +95,18 @@ export function CardViewer({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
back={
|
back={
|
||||||
|
backContent ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: cardWidth,
|
||||||
|
height: cardHeight,
|
||||||
|
borderRadius: radius,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backContent}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
<CardFace
|
<CardFace
|
||||||
side={card.back}
|
side={card.back}
|
||||||
width={cardWidth}
|
width={cardWidth}
|
||||||
@@ -95,6 +114,7 @@ export function CardViewer({
|
|||||||
borderRadius={radius}
|
borderRadius={radius}
|
||||||
profileUrl={profileUrl}
|
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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user