Mobile: standalone photo demo + full-bleed full-screen cards
- New /demo flow (no login/backend): take/pick a photo → card front; swipe → back reveals QR + name/title. Built in-memory on device. - CardFace renders image backgrounds (local URI or asset key) + borderRadius prop - CardViewer `fullScreen` mode fills the device window (useWindowDimensions), radius 0, status bar hidden; applied to the demo and the real card viewer - Landing screen offers the demo without signing in - Added expo-image-picker (camera/photo perms); pinned expo-constants/expo-linking to SDK 51 (npm had resolved newer-SDK majors that broke the Gradle build) - Native android/ is gitignored (regenerated by `expo prebuild`) Built and deployed to a physical Android device (BUILD SUCCESSFUL). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
826ef69030
commit
bc6f158f43
@@ -0,0 +1,18 @@
|
|||||||
|
# Expo / Metro
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Native projects are generated by `expo prebuild` (CNG) — not committed.
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||||
|
# The following patterns were generated by expo-cli
|
||||||
|
|
||||||
|
expo-env.d.ts
|
||||||
|
# @end expo-cli
|
||||||
@@ -15,7 +15,16 @@ const config: ExpoConfig = {
|
|||||||
android: {
|
android: {
|
||||||
package: "com.cardclaws.app",
|
package: "com.cardclaws.app",
|
||||||
},
|
},
|
||||||
plugins: ["expo-router"],
|
plugins: [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
photosPermission: "CardClaws uses your photos to set your card image.",
|
||||||
|
cameraPermission: "CardClaws uses the camera to capture your card image.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
experiments: {
|
experiments: {
|
||||||
typedRoutes: true,
|
typedRoutes: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function CardViewScreen() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CardViewer card={data.definition} />
|
<CardViewer card={data.definition} fullScreen />
|
||||||
<ShareSheet
|
<ShareSheet
|
||||||
cardId={cardId}
|
cardId={cardId}
|
||||||
handle={data.handle}
|
handle={data.handle}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Standalone, no-login demo (no backend needed): take/pick a photo → it's your
|
||||||
|
// card front; swipe → the back reveals a QR code + your info. Everything is
|
||||||
|
// built in-memory on the device.
|
||||||
|
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { CardViewer } from "../src/components/card/CardViewer";
|
||||||
|
import { newLayerId } from "../src/stores/cardStore";
|
||||||
|
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
|
||||||
|
|
||||||
|
function textLayer(text: string, y: number, size: number, color: string): TextLayer {
|
||||||
|
return {
|
||||||
|
id: newLayerId(),
|
||||||
|
type: "text",
|
||||||
|
x: 0.08,
|
||||||
|
y,
|
||||||
|
width: 0.84,
|
||||||
|
height: 0.12,
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: 10,
|
||||||
|
text,
|
||||||
|
fontFamily: "System",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: size,
|
||||||
|
lineHeight: size + 4,
|
||||||
|
letterSpacing: 0,
|
||||||
|
color,
|
||||||
|
align: "left",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the demo card in memory: photo as the full-bleed face, QR + info on the
|
||||||
|
/// back.
|
||||||
|
function buildDemoCard(imageUri: string, name: string, title: string): CardDefinition {
|
||||||
|
return {
|
||||||
|
id: "demo",
|
||||||
|
ownerId: "demo",
|
||||||
|
handle: "demo",
|
||||||
|
version: 1,
|
||||||
|
face: {
|
||||||
|
layers: [
|
||||||
|
textLayer(name, 0.78, 30, "#ffffff"),
|
||||||
|
title ? textLayer(title, 0.87, 18, "#f0f0f2") : textLayer("", 0.87, 1, "#ffffff"),
|
||||||
|
],
|
||||||
|
background: { type: "image", value: imageUri },
|
||||||
|
entryAnimation: "fade",
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: newLayerId(),
|
||||||
|
type: "qr",
|
||||||
|
x: 0.27,
|
||||||
|
y: 0.14,
|
||||||
|
width: 0.46,
|
||||||
|
height: 0.31,
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: 5,
|
||||||
|
},
|
||||||
|
textLayer(name, 0.58, 26, "#f5f5f7"),
|
||||||
|
title ? textLayer(title, 0.67, 17, "#9a9aa0") : textLayer("", 0.67, 1, "#fff"),
|
||||||
|
],
|
||||||
|
background: { type: "solid", value: "#101014" },
|
||||||
|
entryAnimation: "fade",
|
||||||
|
},
|
||||||
|
palette: { colors: [] },
|
||||||
|
settings: DEFAULT_SETTINGS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DemoScreen() {
|
||||||
|
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState("Omar Sobh");
|
||||||
|
const [title, setTitle] = useState("Founder & CEO");
|
||||||
|
const [url, setUrl] = useState("https://cardclaws.com/omar");
|
||||||
|
const [showing, setShowing] = useState(false);
|
||||||
|
|
||||||
|
const pickFromLibrary = async () => {
|
||||||
|
const res = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [2, 3],
|
||||||
|
quality: 0.9,
|
||||||
|
});
|
||||||
|
if (!res.canceled) setImageUri(res.assets[0].uri);
|
||||||
|
};
|
||||||
|
|
||||||
|
const takePhoto = async () => {
|
||||||
|
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (!perm.granted) return;
|
||||||
|
const res = await ImagePicker.launchCameraAsync({
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [2, 3],
|
||||||
|
quality: 0.9,
|
||||||
|
});
|
||||||
|
if (!res.canceled) setImageUri(res.assets[0].uri);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showing && imageUri) {
|
||||||
|
const card = buildDemoCard(imageUri, name, title);
|
||||||
|
return (
|
||||||
|
<View style={styles.viewerRoot}>
|
||||||
|
{/* Hide the header + status bar so the card is truly full-bleed. */}
|
||||||
|
<Stack.Screen options={{ headerShown: false }} />
|
||||||
|
<StatusBar hidden />
|
||||||
|
<CardViewer card={card} profileUrl={url} fullScreen />
|
||||||
|
<Text style={styles.swipeHint}>Swipe or tap the card to flip →</Text>
|
||||||
|
<Pressable style={styles.editBtn} onPress={() => setShowing(false)}>
|
||||||
|
<Text style={styles.editText}>Edit</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
||||||
|
<Stack.Screen options={{ title: "Card demo" }} />
|
||||||
|
<Text style={styles.h1}>Make your card</Text>
|
||||||
|
|
||||||
|
{imageUri ? (
|
||||||
|
<Image source={{ uri: imageUri }} style={styles.preview} resizeMode="cover" />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.preview, styles.previewEmpty]}>
|
||||||
|
<Text style={styles.previewHint}>Your photo becomes the card front</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.photoRow}>
|
||||||
|
<Pressable style={styles.photoBtn} onPress={takePhoto}>
|
||||||
|
<Text style={styles.photoText}>Take photo</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable style={styles.photoBtn} onPress={pickFromLibrary}>
|
||||||
|
<Text style={styles.photoText}>Choose photo</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Field label="Name" value={name} onChangeText={setName} />
|
||||||
|
<Field label="Title" value={title} onChangeText={setTitle} />
|
||||||
|
<Field label="QR link" value={url} onChangeText={setUrl} autoCapitalize="none" />
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.cta, !imageUri && styles.ctaDisabled]}
|
||||||
|
disabled={!imageUri}
|
||||||
|
onPress={() => setShowing(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.ctaText}>Show my card</Text>
|
||||||
|
</Pressable>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: { label: string } & React.ComponentProps<typeof TextInput>) {
|
||||||
|
return (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
<TextInput {...props} style={styles.input} placeholderTextColor="#6b6b70" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
|
content: { padding: 20, gap: 14 },
|
||||||
|
h1: { color: "#f5f5f7", fontSize: 28, fontWeight: "800" },
|
||||||
|
preview: {
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: 2 / 3,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
maxHeight: 360,
|
||||||
|
alignSelf: "center",
|
||||||
|
},
|
||||||
|
previewEmpty: { alignItems: "center", justifyContent: "center" },
|
||||||
|
previewHint: { color: "#6b6b70" },
|
||||||
|
photoRow: { flexDirection: "row", gap: 12 },
|
||||||
|
photoBtn: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#222228",
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
photoText: { color: "#f5f5f7", fontWeight: "600", fontSize: 16 },
|
||||||
|
field: { gap: 6 },
|
||||||
|
label: { color: "#9a9aa0", fontSize: 14 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
color: "#f5f5f7",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
backgroundColor: "#ff3b30",
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingVertical: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
ctaDisabled: { opacity: 0.4 },
|
||||||
|
ctaText: { color: "#fff", fontWeight: "700", fontSize: 17 },
|
||||||
|
viewerRoot: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
|
swipeHint: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 90,
|
||||||
|
alignSelf: "center",
|
||||||
|
color: "#9a9aa0",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
editBtn: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 36,
|
||||||
|
alignSelf: "center",
|
||||||
|
backgroundColor: "#222228",
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
editText: { color: "#f5f5f7", fontWeight: "600" },
|
||||||
|
});
|
||||||
@@ -1,8 +1,29 @@
|
|||||||
// Entry redirect: authenticated users land on their cards, others on login.
|
// Landing: jump straight into the no-login demo, or sign in to the full app.
|
||||||
import { Redirect } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { useAuthStore } from "../src/stores/authStore";
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const authed = useAuthStore((s) => s.accessToken !== null);
|
const router = useRouter();
|
||||||
return <Redirect href={authed ? "/(tabs)/cards" : "/(auth)/login"} />;
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Text style={styles.title}>CardClaws</Text>
|
||||||
|
<Text style={styles.tagline}>The other side of you.</Text>
|
||||||
|
|
||||||
|
<Pressable style={styles.primary} onPress={() => router.push("/demo")}>
|
||||||
|
<Text style={styles.primaryText}>Try the card demo</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => router.push("/(auth)/login")}>
|
||||||
|
<Text style={styles.link}>Sign in to the full app</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0a0a0c", alignItems: "center", justifyContent: "center", padding: 24, gap: 14 },
|
||||||
|
title: { color: "#f5f5f7", fontSize: 44, fontWeight: "800", letterSpacing: -1 },
|
||||||
|
tagline: { color: "#9a9aa0", marginBottom: 24 },
|
||||||
|
primary: { backgroundColor: "#ff3b30", borderRadius: 16, paddingVertical: 16, paddingHorizontal: 40 },
|
||||||
|
primaryText: { color: "#fff", fontWeight: "700", fontSize: 17 },
|
||||||
|
link: { color: "#9a9aa0", marginTop: 12 },
|
||||||
|
});
|
||||||
|
|||||||
Generated
+30
-50
@@ -12,8 +12,11 @@
|
|||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"expo": "~51.0.28",
|
"expo": "~51.0.28",
|
||||||
|
"expo-constants": "~16.0.2",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
"expo-router": "~3.5.23",
|
"expo-image-picker": "~15.1.0",
|
||||||
|
"expo-linking": "~6.3.1",
|
||||||
|
"expo-router": "~3.5.24",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@@ -8547,7 +8550,7 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-asset/node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "16.0.2",
|
"version": "16.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-16.0.2.tgz",
|
||||||
"integrity": "sha512-9tNY3OVO0jfiMzl7ngb6IOyR5VFzNoN5OOazUWoeGfmMqVB5kltTemRvKraK9JRbBKIw+SOYLEmF0sEqgFZ6OQ==",
|
"integrity": "sha512-9tNY3OVO0jfiMzl7ngb6IOyR5VFzNoN5OOazUWoeGfmMqVB5kltTemRvKraK9JRbBKIw+SOYLEmF0sEqgFZ6OQ==",
|
||||||
@@ -8560,45 +8563,6 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-constants": {
|
|
||||||
"version": "56.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.16.tgz",
|
|
||||||
"integrity": "sha512-6tsiN+gmTUPp/atyA+uY9Tg8VOdXdmb4s/3TVGolfn6A/oCAraw1pcPZX5XllyD+xUguxB6eBSFAT8494hZVMA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/env": "~2.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-constants/node_modules/@expo/env": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "^4.0.0",
|
|
||||||
"debug": "^4.3.4",
|
|
||||||
"getenv": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.12.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-constants/node_modules/getenv": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-file-system": {
|
"node_modules/expo-file-system": {
|
||||||
"version": "17.0.1",
|
"version": "17.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-17.0.1.tgz",
|
||||||
@@ -8629,6 +8593,27 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-image-loader": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-image-picker": {
|
||||||
|
"version": "15.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-15.1.0.tgz",
|
||||||
|
"integrity": "sha512-6cE10S7d0qG7+pcHtsYukRKuFL44ssOM0/v+5JIBzRwxpIgGA6qokr8PxasFEpxOA9NpN0gwry33jmq1qLXJTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-image-loader": "~4.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-keep-awake": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "13.0.2",
|
"version": "13.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz",
|
||||||
@@ -8639,18 +8624,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-linking": {
|
"node_modules/expo-linking": {
|
||||||
"version": "56.0.13",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-6.3.1.tgz",
|
||||||
"integrity": "sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==",
|
"integrity": "sha512-xuZCntSBGWCD/95iZ+mTUGTwHdy8Sx+immCqbUBxdvZ2TN61P02kKg7SaLS8A4a/hLrSCwrg5tMMwu5wfKr35g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"expo-constants": "~56.0.16",
|
"expo-constants": "~16.0.0",
|
||||||
"invariant": "^2.2.4"
|
"invariant": "^2.2.4"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-modules-autolinking": {
|
"node_modules/expo-modules-autolinking": {
|
||||||
|
|||||||
@@ -15,9 +15,13 @@
|
|||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"expo": "~51.0.28",
|
"expo": "~51.0.28",
|
||||||
|
"expo-constants": "~16.0.2",
|
||||||
"expo-haptics": "~13.0.1",
|
"expo-haptics": "~13.0.1",
|
||||||
"expo-router": "~3.5.23",
|
"expo-image-picker": "~15.1.0",
|
||||||
|
"expo-linking": "~6.3.1",
|
||||||
|
"expo-router": "~3.5.24",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
@@ -25,7 +29,6 @@
|
|||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
"react-native-screens": "3.31.1",
|
"react-native-screens": "3.31.1",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"zustand": "^4.5.4"
|
"zustand": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface Props {
|
|||||||
side: CardSide;
|
side: CardSide;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
/** Corner radius; 0 for full-bleed full-screen cards. */
|
||||||
|
borderRadius?: number;
|
||||||
/** URL encoded by any `qr` layer on this side (the card's profile URL). */
|
/** URL encoded by any `qr` layer on this side (the card's profile URL). */
|
||||||
profileUrl?: string;
|
profileUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,16 @@ function assetUri(r2Key: string): string {
|
|||||||
return `${API_BASE}/assets/${r2Key}`;
|
return `${API_BASE}/assets/${r2Key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve an image background to a displayable URI. `value` carries a direct
|
||||||
|
/// URI (local file:// or content:// for the demo, or a full URL); otherwise the
|
||||||
|
/// r2Key resolves to the asset endpoint.
|
||||||
|
function backgroundImageUri(bg: BackgroundConfig): string | null {
|
||||||
|
if (bg.type !== "image") 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,
|
||||||
@@ -137,10 +149,14 @@ function LayerView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardFace({ side, width, height, 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);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.face, { width, height }, backgroundStyle(side.background)]}>
|
<View style={[styles.face, { width, height, borderRadius }, backgroundStyle(side.background)]}>
|
||||||
|
{bgImage && (
|
||||||
|
<Image source={{ uri: bgImage }} style={StyleSheet.absoluteFill} resizeMode="cover" />
|
||||||
|
)}
|
||||||
{ordered.map((layer) => (
|
{ordered.map((layer) => (
|
||||||
<LayerView
|
<LayerView
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
@@ -156,7 +172,6 @@ export function CardFace({ side, width, height, profileUrl }: Props) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
face: {
|
face: {
|
||||||
borderRadius: 24,
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
contactLine: {
|
contactLine: {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// Full-screen card viewer: entry animation, flip, and ambient idle drift
|
// Card viewer: entry animation, flip, and ambient idle drift (PRD §6.2).
|
||||||
// (PRD §6.2). Ambient brightness boost requires expo-brightness (added with the
|
// Ambient brightness boost requires expo-brightness (added with the hardware
|
||||||
// hardware integration milestone); the idle motion is implemented here.
|
// integration milestone); the idle motion is implemented here.
|
||||||
|
//
|
||||||
|
// 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").
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||||
@@ -18,11 +21,24 @@ import { CardDefinition } from "../../types/card";
|
|||||||
import { CardFace } from "./CardFace";
|
import { CardFace } from "./CardFace";
|
||||||
import { CardFlip } from "./CardFlip";
|
import { CardFlip } from "./CardFlip";
|
||||||
|
|
||||||
export function CardViewer({ card }: { card: CardDefinition }) {
|
export function CardViewer({
|
||||||
|
card,
|
||||||
|
profileUrl: profileUrlOverride,
|
||||||
|
fullScreen = false,
|
||||||
|
}: {
|
||||||
|
card: CardDefinition;
|
||||||
|
/** Override the QR target; defaults to the card's public profile URL. */
|
||||||
|
profileUrl?: string;
|
||||||
|
/** Fill the entire device window instead of a centered, rounded card. */
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}) {
|
||||||
|
// useWindowDimensions is the live size of the app's drawable area; it updates
|
||||||
|
// on rotation/resize. Full-screen cards use it verbatim.
|
||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions();
|
||||||
const cardWidth = Math.min(width * 0.92, 380);
|
const cardWidth = fullScreen ? width : Math.min(width * 0.92, 380);
|
||||||
const cardHeight = Math.min(height * 0.72, cardWidth * 1.5);
|
const cardHeight = fullScreen ? height : Math.min(height * 0.72, cardWidth * 1.5);
|
||||||
const profileUrl = `${PROFILE_BASE}/${card.handle}`;
|
const radius = fullScreen ? 0 : 24;
|
||||||
|
const profileUrl = profileUrlOverride ?? `${PROFILE_BASE}/${card.handle}`;
|
||||||
|
|
||||||
const entry = useSharedValue(0);
|
const entry = useSharedValue(0);
|
||||||
const float = useSharedValue(0);
|
const float = useSharedValue(0);
|
||||||
@@ -46,14 +62,16 @@ export function CardViewer({ card }: { card: CardDefinition }) {
|
|||||||
const containerStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
opacity: entry.value,
|
opacity: entry.value,
|
||||||
transform: [
|
transform: [
|
||||||
{ scale: 0.92 + 0.08 * entry.value },
|
// Full-bleed cards fade in without the scale-from-center (which would
|
||||||
{ translateY: float.value * 6 },
|
// reveal the background at the edges); a tiny ambient drift remains.
|
||||||
|
{ scale: fullScreen ? 1 : 0.92 + 0.08 * entry.value },
|
||||||
|
{ translateY: float.value * (fullScreen ? 3 : 6) },
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={[styles.root, fullScreen && styles.rootFull]}>
|
||||||
<Animated.View style={containerStyle}>
|
<Animated.View style={[containerStyle, fullScreen && StyleSheet.absoluteFill]}>
|
||||||
<CardFlip
|
<CardFlip
|
||||||
width={cardWidth}
|
width={cardWidth}
|
||||||
height={cardHeight}
|
height={cardHeight}
|
||||||
@@ -61,10 +79,22 @@ export function CardViewer({ card }: { card: CardDefinition }) {
|
|||||||
hapticEnabled={card.settings.hapticEnabled}
|
hapticEnabled={card.settings.hapticEnabled}
|
||||||
gesture={card.settings.flipGesture}
|
gesture={card.settings.flipGesture}
|
||||||
front={
|
front={
|
||||||
<CardFace side={card.face} width={cardWidth} height={cardHeight} profileUrl={profileUrl} />
|
<CardFace
|
||||||
|
side={card.face}
|
||||||
|
width={cardWidth}
|
||||||
|
height={cardHeight}
|
||||||
|
borderRadius={radius}
|
||||||
|
profileUrl={profileUrl}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
back={
|
back={
|
||||||
<CardFace side={card.back} width={cardWidth} height={cardHeight} profileUrl={profileUrl} />
|
<CardFace
|
||||||
|
side={card.back}
|
||||||
|
width={cardWidth}
|
||||||
|
height={cardHeight}
|
||||||
|
borderRadius={radius}
|
||||||
|
profileUrl={profileUrl}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -79,4 +109,5 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#0a0a0c",
|
backgroundColor: "#0a0a0c",
|
||||||
},
|
},
|
||||||
|
rootFull: { alignItems: "stretch", justifyContent: "flex-start" },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user