summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/auth.tsx81
-rw-r--r--lib/device.tsx90
-rw-r--r--lib/locales.ts247
-rw-r--r--lib/notifications.ts206
-rw-r--r--lib/theme.ts24
-rw-r--r--lib/ui.tsx846
6 files changed, 1494 insertions, 0 deletions
diff --git a/lib/auth.tsx b/lib/auth.tsx
new file mode 100644
index 0000000..b05c6a7
--- /dev/null
+++ b/lib/auth.tsx
@@ -0,0 +1,81 @@
+import {
+ createContext,
+ ReactNode,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { apiClient } from "../api/client";
+
+type AuthState = {
+ isLoading: boolean;
+ isAuthenticated: boolean;
+};
+
+type AuthContextType = AuthState & {
+ signIn: (
+ email: string,
+ password: string,
+ ) => Promise<{ success: boolean; reason?: string }>;
+ signUp: (
+ email: string,
+ password: string,
+ ) => Promise<{ success: boolean; reason?: string }>;
+ signOut: () => Promise<void>;
+};
+
+const AuthContext = createContext<AuthContextType | null>(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState<AuthState>({
+ isLoading: true,
+ isAuthenticated: false,
+ });
+
+ useEffect(() => {
+ // Check if user is already authenticated
+ const checkAuth = async () => {
+ await apiClient.initialize();
+ setState({
+ isLoading: false,
+ isAuthenticated: apiClient.isAuthenticated(),
+ });
+ };
+ checkAuth();
+ }, []);
+
+ const signIn = async (email: string, password: string) => {
+ const result = await apiClient.signIn(email, password);
+ if (result.success) {
+ setState({ isLoading: false, isAuthenticated: true });
+ }
+ return { success: result.success, reason: result.reason };
+ };
+
+ const signUp = async (email: string, password: string) => {
+ const result = await apiClient.signUp(email, password);
+ if (result.success) {
+ setState({ isLoading: false, isAuthenticated: true });
+ }
+ return { success: result.success, reason: result.reason };
+ };
+
+ const signOut = async () => {
+ await apiClient.signOut();
+ setState({ isLoading: false, isAuthenticated: false });
+ };
+
+ return (
+ <AuthContext.Provider value={{ ...state, signIn, signUp, signOut }}>
+ {children}
+ </AuthContext.Provider>
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/lib/device.tsx b/lib/device.tsx
new file mode 100644
index 0000000..e0ec600
--- /dev/null
+++ b/lib/device.tsx
@@ -0,0 +1,90 @@
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { apiClient, getDevices } from "../api";
+import { Device } from "../api/types";
+import { useAuth } from "./auth";
+
+type DeviceContextType = {
+ devices: Device[];
+ selectedDevice: Device | null;
+ isLoading: boolean;
+ selectDevice: (device: Device) => Promise<void>;
+ refreshDevices: () => Promise<void>;
+};
+
+const DeviceContext = createContext<DeviceContextType | null>(null);
+
+export function DeviceProvider({ children }: { children: ReactNode }) {
+ const [devices, setDevices] = useState<Device[]>([]);
+ const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const { isAuthenticated } = useAuth();
+
+ const refreshDevices = useCallback(async () => {
+ if (!isAuthenticated) {
+ setIsLoading(false);
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const fetchedDevices = await getDevices();
+ setDevices(fetchedDevices);
+
+ // If no device is selected, try to restore from storage or select first device
+ if (!selectedDevice && fetchedDevices.length > 0) {
+ const savedDeviceId = apiClient.getSelectedDeviceId();
+ const savedDevice = fetchedDevices.find((d) => d.id === savedDeviceId);
+
+ if (savedDevice) {
+ setSelectedDevice(savedDevice);
+ } else {
+ // Select first device by default
+ setSelectedDevice(fetchedDevices[0]!);
+ await apiClient.setSelectedDevice(fetchedDevices[0]!.id);
+ }
+ }
+ } catch (e) {
+ console.error("Failed to refresh devices", e);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [selectedDevice, isAuthenticated]);
+
+ useEffect(() => {
+ refreshDevices();
+ }, [refreshDevices]);
+
+ const selectDevice = useCallback(async (device: Device) => {
+ setSelectedDevice(device);
+ await apiClient.setSelectedDevice(device.id);
+ }, []);
+
+ return (
+ <DeviceContext.Provider
+ value={{
+ devices,
+ selectedDevice,
+ isLoading,
+ selectDevice,
+ refreshDevices,
+ }}
+ >
+ {children}
+ </DeviceContext.Provider>
+ );
+}
+
+export function useDevice() {
+ const context = useContext(DeviceContext);
+ if (!context) {
+ throw new Error("useDevice must be used within a DeviceProvider");
+ }
+ return context;
+}
diff --git a/lib/locales.ts b/lib/locales.ts
new file mode 100644
index 0000000..d78621f
--- /dev/null
+++ b/lib/locales.ts
@@ -0,0 +1,247 @@
+import { getLocales } from "expo-localization";
+
+const en = {
+ home: "Home",
+ alerts: "Alerts",
+ controls: "Controls",
+ settings: "Settings",
+ allClear: "All clear",
+ attentionNeeded: "Attention needed",
+ captchaError: "Captcha verification failed. Please try again.",
+ today: "Today",
+ review: "Review",
+ nothingNeedsAttention: "Nothing needs attention.",
+ fewItemsNeedReview: "A few items need review.",
+ deviceIsOnline: "Device is online",
+ deviceIsOffline: "Device is offline",
+ protectionActive: "Protection active.",
+ reconnectingAutomatically: "Reconnecting automatically.",
+ recentAlerts: "Recent alerts",
+ last24Hours: "Last 24 hours",
+ thisWeek: "This week",
+ needsAction: "needs action",
+ reviewed: "reviewed",
+ // Onboarding & Auth
+ welcomeTitle: "Welcome to Buddy",
+ welcomeSubtitle: "Keep your family safe online with smart parental controls.",
+ getStarted: "Get Started",
+ signIn: "Sign In",
+ signUp: "Sign Up",
+ email: "Email",
+ password: "Password",
+ confirmPassword: "Confirm Password",
+ emailPlaceholder: "Enter your email",
+ passwordPlaceholder: "Enter your password",
+ confirmPasswordPlaceholder: "Confirm your password",
+ createAccount: "Create Account",
+ alreadyHaveAccount: "Already have an account?",
+ dontHaveAccount: "Don't have an account?",
+ invalidEmail: "Please enter a valid email",
+ passwordRequired: "Password is required",
+ passwordTooShort: "Password must be at least 8 characters",
+ passwordsDoNotMatch: "Passwords do not match",
+ signInError: "Unable to sign in. Please check your credentials.",
+ signUpError: "Unable to create account. Please try again.",
+ loadingAlerts: "Loading alerts...",
+ allClearTitle: "All clear!",
+ noAlertsToReview: "No alerts to review right now.",
+ reviewPill: "Review",
+ fyiPill: "FYI",
+ whatHappened: "What happened",
+ whyItMatters: "Why it matters",
+ suggestedNextStep: "Suggested next step",
+ noDeviceSelected: "No device selected. Please link a device first.",
+ disableBuddy: "Disable Buddy",
+ temporarilyDisablesBuddy: "Temporarily disables Buddy.",
+ contentBlocking: "Content blocking",
+ adultSites: "Adult sites",
+ blockAdultWebsites: "Block adult websites.",
+ familyLink: "Family Link",
+ antiCircumvention: "Anti-Circumvention",
+ preventFamilyLinkBypasses:
+ "Measures that prevent certain Family Link bypasses.",
+ communication: "Communication",
+ communicationWithStrangers: "Communication with strangers",
+ blockAllCommunications: "Block all communications",
+ scanCommunicationsWithAI: "Scan communications with AI",
+ chooseHowBuddyShouldHandleStrangers:
+ "Choose how Buddy should handle strangers.",
+ communicationWithStrangersTitle: "Communication with strangers",
+ blockAllCommunicationsConfirm:
+ "This will notify of all comms with strangers.",
+ okay: "Okay",
+ cancel: "Cancel",
+ notifications: "Notifications",
+ dangerousMessages: "Dangerous messages",
+ newContactAdded: "New contact added",
+ devices: "Devices",
+ device: "device",
+ devicesPlural: "devices",
+ lastSeen: "Last seen",
+ online: "Online",
+ offline: "Offline",
+ transparency: "Transparency",
+ privacyAndTerms: "Privacy & terms",
+ legalAndPrivacyInfo: "Legal and privacy info.",
+ account: "Account",
+ verifyEmail: "Verify E-Mail",
+ verifyYourEmailAddress: "Verify your email address.",
+ signOut: "Sign Out",
+ notWiredYet: "Not wired yet",
+ hookThisUpLater: "Hook this up later.",
+ renameDevice: "Rename Device",
+ enterNewNameFor: "Enter a new name for",
+ verifyEmailTitle: "Verify Email",
+ enterVerificationCode: "Enter the verification code sent to your email:",
+ success: "Success",
+ error: "Error",
+ emailVerifiedSuccessfully: "Email verified successfully!",
+ failedToVerifyEmail: "Failed to verify email",
+ newContactAddedTitle: "New Contact Added",
+ newContactAddedSubtitle: "A new contact was added to a device",
+ contactName: "Contact Name",
+ unknown: "Unknown",
+ phoneNumber: "Phone Number",
+ emailAddress: "Email Address",
+ identifier: "Identifier",
+ notProvided: "Not provided",
+ unknownDevice: "Unknown Device",
+ contactAddedInfo:
+ "This contact was added to {deviceName}. You can review it in the device's contact list.",
+ backToHome: "Back to Home",
+ noDeviceSelectedPleaseLinkFirst:
+ "No device selected. Please link a device first.",
+};
+
+const hr: typeof en = {
+ home: "Početna",
+ alerts: "Upozorenja",
+ controls: "Kontrole",
+ settings: "Postavke",
+ allClear: "Sve je u redu",
+ attentionNeeded: "Potrebna pažnja",
+ captchaError: "Neuspjela provjera Captcha. Pokušajte ponovno.",
+ today: "Danas",
+ review: "Pregled",
+ nothingNeedsAttention: "Ništa ne zahtijeva pažnju.",
+ fewItemsNeedReview: "Nekoliko stavki treba pregled.",
+ deviceIsOnline: "Uređaj je na mreži",
+ deviceIsOffline: "Uređaj je izvan mreže",
+ protectionActive: "Zaštita je aktivna.",
+ reconnectingAutomatically: "Automatsko ponovno povezivanje.",
+ recentAlerts: "Nedavna upozorenja",
+ last24Hours: "Zadnja 24 sata",
+ thisWeek: "Ovaj tjedan",
+ needsAction: "zahtijeva radnju",
+ reviewed: "pregledano",
+ // Onboarding & Auth
+ welcomeTitle: "Dobrodošli u Buddy",
+ welcomeSubtitle:
+ "Zaštitite svoju obitelj na internetu uz pametne roditeljske kontrole.",
+ getStarted: "Započni",
+ signIn: "Prijava",
+ signUp: "Registracija",
+ email: "E-mail",
+ password: "Lozinka",
+ confirmPassword: "Potvrdite lozinku",
+ emailPlaceholder: "Unesite svoj e-mail",
+ passwordPlaceholder: "Unesite svoju lozinku",
+ confirmPasswordPlaceholder: "Potvrdite svoju lozinku",
+ createAccount: "Izradi račun",
+ alreadyHaveAccount: "Već imate račun?",
+ dontHaveAccount: "Nemate račun?",
+ invalidEmail: "Unesite valjanu e-mail adresu",
+ passwordRequired: "Lozinka je obavezna",
+ passwordTooShort: "Lozinka mora imati najmanje 8 znakova",
+ passwordsDoNotMatch: "Lozinke se ne podudaraju",
+ signInError: "Prijava nije uspjela. Provjerite podatke.",
+ signUpError: "Registracija nije uspjela. Pokušajte ponovno.",
+ loadingAlerts: "Učitavanje upozorenja...",
+ allClearTitle: "Sve je u redu!",
+ noAlertsToReview: "Trenutno nema upozorenja za pregled.",
+ reviewPill: "Pregled",
+ fyiPill: "Informacija",
+ whatHappened: "Što se dogodilo",
+ whyItMatters: "Zašto je važno",
+ suggestedNextStep: "Predloženi sljedeći korak",
+ noDeviceSelected: "Nije odabran uređaj. Najprije povežite uređaj.",
+ disableBuddy: "Onemogući Buddy",
+ temporarilyDisablesBuddy: "Privremeno onemogućuje Buddy.",
+ contentBlocking: "Blokiranje sadržaja",
+ adultSites: "Stranice za odrasle",
+ blockAdultWebsites: "Blokiraj web-stranice za odrasle.",
+ familyLink: "Family Link",
+ antiCircumvention: "Protiv zaobilaženja",
+ preventFamilyLinkBypasses:
+ "Mjere koje sprječavaju određene zaobilaske Family Linka.",
+ communication: "Komunikacija",
+ communicationWithStrangers: "Komunikacija sa strancima",
+ blockAllCommunications: "Blokiraj sve komunikacije",
+ scanCommunicationsWithAI: "Skeniraj komunikacije pomoću AI",
+ chooseHowBuddyShouldHandleStrangers:
+ "Odaberite kako Buddy treba postupati sa strancima.",
+ communicationWithStrangersTitle: "Komunikacija sa strancima",
+ blockAllCommunicationsConfirm:
+ "Ovo će obavještavati o svim komunikacijama sa strancima.",
+ okay: "U redu",
+ cancel: "Odustani",
+ notifications: "Obavijesti",
+ dangerousMessages: "Opasne poruke",
+ newContactAdded: "Dodan novi kontakt",
+ devices: "Uređaji",
+ device: "uređaj",
+ devicesPlural: "uređaji",
+ lastSeen: "Zadnje viđeno",
+ online: "Na mreži",
+ offline: "Izvan mreže",
+ transparency: "Transparentnost",
+ privacyAndTerms: "Privatnost i uvjeti",
+ legalAndPrivacyInfo: "Pravne i privatnosne informacije.",
+ account: "Račun",
+ verifyEmail: "Potvrdi e-mail",
+ verifyYourEmailAddress: "Potvrdite svoju e-mail adresu.",
+ signOut: "Odjava",
+ notWiredYet: "Još nije povezano",
+ hookThisUpLater: "Povežite ovo kasnije.",
+ renameDevice: "Preimenuj uređaj",
+ enterNewNameFor: "Unesite novo ime za",
+ verifyEmailTitle: "Potvrdite e-mail",
+ enterVerificationCode: "Unesite verifikacijski kod poslan na vaš e-mail:",
+ success: "Uspjeh",
+ error: "Greška",
+ emailVerifiedSuccessfully: "E-mail je uspješno potvrđen!",
+ failedToVerifyEmail: "Neuspjela potvrda e-maila",
+ newContactAddedTitle: "Dodan novi kontakt",
+ newContactAddedSubtitle: "Na uređaj je dodan novi kontakt",
+ contactName: "Ime kontakta",
+ unknown: "Nepoznato",
+ phoneNumber: "Telefonski broj",
+ emailAddress: "E-mail adresa",
+ identifier: "Identifikator",
+ notProvided: "Nije navedeno",
+ unknownDevice: "Nepoznati uređaj",
+ contactAddedInfo:
+ "Ovaj kontakt je dodan na {deviceName}. Možete ga pregledati na popisu kontakata uređaja.",
+ backToHome: "Natrag na početnu",
+ noDeviceSelectedPleaseLinkFirst:
+ "Nije odabran uređaj. Najprije povežite uređaj.",
+};
+
+type Locale = "en" | "hr";
+
+export const locales: Record<Locale, typeof en> = {
+ en,
+ hr,
+} as const;
+
+export type LocaleKey = keyof typeof en;
+
+export const getCurrentLocale = (): Locale => {
+ const currentLocale = getLocales()[0]?.languageTag || "en";
+ return currentLocale.startsWith("hr") ? "hr" : "en";
+};
+
+export const t = (key: LocaleKey): string => {
+ const locale = getCurrentLocale();
+ return locales[locale][key] || locales.en[key] || key;
+};
diff --git a/lib/notifications.ts b/lib/notifications.ts
new file mode 100644
index 0000000..a9c9a95
--- /dev/null
+++ b/lib/notifications.ts
@@ -0,0 +1,206 @@
+import Constants from "expo-constants";
+import * as Device from "expo-device";
+import * as Notifications from "expo-notifications";
+import { Platform } from "react-native";
+import { apiClient } from "../api/client";
+
+// Configure notification handling behavior
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: true,
+ shouldPlaySound: true,
+ shouldSetBadge: true,
+ shouldShowBanner: true,
+ shouldShowList: true,
+ }),
+});
+
+export type NotificationPermissionStatus =
+ | "granted"
+ | "denied"
+ | "undetermined";
+
+/**
+ * Get current notification permission status
+ */
+export async function getNotificationPermissionStatus(): Promise<NotificationPermissionStatus> {
+ const { status } = await Notifications.getPermissionsAsync();
+ return status;
+}
+
+/**
+ * Request notification permissions from the user
+ */
+export async function requestNotificationPermissions(): Promise<NotificationPermissionStatus> {
+ // Check if we're on a physical device (notifications don't work on simulator)
+ if (!Device.isDevice) {
+ console.log("Push notifications require a physical device");
+ return "denied";
+ }
+
+ // Get existing permissions first
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
+
+ let finalStatus = existingStatus;
+
+ // Only ask if permissions have not already been determined
+ if (existingStatus !== "granted") {
+ const { status } = await Notifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+
+ return finalStatus;
+}
+
+/**
+ * Get the Expo push token for this device
+ */
+export async function getExpoPushToken(): Promise<string | null> {
+ if (!Device.isDevice) {
+ console.log("Push notifications require a physical device");
+ return null;
+ }
+
+ const { status } = await Notifications.getPermissionsAsync();
+ if (status !== "granted") {
+ console.log("Notification permissions not granted");
+ return null;
+ }
+
+ try {
+ // Get the project ID from Expo config
+ const projectId =
+ Constants.expoConfig?.extra?.eas?.projectId ??
+ Constants.easConfig?.projectId;
+
+ if (!projectId) {
+ console.error("Project ID not found in Expo config");
+ return null;
+ }
+
+ const tokenData = await Notifications.getExpoPushTokenAsync({
+ projectId,
+ });
+
+ return tokenData.data;
+ } catch (error) {
+ console.error("Failed to get Expo push token:", error);
+ return null;
+ }
+}
+
+/**
+ * Register push token with the backend
+ */
+export async function registerPushToken(): Promise<boolean> {
+ const token = await getExpoPushToken();
+
+ if (!token) {
+ console.log("No push token available to register");
+ return false;
+ }
+
+ try {
+ await apiClient.post("/parent/push-token", { token });
+ console.log("Push token registered successfully");
+ return true;
+ } catch (error) {
+ console.error("Failed to register push token:", error);
+ return false;
+ }
+}
+
+/**
+ * Remove push token from the backend
+ */
+export async function unregisterPushToken(): Promise<boolean> {
+ const token = await getExpoPushToken();
+
+ if (!token) {
+ console.log("No push token available to unregister");
+ return false;
+ }
+
+ try {
+ await apiClient.delete("/parent/push-token", { token });
+ console.log("Push token unregistered successfully");
+ return true;
+ } catch (error) {
+ console.error("Failed to unregister push token:", error);
+ return false;
+ }
+}
+
+/**
+ * Set up Android notification channel for alerts
+ */
+export async function setupNotificationChannels(): Promise<void> {
+ if (Platform.OS === "android") {
+ await Notifications.setNotificationChannelAsync("default", {
+ name: "Default",
+ importance: Notifications.AndroidImportance.DEFAULT,
+ vibrationPattern: [0, 250, 250, 250],
+ lightColor: "#FF231F7C",
+ });
+
+ await Notifications.setNotificationChannelAsync("alerts", {
+ name: "Safety Alerts",
+ description: "Important safety alerts about your child's device",
+ importance: Notifications.AndroidImportance.HIGH,
+ vibrationPattern: [0, 500, 250, 500],
+ lightColor: "#FF0000",
+ sound: "default",
+ enableVibrate: true,
+ enableLights: true,
+ });
+ }
+}
+
+/**
+ * Add a listener for received notifications (when app is foregrounded)
+ */
+export function addNotificationReceivedListener(
+ callback: (notification: Notifications.Notification) => void
+): Notifications.EventSubscription {
+ return Notifications.addNotificationReceivedListener(callback);
+}
+
+/**
+ * Add a listener for notification responses (when user taps notification)
+ */
+export function addNotificationResponseListener(
+ callback: (response: Notifications.NotificationResponse) => void
+): Notifications.EventSubscription {
+ return Notifications.addNotificationResponseReceivedListener(callback);
+}
+
+/**
+ * Get the last notification response (for handling cold start from notification)
+ */
+export async function getLastNotificationResponse(): Promise<Notifications.NotificationResponse | null> {
+ return Notifications.getLastNotificationResponseAsync();
+}
+
+/**
+ * Initialize notifications: setup channels, request permissions, and register token
+ * Call this when the user is authenticated
+ */
+export async function initializeNotifications(): Promise<{
+ permissionStatus: NotificationPermissionStatus;
+ tokenRegistered: boolean;
+}> {
+ // Setup Android channels first
+ await setupNotificationChannels();
+
+ // Request permissions
+ const permissionStatus = await requestNotificationPermissions();
+
+ if (permissionStatus !== "granted") {
+ return { permissionStatus, tokenRegistered: false };
+ }
+
+ // Register token with backend
+ const tokenRegistered = await registerPushToken();
+
+ return { permissionStatus, tokenRegistered };
+}
diff --git a/lib/theme.ts b/lib/theme.ts
new file mode 100644
index 0000000..3d23af3
--- /dev/null
+++ b/lib/theme.ts
@@ -0,0 +1,24 @@
+export const colors = {
+ primary: "#F42E2E",
+ onPrimary: "#F3F3F3",
+ primaryContainer: "#F42E2E",
+ onPrimaryContainer: "#F3F3F3",
+ secondary: "#F42E2E",
+ onSecondary: "#F3F3F3",
+ secondaryContainer: "#F42E2E",
+ onSecondaryContainer: "#F3F3F3",
+
+ background: "#020202",
+ onBackground: "#F3F3F3",
+
+ surface: "#111112",
+ onSurface: "#F3F3F3",
+
+ surfaceVariant: "#1A1A1B",
+ onSurfaceVariant: "#F3F3F3",
+
+ surfaceContainer: "#111112",
+ surfaceContainerLow: "#0A0A0A",
+
+ outline: "#3A3A3A",
+};
diff --git a/lib/ui.tsx b/lib/ui.tsx
new file mode 100644
index 0000000..e31a10d
--- /dev/null
+++ b/lib/ui.tsx
@@ -0,0 +1,846 @@
+import { Ionicons } from "@expo/vector-icons";
+import React, { ReactNode } from "react";
+import {
+ ActivityIndicator,
+ Modal,
+ Pressable,
+ TextInput as RNTextInput,
+ ScrollView,
+ StyleProp,
+ StyleSheet,
+ Text,
+ TextStyle,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from "react-native";
+import { Device } from "../api/types";
+import { colors } from "./theme";
+
+export function Screen({
+ children,
+ contentContainerStyle,
+}: {
+ children: ReactNode;
+ contentContainerStyle?: StyleProp<ViewStyle>;
+}) {
+ return (
+ <ScrollView
+ style={styles.screen}
+ contentContainerStyle={[styles.screenContent, contentContainerStyle]}
+ contentInsetAdjustmentBehavior="automatic"
+ >
+ {children}
+ </ScrollView>
+ );
+}
+
+export function Card({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp<ViewStyle>;
+}) {
+ return <View style={[styles.card, style]}>{children}</View>;
+}
+
+export function H1({ children }: { children: ReactNode }) {
+ return <Text style={styles.h1}>{children}</Text>;
+}
+
+export function H2({ children }: { children: ReactNode }) {
+ return <Text style={styles.h2}>{children}</Text>;
+}
+
+export function Body({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp<TextStyle>;
+}) {
+ return <Text style={[styles.body, style]}>{children}</Text>;
+}
+
+export function Muted({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp<TextStyle>;
+}) {
+ return <Text style={[styles.muted, style]}>{children}</Text>;
+}
+
+export function Pill({
+ label,
+ tone = "neutral",
+}: {
+ label: string;
+ tone?: "neutral" | "good" | "attention";
+}) {
+ const backgroundColor =
+ tone === "good"
+ ? colors.surfaceVariant
+ : tone === "attention"
+ ? colors.primaryContainer
+ : colors.surfaceVariant;
+
+ const borderColor = tone === "attention" ? colors.primary : colors.outline;
+
+ const textColor = tone === "attention" ? colors.onPrimary : colors.onSurface;
+
+ return (
+ <View style={[styles.pill, { backgroundColor, borderColor }]}>
+ <Text style={[styles.pillText, { color: textColor }]}>{label}</Text>
+ </View>
+ );
+}
+
+export function Row({ left, right }: { left: ReactNode; right?: ReactNode }) {
+ return (
+ <View style={styles.row}>
+ <View style={styles.rowLeft}>{left}</View>
+ {right ? <View style={styles.rowRight}>{right}</View> : null}
+ </View>
+ );
+}
+
+export function Divider() {
+ return <View style={styles.divider} />;
+}
+
+export function ActionRow({
+ title,
+ subtitle,
+ onPress,
+ right,
+}: {
+ title: string;
+ subtitle?: string;
+ onPress?: () => void;
+ right?: ReactNode;
+}) {
+ return (
+ <Pressable
+ onPress={onPress}
+ disabled={!onPress}
+ style={({ pressed }) => [
+ styles.actionRow,
+ pressed && onPress ? styles.actionRowPressed : undefined,
+ ]}
+ >
+ <View style={{ flex: 1, gap: 4 }}>
+ <Text style={styles.actionTitle}>{title}</Text>
+ {subtitle ? (
+ <Text style={styles.actionSubtitle}>{subtitle}</Text>
+ ) : null}
+ </View>
+ {right ? (
+ <View style={{ marginLeft: 12, alignItems: "flex-end" }}>{right}</View>
+ ) : null}
+ </Pressable>
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Dialog / Modal Components
+// ─────────────────────────────────────────────────────────────────────────────
+
+export type DialogOption = {
+ label: string;
+ onPress: () => void;
+ destructive?: boolean;
+};
+
+/**
+ * A selection dialog that shows a list of options to choose from.
+ */
+export function SelectDialog({
+ visible,
+ title,
+ options,
+ onClose,
+}: {
+ visible: boolean;
+ title: string;
+ options: DialogOption[];
+ onClose: () => void;
+}) {
+ return (
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={onClose}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>{title}</Text>
+ {options.map((option, index) => (
+ <TouchableOpacity
+ key={index}
+ onPress={option.onPress}
+ style={styles.dialogOption}
+ >
+ <Text
+ style={[
+ styles.dialogOptionText,
+ option.destructive && styles.dialogDestructiveText,
+ ]}
+ >
+ {option.label}
+ </Text>
+ </TouchableOpacity>
+ ))}
+ <TouchableOpacity onPress={onClose} style={styles.dialogCancel}>
+ <Text style={styles.dialogCancelText}>Cancel</Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+/**
+ * A confirmation dialog with a message and OK/Cancel buttons.
+ */
+export function ConfirmDialog({
+ visible,
+ title,
+ message,
+ confirmLabel = "Okay",
+ cancelLabel = "Cancel",
+ onConfirm,
+ onCancel,
+ destructive = false,
+}: {
+ visible: boolean;
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ destructive?: boolean;
+}) {
+ return (
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={onCancel}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>{title}</Text>
+ <Text style={styles.dialogBody}>{message}</Text>
+ <View style={styles.dialogButtonRow}>
+ <TouchableOpacity
+ onPress={onCancel}
+ style={styles.dialogInlineButton}
+ >
+ <Text style={styles.dialogButtonText}>{cancelLabel}</Text>
+ </TouchableOpacity>
+ <TouchableOpacity
+ onPress={onConfirm}
+ style={styles.dialogInlineButton}
+ >
+ <Text
+ style={[
+ styles.dialogButtonText,
+ destructive && styles.dialogDestructiveText,
+ ]}
+ >
+ {confirmLabel}
+ </Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+/**
+ * A prompt dialog with an input field, message, and Cancel/Submit buttons.
+ */
+export function PromptDialog({
+ visible,
+ title,
+ message,
+ onClose,
+ onSubmit,
+ initialValue = "",
+}: {
+ visible: boolean;
+ title: string;
+ message: string;
+ onClose: () => void;
+ onSubmit: (value: string) => void;
+ initialValue?: string;
+}) {
+ const [value, setValue] = React.useState(initialValue);
+
+ React.useEffect(() => {
+ setValue(initialValue);
+ }, [initialValue, visible]);
+
+ return (
+ <Modal
+ visible={visible}
+ transparent={true}
+ animationType="fade"
+ onRequestClose={onClose}
+ >
+ <View style={styles.modalOverlay}>
+ <View style={styles.modalContent}>
+ <H2>{title}</H2>
+ <Muted>{message}</Muted>
+
+ <View style={{ height: 12 }} />
+
+ <TextInput value={value} onChangeText={setValue} />
+ <View style={styles.modalActions}>
+ <Button title="Cancel" onPress={onClose} variant="secondary" />
+ <Button title="Submit" onPress={() => onSubmit(value)} />
+ </View>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+export function AlertDialog({
+ visible,
+ title,
+ message,
+ buttonLabel = "OK",
+ onClose,
+}: {
+ visible: boolean;
+ title: string;
+ message?: string;
+ buttonLabel?: string;
+ onClose: () => void;
+}) {
+ return (
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={onClose}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>{title}</Text>
+ {message && <Text style={styles.dialogBody}>{message}</Text>}
+ <View style={styles.dialogButtonRow}>
+ <TouchableOpacity
+ onPress={onClose}
+ style={styles.dialogInlineButton}
+ >
+ <Text style={styles.dialogButtonText}>{buttonLabel}</Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </View>
+ </Modal>
+ );
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Form Components
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function TextInput({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ secureTextEntry,
+ autoCapitalize = "none",
+ keyboardType = "default",
+ error,
+}: {
+ label?: string;
+ value: string;
+ onChangeText: (text: string) => void;
+ placeholder?: string;
+ secureTextEntry?: boolean;
+ autoCapitalize?: "none" | "sentences" | "words" | "characters";
+ keyboardType?: "default" | "email-address" | "numeric";
+ error?: string;
+}) {
+ return (
+ <View style={styles.inputContainer}>
+ {label && <Text style={styles.inputLabel}>{label}</Text>}
+ <RNTextInput
+ style={[styles.textInput, error && styles.textInputError]}
+ value={value}
+ onChangeText={onChangeText}
+ placeholder={placeholder}
+ placeholderTextColor={colors.onSurfaceVariant}
+ secureTextEntry={secureTextEntry}
+ autoCapitalize={autoCapitalize}
+ keyboardType={keyboardType}
+ />
+ {error && <Text style={styles.inputError}>{error}</Text>}
+ </View>
+ );
+}
+
+export function Button({
+ title,
+ onPress,
+ variant = "primary",
+ disabled,
+ loading,
+}: {
+ title: string;
+ onPress: () => void;
+ variant?: "primary" | "secondary" | "text";
+ disabled?: boolean;
+ loading?: boolean;
+}) {
+ const isPrimary = variant === "primary";
+ const isText = variant === "text";
+
+ return (
+ <TouchableOpacity
+ onPress={onPress}
+ disabled={disabled || loading}
+ style={[
+ styles.button,
+ isPrimary && styles.buttonPrimary,
+ !isPrimary && !isText && styles.buttonSecondary,
+ isText && styles.buttonText,
+ (disabled || loading) && styles.buttonDisabled,
+ ]}
+ >
+ {loading ? (
+ <ActivityIndicator
+ color={isPrimary ? colors.onPrimary : colors.primary}
+ size="small"
+ />
+ ) : (
+ <Text
+ style={[
+ styles.buttonLabel,
+ isPrimary && styles.buttonLabelPrimary,
+ !isPrimary && styles.buttonLabelSecondary,
+ ]}
+ >
+ {title}
+ </Text>
+ )}
+ </TouchableOpacity>
+ );
+}
+
+export function LoadingScreen() {
+ return (
+ <View style={styles.loadingScreen}>
+ <ActivityIndicator size="large" color={colors.primary} />
+ </View>
+ );
+}
+
+/**
+ * Device selector dropdown component for selecting a device.
+ */
+export function DeviceSelector({
+ devices,
+ selectedDevice,
+ onSelectDevice,
+ isLoading,
+}: {
+ devices: Device[];
+ selectedDevice: Device | null;
+ onSelectDevice: (device: Device) => void;
+ isLoading?: boolean;
+}) {
+ const [visible, setVisible] = React.useState(false);
+
+ if (isLoading) {
+ return (
+ <View style={styles.deviceSelectorContainer}>
+ <ActivityIndicator size="small" color={colors.onSurfaceVariant} />
+ </View>
+ );
+ }
+
+ if (devices.length === 0) {
+ return (
+ <View style={styles.deviceSelectorContainer}>
+ <Text style={styles.deviceSelectorText}>No devices linked</Text>
+ </View>
+ );
+ }
+
+ return (
+ <>
+ <Pressable
+ style={styles.deviceSelectorContainer}
+ onPress={() => setVisible(true)}
+ >
+ <View style={styles.deviceSelectorContent}>
+ <View
+ style={[
+ styles.deviceStatusDot,
+ {
+ backgroundColor:
+ selectedDevice?.status === "online"
+ ? "#4CAF50"
+ : colors.onSurfaceVariant,
+ },
+ ]}
+ />
+ <Text style={styles.deviceSelectorText} numberOfLines={1}>
+ {selectedDevice?.name || "Select device"}
+ </Text>
+ <Ionicons
+ name="chevron-down"
+ size={16}
+ color={colors.onSurfaceVariant}
+ />
+ </View>
+ </Pressable>
+
+ <Modal
+ visible={visible}
+ transparent
+ animationType="fade"
+ onRequestClose={() => setVisible(false)}
+ >
+ <View style={styles.dialogOverlay}>
+ <View style={styles.dialogCard}>
+ <Text style={styles.dialogTitle}>Select Device</Text>
+ {devices.map((device) => (
+ <TouchableOpacity
+ key={device.id}
+ onPress={() => {
+ onSelectDevice(device);
+ setVisible(false);
+ }}
+ style={[
+ styles.deviceOption,
+ selectedDevice?.id === device.id &&
+ styles.deviceOptionSelected,
+ ]}
+ >
+ <View style={styles.deviceOptionContent}>
+ <View
+ style={[
+ styles.deviceStatusDot,
+ {
+ backgroundColor:
+ device.status === "online"
+ ? "#4CAF50"
+ : colors.onSurfaceVariant,
+ },
+ ]}
+ />
+ <View style={{ flex: 1 }}>
+ <Text style={styles.deviceOptionName}>{device.name}</Text>
+ <Text style={styles.deviceOptionStatus}>
+ {device.status === "online" ? "Online" : "Offline"} •{" "}
+ {device.lastCheck}
+ </Text>
+ </View>
+ {selectedDevice?.id === device.id && (
+ <Ionicons
+ name="checkmark"
+ size={20}
+ color={colors.primary}
+ />
+ )}
+ </View>
+ </TouchableOpacity>
+ ))}
+ <TouchableOpacity
+ onPress={() => setVisible(false)}
+ style={styles.dialogCancel}
+ >
+ <Text style={styles.dialogCancelText}>Cancel</Text>
+ </TouchableOpacity>
+ </View>
+ </View>
+ </Modal>
+ </>
+ );
+}
+
+const styles = StyleSheet.create({
+ screen: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ screenContent: {
+ padding: 16,
+ gap: 12,
+ },
+ card: {
+ backgroundColor: colors.surface,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ padding: 14,
+ gap: 10,
+ },
+ h1: {
+ color: colors.onBackground,
+ fontSize: 24,
+ fontWeight: "800",
+ },
+ h2: {
+ color: colors.onBackground,
+ fontSize: 16,
+ fontWeight: "700",
+ },
+ body: {
+ color: colors.onBackground,
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ muted: {
+ color: colors.onSurfaceVariant,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ pill: {
+ alignSelf: "flex-start",
+ borderWidth: StyleSheet.hairlineWidth,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ },
+ pillText: {
+ fontSize: 12,
+ fontWeight: "700",
+ letterSpacing: 0.2,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ },
+ rowLeft: {
+ flex: 1,
+ },
+ rowRight: {
+ alignItems: "flex-end",
+ },
+ divider: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.outline,
+ opacity: 0.7,
+ },
+ actionRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: 10,
+ gap: 12,
+ },
+ actionRowPressed: {
+ opacity: 0.85,
+ },
+ actionTitle: {
+ color: colors.onBackground,
+ fontSize: 14,
+ fontWeight: "700",
+ },
+ actionSubtitle: {
+ color: colors.onSurfaceVariant,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ // Dialog styles
+ dialogOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.4)",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: 24,
+ },
+ dialogCard: {
+ width: "100%",
+ backgroundColor: colors.surface,
+ padding: 16,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ gap: 12,
+ },
+ dialogTitle: {
+ fontSize: 16,
+ fontWeight: "700",
+ color: colors.onBackground,
+ },
+ dialogBody: {
+ color: colors.onSurfaceVariant,
+ fontSize: 14,
+ marginBottom: 8,
+ },
+ dialogOption: {
+ paddingVertical: 12,
+ },
+ dialogOptionText: {
+ color: colors.onBackground,
+ fontSize: 15,
+ fontWeight: "700",
+ },
+ dialogDestructiveText: {
+ color: "#b00020",
+ },
+ dialogCancel: {
+ paddingVertical: 12,
+ alignItems: "center",
+ },
+ dialogCancelText: {
+ color: colors.onSurfaceVariant,
+ fontWeight: "700",
+ },
+ dialogButtonRow: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ gap: 12,
+ },
+ dialogInlineButton: {
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ },
+ dialogButtonText: {
+ color: colors.onBackground,
+ fontSize: 15,
+ fontWeight: "700",
+ },
+ // Form styles
+ inputContainer: {
+ gap: 6,
+ },
+ inputLabel: {
+ color: colors.onBackground,
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ textInput: {
+ backgroundColor: colors.surfaceVariant,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ color: colors.onBackground,
+ fontSize: 16,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ textInputError: {
+ borderColor: colors.primary,
+ },
+ inputError: {
+ color: colors.primary,
+ fontSize: 12,
+ },
+ button: {
+ alignItems: "center",
+ justifyContent: "center",
+ paddingVertical: 14,
+ paddingHorizontal: 24,
+ },
+ buttonPrimary: {
+ backgroundColor: colors.primary,
+ },
+ buttonSecondary: {
+ backgroundColor: "transparent",
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ },
+ buttonText: {
+ backgroundColor: "transparent",
+ },
+ buttonDisabled: {
+ opacity: 0.5,
+ },
+ buttonLabel: {
+ fontSize: 16,
+ fontWeight: "700",
+ },
+ buttonLabelPrimary: {
+ color: colors.onPrimary,
+ },
+ buttonLabelSecondary: {
+ color: colors.onBackground,
+ },
+ loadingScreen: {
+ flex: 1,
+ backgroundColor: colors.background,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ // Device Selector styles
+ deviceSelectorContainer: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: colors.surfaceVariant,
+ borderColor: colors.outline,
+ borderWidth: StyleSheet.hairlineWidth,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ marginBottom: 4,
+ },
+ deviceSelectorContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ flex: 1,
+ },
+ deviceSelectorText: {
+ flex: 1,
+ color: colors.onBackground,
+ fontSize: 14,
+ fontWeight: "600",
+ },
+ deviceStatusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ deviceOption: {
+ paddingVertical: 12,
+ paddingHorizontal: 4,
+ },
+ deviceOptionSelected: {
+ backgroundColor: colors.surfaceVariant,
+ },
+ deviceOptionContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 12,
+ },
+ deviceOptionName: {
+ color: colors.onBackground,
+ fontSize: 15,
+ fontWeight: "600",
+ },
+ deviceOptionStatus: {
+ color: colors.onSurfaceVariant,
+ fontSize: 12,
+ marginTop: 2,
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.7)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalContent: {
+ backgroundColor: colors.surface,
+ borderRadius: 20,
+ padding: 20,
+ width: "80%",
+ },
+ modalActions: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ marginTop: 20,
+ gap: 10,
+ },
+});