MAJOR UPDATE ALERT!!!!!

This commit is contained in:
2026-03-01 14:57:18 +01:00
parent bb46330af8
commit 6497eb9770
21 changed files with 2374 additions and 915 deletions

View File

@@ -1,10 +1,12 @@
import { StatusBar } from "expo-status-bar";
import { StyleSheet, View } from "react-native";
import { BottomNav } from "./src/components/BottomNav";
import { colors } from "./src/styles";
import { NotFoundPage } from "./src/pages/NotFound";
import { DataCardsPage } from "./src/pages/datacards";
import { ImagePage } from "./src/pages/image";
import { IndexPage } from "./src/pages/index";
import { SettingsPage } from "./src/pages/settings";
import { TextPage } from "./src/pages/text";
import { Route, RouterProvider, useRouter } from "./src/router";
interface Tab {
@@ -17,7 +19,13 @@ const TABS: Tab[] = [
{ label: "Home", route: "home", page: IndexPage },
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
{ label: "Image", route: "image", page: ImagePage, hideInNav: true },
{ label: "Data Cards", route: "datacards", page: DataCardsPage, hideInNav: true },
{
label: "Data Cards",
route: "datacards",
page: DataCardsPage,
hideInNav: true,
},
{ label: "Settings", route: "settings", page: SettingsPage },
];
export { TABS, type Tab };
@@ -41,8 +49,10 @@ export default function App() {
return (
<RouterProvider>
<View style={styles.container}>
<StatusBar style="auto" />
<Screen />
<StatusBar style="light" />
<View style={{ flex: 1, marginTop: 12 }}>
<Screen />
</View>
<BottomNav />
</View>
</RouterProvider>
@@ -52,6 +62,6 @@ export default function App() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
backgroundColor: colors.bg,
},
});

View File

