diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-02-06 13:22:33 +0100 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-02-06 13:22:33 +0100 |
| commit | 7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 (patch) | |
| tree | 57b7dd06ac9aa7053c671d916f7183e3b4fa9410 /lib | |
feat: initial commit!
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/auth.tsx | 81 | ||||
| -rw-r--r-- | lib/device.tsx | 90 | ||||
| -rw-r--r-- | lib/locales.ts | 247 | ||||
| -rw-r--r-- | lib/notifications.ts | 206 | ||||
| -rw-r--r-- | lib/theme.ts | 24 | ||||
| -rw-r--r-- | lib/ui.tsx | 846 |
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, + }, +}); |