@@ -9,6 +9,7 @@
"web": "expo start --web"
},
"dependencies": {
"@react-native-community/datetimepicker": "^8.6.0",
"expo": "~54.0.33",
"expo-image-picker": "^55.0.10",
"expo-status-bar": "~3.0.9",

28
mobile/pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@react-native-community/datetimepicker':
specifier: ^8.6.0
version: 8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo:
specifier: ~54.0.33
version: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -702,6 +705,19 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@react-native-community/datetimepicker@8.6.0':
resolution: {integrity: sha512-yxPSqNfxgpGaqHQIpatqe6ykeBdU/1pdsk/G3x01mY2bpTflLpmVTLqFSJYd3MiZzxNZcMs/j1dQakUczSjcYA==}
peerDependencies:
expo: '>=52.0.0'
react: '*'
react-native: '*'
react-native-windows: '*'
peerDependenciesMeta:
expo:
optional: true
react-native-windows:
optional: true
'@react-native/assets-registry@0.81.5':
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
engines: {node: '>= 20.19.4'}
@@ -1630,28 +1646,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -3546,6 +3558,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@react-native-community/datetimepicker@8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
invariant: 2.2.4
react: 19.1.0
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
optionalDependencies:
expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@react-native/assets-registry@0.81.5': {}
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':

View File

@@ -1,6 +1,7 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useRouter } from "../router";
import { TABS } from "../../App";
import { colors } from "../styles";
export function BottomNav() {
@@ -32,9 +33,9 @@ const styles = StyleSheet.create({
bar: {
flexDirection: "row",
height: 74,
borderTopWidth:1,
borderTopColor: "#e5e5e5",
backgroundColor: "#fff",
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.surface,
},
tab: {
flex: 1,
@@ -43,11 +44,11 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 13,
color: "#aaa",
color: colors.textMuted,
fontWeight: "500",
},
labelActive: {
color: "#1a1a1a",
color: colors.textPrimary,
fontWeight: "700",
},
indicator: {
@@ -56,6 +57,6 @@ const styles = StyleSheet.create({
width: 32,
height: 3,
borderRadius: 2,
backgroundColor: "#1a1a1a",
backgroundColor: colors.accent,
},
});

View File

@@ -1,4 +1,5 @@
import { StyleSheet, Text, View } from "react-native";
import { colors } from "../styles";
export function NotFoundPage() {
return (
@@ -15,12 +16,12 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9f9f9",
backgroundColor: colors.bg,
},
code: {
fontSize: 72,
fontWeight: "800",
color: "#ccc",
color: colors.border,
lineHeight: 80,
},
title: {
@@ -28,9 +29,11 @@ const styles = StyleSheet.create({
fontWeight: "600",
marginTop: 8,
marginBottom: 6,
color: colors.textPrimary,
},
subtitle: {
fontSize: 14,
color: "#999",
color: colors.textSecondary,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import * as ImagePicker from "expo-image-picker";
import { useState } from "react";
import { ActivityIndicator, Button, Image, StyleSheet, Text, TextInput, View } from "react-native";
import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
import { colors, shared } from "../styles";
const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
@@ -8,27 +9,12 @@ const BASE_URL =
export function ImagePage() {
const [caption, setCaption] = useState("");
const [uploading, setUploading] = useState(false);
const [dismissing, setDismissing] = useState(false);
const [previewUri, setPreviewUri] = useState<string | null>(null);
const handleTakePhoto = async () => {
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
alert("Camera permission is required to take photos.");
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: "images",
quality: 1,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
const uploadAsset = async (asset: ImagePicker.ImagePickerAsset) => {
setPreviewUri(asset.uri);
setUploading(true);
try {
const res = await fetch(`${BASE_URL}/push_image`, {
method: "POST",
@@ -45,7 +31,42 @@ export function ImagePage() {
}
};
const handleTakePhoto = async () => {
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
alert("Camera permission is required to take photos.");
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: "images",
quality: 1,
base64: true,
});
if (result.canceled) return;
await uploadAsset(result.assets[0]);
};
const handlePickFromGallery = async () => {
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) {
alert("Media library permission is required to pick photos.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "images",
quality: 1,
base64: true,
});
if (result.canceled) return;
await uploadAsset(result.assets[0]);
};
const handleDismiss = () => {
setDismissing(true);
fetch(`${BASE_URL}/push_dismiss_image`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
@@ -54,19 +75,21 @@ export function ImagePage() {
.catch((error) => {
console.error("Error dismissing image:", error);
alert("Error dismissing image.");
});
})
.finally(() => setDismissing(false));
};
return (
<View style={styles.container}>
<Text style={styles.title}>Image Popup</Text>
<Text style={styles.subtitle}>Show a photo on the TV with an optional caption.</Text>
<View style={shared.screenPadded}>
<Text style={shared.pageTitle}>Image Popup</Text>
<Text style={shared.subtitle}>Show a photo on the TV with an optional caption.</Text>
<View style={styles.field}>
<Text style={styles.label}>Caption (optional)</Text>
<View style={shared.field}>
<Text style={shared.label}>Caption (optional)</Text>
<TextInput
style={styles.input}
style={shared.input}
placeholder="Add a caption..."
placeholderTextColor={colors.placeholderColor}
value={caption}
onChangeText={setCaption}
/>
@@ -77,69 +100,50 @@ export function ImagePage() {
)}
{uploading ? (
<ActivityIndicator size="large" style={{ marginTop: 8 }} />
<ActivityIndicator size="large" color={colors.accent} style={{ marginTop: 8 }} />
) : (
<View style={styles.actions}>
<View style={styles.actionBtn}>
<Button title="Take Photo & Show" onPress={handleTakePhoto} />
<>
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex]}
onPress={handleTakePhoto}
activeOpacity={0.8}
>
<Text style={shared.btnPrimaryText}>Take Photo & Show</Text>
</TouchableOpacity>
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex]}
onPress={handlePickFromGallery}
activeOpacity={0.8}
>
<Text style={shared.btnPrimaryText}>Pick from Gallery</Text>
</TouchableOpacity>
</View>
<View style={styles.actionBtn}>
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnDanger, shared.actionFlex, dismissing && shared.btnDisabled]}
onPress={handleDismiss}
activeOpacity={0.8}
disabled={dismissing}
>
{dismissing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Dismiss</Text>
)}
</TouchableOpacity>
</View>
</View>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: "#f9f9f9",
gap: 20,
},
title: {
fontSize: 26,
fontWeight: "700",
color: "#111",
marginTop: 8,
},
subtitle: {
fontSize: 14,
color: "#888",
marginTop: -12,
},
field: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: "600",
color: "#555",
textTransform: "uppercase",
letterSpacing: 0.5,
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 10,
padding: 12,
fontSize: 16,
backgroundColor: "#fff",
},
preview: {
width: "100%",
height: 200,
borderRadius: 10,
borderRadius: 12,
resizeMode: "cover",
},
actions: {
flexDirection: "row",
gap: 12,
marginTop: 4,
},
actionBtn: {
flex: 1,
},
});

View File

@@ -1,31 +1,32 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useRouter } from "../router";
import { colors, shared } from "../styles";
export function IndexPage() {
const { navigate } = useRouter();
return (
<View style={styles.container}>
<Text style={styles.title}>TV Control</Text>
<Text style={styles.subtitle}>Choose what to send to the TV.</Text>
<View style={shared.screenPadded}>
<Text style={shared.pageTitleLarge}>TV Control</Text>
<Text style={shared.subtitle}>Your TV, Your Control.</Text>
<View style={styles.cards}>
<TouchableOpacity style={styles.card} onPress={() => navigate("text")} activeOpacity={0.8}>
<Text style={styles.cardIcon}>💬</Text>
<Text style={styles.cardTitle}>Text Popup</Text>
<Text style={styles.cardDesc}>Display a message on the TV screen.</Text>
<TouchableOpacity style={shared.card} onPress={() => navigate("text")} activeOpacity={0.7}>
<Text style={shared.cardIcon}>💬</Text>
<Text style={shared.cardTitle}>Text Popup</Text>
<Text style={shared.cardDesc}>Display a message on the TV screen.</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.card} onPress={() => navigate("image")} activeOpacity={0.8}>
<Text style={styles.cardIcon}>📷</Text>
<Text style={styles.cardTitle}>Image Popup</Text>
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
<TouchableOpacity style={shared.card} onPress={() => navigate("image")} activeOpacity={0.7}>
<Text style={shared.cardIcon}>📷</Text>
<Text style={shared.cardTitle}>Image Popup</Text>
<Text style={shared.cardDesc}>Take a photo and show it on the TV.</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.card} onPress={() => navigate("datacards")} activeOpacity={0.8}>
<Text style={styles.cardIcon}>📊</Text>
<Text style={styles.cardTitle}>Data Cards</Text>
<Text style={styles.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
<TouchableOpacity style={shared.card} onPress={() => navigate("datacards")} activeOpacity={0.7}>
<Text style={shared.cardIcon}>📊</Text>
<Text style={shared.cardTitle}>Data Cards</Text>
<Text style={shared.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
</TouchableOpacity>
</View>
</View>
@@ -33,50 +34,9 @@ export function IndexPage() {
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: "#f9f9f9",
gap: 20,
},
title: {
fontSize: 30,
fontWeight: "700",
color: "#111",
marginTop: 8,
},
subtitle: {
fontSize: 15,
color: "#888",
marginTop: -12,
},
cards: {
gap: 16,
gap: 14,
marginTop: 8,
},
card: {
backgroundColor: "#fff",
borderRadius: 16,
borderWidth: 1,
borderColor: "#e8e8e8",
padding: 20,
gap: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
},
cardIcon: {
fontSize: 28,
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
color: "#111",
},
cardDesc: {
fontSize: 14,
color: "#888",
},
});

View File

@@ -0,0 +1,286 @@
import * as ImagePicker from "expo-image-picker";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { colors, shared } from "../styles";
const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
// ─── Settings page ────────────────────────────────────────────────────────────
export function SettingsPage() {
const [currentBgUrl, setCurrentBgUrl] = useState<string>("");
const [pendingUri, setPendingUri] = useState<string | null>(null);
const [pendingBase64, setPendingBase64] = useState<string | null>(null);
const [pendingExt, setPendingExt] = useState<string>("jpg");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [status, setStatus] = useState<string | null>(null);
// ── Load current settings on mount
useEffect(() => {
setLoading(true);
fetch(`${BASE_URL}/pull_full`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
const bg = data.state?.settings?.background_url ?? "";
setCurrentBgUrl(bg);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
// ── Pick a portrait image from the gallery
const handlePickBackground = async () => {
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) {
setStatus("Media library permission is required.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "images",
quality: 0.85,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
// Enforce portrait orientation (height must exceed width)
if (asset.width >= asset.height) {
setStatus("Please pick a portrait image (height must be greater than width).");
return;
}
const ext = (asset.uri.split(".").pop() ?? "jpg").toLowerCase();
setPendingUri(asset.uri);
setPendingBase64(asset.base64 ?? null);
setPendingExt(ext);
setStatus(null);
};
// ── Upload pending image and save as background
const handleSave = async () => {
if (!pendingBase64) return;
setSaving(true);
setStatus(null);
try {
// Upload to MinIO via push_upload_images
const uploadRes = await fetch(`${BASE_URL}/push_upload_images`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: [{ image_b64: pendingBase64, ext: pendingExt }] }),
});
const uploadData = await uploadRes.json();
if (uploadData.status !== "success" || !uploadData.urls?.[0]) {
throw new Error(uploadData.message ?? "Upload failed");
}
const url: string = uploadData.urls[0];
// Persist as background URL
const settingsRes = await fetch(`${BASE_URL}/push_settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ background_url: url }),
});
const settingsData = await settingsRes.json();
if (settingsData.status !== "success") {
throw new Error(settingsData.message ?? "Save failed");
}
setCurrentBgUrl(url);
setPendingUri(null);
setPendingBase64(null);
setStatus("Background saved!");
} catch (err) {
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSaving(false);
}
};
// ── Clear the TV background
const handleClear = async () => {
setClearing(true);
setStatus(null);
try {
const res = await fetch(`${BASE_URL}/push_settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ background_url: "" }),
});
const data = await res.json();
if (data.status !== "success") throw new Error(data.message ?? "Clear failed");
setCurrentBgUrl("");
setPendingUri(null);
setPendingBase64(null);
setStatus("Background cleared.");
} catch (err) {
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setClearing(false);
}
};
const previewUri = pendingUri ?? (currentBgUrl || null);
const hasPending = !!pendingUri;
const hasCurrent = !!currentBgUrl;
return (
<ScrollView
style={shared.screen}
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Text style={shared.pageTitle}>Settings</Text>
<Text style={shared.subtitle}>Configure global TV display options.</Text>
{/* ── Background Image ── */}
<View style={styles.section}>
<Text style={shared.sectionLabel}>TV Background</Text>
<Text style={shared.hint}>
Displayed behind all content on the TV. Must be a portrait image (taller than wide).
</Text>
{loading ? (
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
) : (
<>
{/* Preview */}
{previewUri && (
<View style={styles.previewWrap}>
<Image source={{ uri: previewUri }} style={styles.preview} resizeMode="cover" />
{hasPending && (
<View style={styles.pendingBadge}>
<Text style={styles.pendingBadgeText}>Unsaved</Text>
</View>
)}
</View>
)}
{!previewUri && (
<View style={styles.emptyPreview}>
<Text style={styles.emptyPreviewText}>No background set</Text>
</View>
)}
{/* Actions */}
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnSecondary, shared.actionFlex]}
onPress={handlePickBackground}
activeOpacity={0.8}
>
<Text style={shared.btnSecondaryText}>
{hasCurrent || hasPending ? "Replace" : "Pick Image"}
</Text>
</TouchableOpacity>
{hasPending && (
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex, saving && shared.btnDisabled]}
onPress={handleSave}
activeOpacity={0.8}
disabled={saving}
>
{saving ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnPrimaryText}>Save</Text>
)}
</TouchableOpacity>
)}
</View>
{(hasCurrent || hasPending) && (
<TouchableOpacity
style={[shared.btnDanger, clearing && shared.btnDisabled]}
onPress={handleClear}
activeOpacity={0.8}
disabled={clearing}
>
{clearing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Clear Background</Text>
)}
</TouchableOpacity>
)}
{status && (
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
)}
</>
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 24,
gap: 20,
paddingBottom: 40,
},
section: {
gap: 12,
},
previewWrap: {
borderRadius: 14,
overflow: "hidden",
borderWidth: 1,
borderColor: colors.border,
position: "relative",
},
preview: {
width: "100%",
aspectRatio: 9 / 16,
},
pendingBadge: {
position: "absolute",
top: 10,
right: 10,
backgroundColor: colors.accent,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 4,
},
pendingBadgeText: {
color: "#fff",
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 0.5,
},
emptyPreview: {
width: "100%",
aspectRatio: 9 / 16,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.border,
borderStyle: "dashed",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
},
emptyPreviewText: {
color: colors.textMuted,
fontSize: 14,
},
statusText: {
marginTop: 4,
color: colors.textSecondary,
},
});

View File

@@ -1,18 +1,21 @@
import { useState } from "react";
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
import { colors, shared } from "../styles";
const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
export function TextPage() {
const [title, setTitle] = useState("");
const [sending, setSending] = useState(false);
const [dismissing, setDismissing] = useState(false);
const handleShow = () => {
if (title.trim() === "") {
alert("Please enter some text before sending.");
return;
}
setSending(true);
fetch(`${BASE_URL}/push_text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -25,10 +28,12 @@ export function TextPage() {
.catch((error) => {
console.error("Error sending text:", error);
alert("Error sending text.");
});
})
.finally(() => setSending(false));
};
const handleDismiss = () => {
setDismissing(true);
fetch(`${BASE_URL}/push_dismiss_text`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
@@ -37,81 +42,53 @@ export function TextPage() {
.catch((error) => {
console.error("Error dismissing text:", error);
alert("Error dismissing text.");
});
})
.finally(() => setDismissing(false));
};
return (
<View style={styles.container}>
<Text style={styles.title}>Text Popup</Text>
<Text style={styles.subtitle}>Display a text message on the TV.</Text>
<View style={shared.screenPadded}>
<Text style={shared.pageTitle}>Text Popup</Text>
<Text style={shared.subtitle}>Display a text message on the TV.</Text>
<View style={styles.field}>
<Text style={styles.label}>Message</Text>
<View style={shared.field}>
<Text style={shared.label}>Message</Text>
<TextInput
style={styles.input}
style={[shared.input, shared.inputMultiline]}
placeholder="Type something..."
placeholderTextColor={colors.placeholderColor}
value={title}
onChangeText={setTitle}
multiline
/>
</View>
<View style={styles.actions}>
<View style={styles.actionBtn}>
<Button title="Show on TV" onPress={handleShow} />
</View>
<View style={styles.actionBtn}>
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
</View>
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex, sending && shared.btnDisabled]}
onPress={handleShow}
activeOpacity={0.8}
disabled={sending}
>
{sending ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnPrimaryText}>Show on TV</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[shared.btnDanger, shared.actionFlex, dismissing && shared.btnDisabled]}
onPress={handleDismiss}
activeOpacity={0.8}
disabled={dismissing}
>
{dismissing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Dismiss</Text>
)}
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: "#f9f9f9",
gap: 20,
},
title: {
fontSize: 26,
fontWeight: "700",
color: "#111",
marginTop: 8,
},
subtitle: {
fontSize: 14,
color: "#888",
marginTop: -12,
},
field: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: "600",
color: "#555",
textTransform: "uppercase",
letterSpacing: 0.5,
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 10,
padding: 12,
fontSize: 16,
backgroundColor: "#fff",
minHeight: 80,
textAlignVertical: "top",
},
actions: {
flexDirection: "row",
gap: 12,
marginTop: 4,
},
actionBtn: {
flex: 1,
},
});

View File

@@ -1,19 +1,43 @@
import React, { createContext, useContext, useState } from "react";
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { BackHandler } from "react-native";
export type Route = "home" | "text" | "image" | "datacards";
export type Route = "home" | "text" | "image" | "datacards" | "settings";
interface RouterContextValue {
route: Route;
navigate: (to: Route) => void;
goBack: () => boolean;
}
const RouterContext = createContext<RouterContextValue | null>(null);
export function RouterProvider({ children }: { children: React.ReactNode }) {
const [route, setRoute] = useState<Route>("home");
const history = useRef<Route[]>([]);
const navigate = useCallback((to: Route) => {
setRoute((prev) => {
history.current.push(prev);
return to;
});
}, []);
const goBack = useCallback((): boolean => {
const prev = history.current.pop();
if (prev !== undefined) {
setRoute(prev);
return true;
}
return false;
}, []);
useEffect(() => {
const sub = BackHandler.addEventListener("hardwareBackPress", goBack);
return () => sub.remove();
}, [goBack]);
return (
<RouterContext.Provider value={{ route, navigate: setRoute }}>
<RouterContext.Provider value={{ route, navigate, goBack }}>
{children}
</RouterContext.Provider>
);

203
mobile/src/styles.ts Normal file
View File

@@ -0,0 +1,203 @@
import { StyleSheet } from "react-native";
// ─── Design Tokens ─────────────────────────────────────────────────────────────
export const colors = {
bg: "#0d0d0d",
surface: "#1a1a1a",
surfaceElevated: "#222",
border: "#2e2e2e",
borderSubtle: "#232323",
textPrimary: "#f0f0f0",
textSecondary: "#888",
textMuted: "#505050",
placeholderColor: "#666",
accent: "#7c6fff",
accentDim: "#3a3570",
danger: "#ff453a",
dangerBg: "#2a1111",
dangerBorder: "#5a2020",
dangerText: "#ff6b63",
} as const;
// ─── Shared Styles ─────────────────────────────────────────────────────────────
export const shared = StyleSheet.create({
// ── Screens
screen: {
flex: 1,
backgroundColor: colors.bg,
},
screenPadded: {
flex: 1,
backgroundColor: colors.bg,
padding: 24,
gap: 20,
},
// ── Typography
pageTitle: {
fontSize: 28,
fontWeight: "700",
color: colors.textPrimary,
marginTop: 8,
},
pageTitleLarge: {
fontSize: 32,
fontWeight: "800",
color: colors.textPrimary,
marginTop: 8,
},
subtitle: {
fontSize: 14,
color: colors.textSecondary,
marginTop: -12,
},
label: {
fontSize: 13,
fontWeight: "600",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabel: {
fontSize: 11,
fontWeight: "700",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: 1.2,
marginTop: 8,
marginBottom: -4,
},
hint: {
fontSize: 12,
color: colors.textMuted,
lineHeight: 17,
},
code: {
fontFamily: "monospace",
fontSize: 12,
color: colors.accent,
},
// ── Form
field: {
gap: 6,
},
input: {
backgroundColor: colors.surface,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 14,
paddingVertical: 11,
fontSize: 15,
color: colors.textPrimary,
},
inputMultiline: {
minHeight: 80,
textAlignVertical: "top",
paddingTop: 10,
},
row: {
flexDirection: "row",
gap: 12,
},
flex1: {
flex: 1,
},
// ── Buttons
btnPrimary: {
backgroundColor: colors.accent,
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
},
btnPrimaryText: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
},
btnDanger: {
backgroundColor: colors.dangerBg,
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
borderWidth: 1,
borderColor: colors.dangerBorder,
},
btnDangerText: {
fontSize: 16,
fontWeight: "600",
color: colors.dangerText,
},
btnSecondary: {
backgroundColor: colors.surface,
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
borderWidth: 1,
borderColor: colors.border,
},
btnSecondaryText: {
fontSize: 16,
fontWeight: "600",
color: colors.textPrimary,
},
btnDisabled: {
opacity: 0.45,
},
actionsRow: {
flexDirection: "row",
gap: 12,
marginTop: 4,
},
actionFlex: {
flex: 1,
},
// ── Page header (with back button)
pageHeader: {
paddingTop: 16,
paddingHorizontal: 24,
paddingBottom: 4,
gap: 4,
},
backBtn: {
alignSelf: "flex-start",
paddingVertical: 4,
},
backBtnText: {
fontSize: 14,
color: colors.accent,
fontWeight: "500",
},
// ── Cards
card: {
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 20,
gap: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.35,
shadowRadius: 6,
elevation: 4,
},
cardIcon: {
fontSize: 28,
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
color: colors.textPrimary,
},
cardDesc: {
fontSize: 14,
color: colors.textSecondary,
},
});