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 /app/(tabs) | |
feat: initial commit!
Diffstat (limited to 'app/(tabs)')
| -rw-r--r-- | app/(tabs)/_layout.tsx | 188 | ||||
| -rw-r--r-- | app/(tabs)/alerts.tsx | 114 | ||||
| -rw-r--r-- | app/(tabs)/contact-detail.tsx | 193 | ||||
| -rw-r--r-- | app/(tabs)/controls.tsx | 270 | ||||
| -rw-r--r-- | app/(tabs)/index.tsx | 135 | ||||
| -rw-r--r-- | app/(tabs)/settings.tsx | 221 |
6 files changed, 1121 insertions, 0 deletions
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..6bd6527 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,188 @@ +import { Ionicons } from "@expo/vector-icons"; +import * as Notifications from "expo-notifications"; +import { Redirect, Tabs, router } from "expo-router"; +import { useEffect, useRef } from "react"; +import { View } from "react-native"; +import { useAuth } from "../../lib/auth"; +import { DeviceProvider } from "../../lib/device"; +import { t } from "../../lib/locales"; +import { + addNotificationReceivedListener, + addNotificationResponseListener, + getLastNotificationResponse, + initializeNotifications, +} from "../../lib/notifications"; +import { colors } from "../../lib/theme"; +import { LoadingScreen } from "../../lib/ui"; + +export default function TabsLayout() { + const { isLoading, isAuthenticated } = useAuth(); + const notificationListener = useRef<Notifications.EventSubscription | null>( + null + ); + const responseListener = useRef<Notifications.EventSubscription | null>(null); + + useEffect(() => { + if (!isAuthenticated) return; + + // Initialize notifications when authenticated + initializeNotifications().then(({ permissionStatus, tokenRegistered }) => { + console.log( + `Notifications initialized: permission=${permissionStatus}, tokenRegistered=${tokenRegistered}` + ); + }); + + // Set up notification listeners + notificationListener.current = addNotificationReceivedListener( + (notification) => { + console.log("Notification received:", notification); + } + ); + + responseListener.current = addNotificationResponseListener((response) => { + console.log("Notification tapped:", response); + const data = response.notification.request.content.data; + + // Navigate based on notification type + if (data?.type === "dangerous_content") { + router.push("/(tabs)/alerts"); + } else if (data?.type === "new_contact") { + // Navigate to contact detail screen with contact info + router.push({ + pathname: "/(tabs)/contact-detail", + params: { + contactName: String(data.contactName || "Unknown"), + contactIdentifier: String(data.contactIdentifier || "Not provided"), + contactType: String(data.contactType || "unknown"), + deviceName: String(data.deviceName || "Unknown Device"), + }, + }); + } + }); + + // Check if app was opened from a notification (cold start) + getLastNotificationResponse().then((response) => { + if (response) { + const data = response.notification.request.content.data; + if (data?.type === "dangerous_content") { + router.push("/(tabs)/alerts"); + } else if (data?.type === "new_contact") { + router.push({ + pathname: "/(tabs)/contact-detail", + params: { + contactName: String(data.contactName || "Unknown"), + contactIdentifier: String( + data.contactIdentifier || "Not provided" + ), + contactType: String(data.contactType || "unknown"), + deviceName: String(data.deviceName || "Unknown Device"), + }, + }); + } + } + }); + + return () => { + if (notificationListener.current) { + notificationListener.current.remove(); + } + if (responseListener.current) { + responseListener.current.remove(); + } + }; + }, [isAuthenticated]); + + if (isLoading) { + return <LoadingScreen />; + } + + if (!isAuthenticated) { + return <Redirect href="/(auth)/welcome" />; + } + + return ( + <DeviceProvider> + <View + style={{ + flex: 1, + backgroundColor: colors.background, + }} + > + <Tabs + screenOptions={{ + tabBarShowLabel: true, + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: colors.onSurfaceVariant, + tabBarLabelStyle: { + fontSize: 11, + fontWeight: "600", + }, + tabBarStyle: { + backgroundColor: colors.surfaceContainer, + borderTopColor: colors.outline, + }, + headerStyle: { + backgroundColor: colors.background, + borderWidth: 0, + marginBottom: 10, + }, + headerTintColor: colors.onBackground, + headerTitleStyle: { + color: colors.onBackground, + fontSize: 30, + fontWeight: "700", + }, + }} + > + <Tabs.Screen + name="index" + options={{ + title: t("home"), + tabBarIcon: ({ color, size }) => ( + <Ionicons name="home" size={size ?? 24} color={color} /> + ), + }} + /> + <Tabs.Screen + name="alerts" + options={{ + title: t("alerts"), + tabBarIcon: ({ color, size }) => ( + <Ionicons name="alert-circle" size={size ?? 24} color={color} /> + ), + }} + /> + <Tabs.Screen + name="controls" + options={{ + title: t("controls"), + tabBarIcon: ({ color, size }) => ( + <Ionicons + name="shield-checkmark" + size={size ?? 24} + color={color} + /> + ), + }} + /> + <Tabs.Screen + name="contact-detail" + options={{ + title: "Contact Details", + href: null, + }} + /> + <Tabs.Screen + name="settings" + options={{ + title: t("settings"), + tabBarIcon: ({ color, size }) => ( + <Ionicons name="settings" size={size ?? 24} color={color} /> + ), + }} + /> + </Tabs> + </View> + </DeviceProvider> + ); +} diff --git a/app/(tabs)/alerts.tsx b/app/(tabs)/alerts.tsx new file mode 100644 index 0000000..590fe87 --- /dev/null +++ b/app/(tabs)/alerts.tsx @@ -0,0 +1,114 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import { Alert, getAlerts } from "../../api"; +import { t } from "../../lib/locales"; +import { colors } from "../../lib/theme"; +import { Card, Divider, H2, Muted, Pill, Row, Screen } from "../../lib/ui"; + +export default function AlertsScreen() { + const [alerts, setAlerts] = useState<Alert[]>([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + console.log("AlertsScreen: Fetching alerts..."); + setLoading(true); + getAlerts() + .then((data) => { + console.log("AlertsScreen: Received alerts:", data); + setAlerts(data); + }) + .catch((error) => { + console.error("AlertsScreen: Error fetching alerts:", error); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + return ( + <Screen> + {loading ? ( + <View style={{ padding: 20, alignItems: "center" }}> + <ActivityIndicator size="large" color={colors.primary} /> + <Text style={{ color: colors.onSurfaceVariant, marginTop: 10 }}> + {t("loadingAlerts")} + </Text> + </View> + ) : alerts.length === 0 ? ( + <Card> + <View style={{ alignItems: "center", padding: 20 }}> + <Ionicons + name="checkmark-circle" + size={48} + color={colors.onSurfaceVariant} + /> + <H2>{t("allClearTitle")}</H2> + <Muted style={{ textAlign: "center", marginTop: 5 }}> + {t("noAlertsToReview")} + </Muted> + </View> + </Card> + ) : ( + alerts.map((alert) => ( + <Card key={alert.id}> + <Row + left={ + <View + style={{ + flexDirection: "row", + alignItems: "center", + gap: 10, + }} + > + <Ionicons + name={ + alert.severity === "needs_attention" + ? "alert-circle" + : "information-circle" + } + size={18} + color={ + alert.severity === "needs_attention" + ? colors.primary + : colors.onSurfaceVariant + } + /> + <View style={{ flex: 1 }}> + <H2>{alert.title}</H2> + <Muted>{alert.timeLabel}</Muted> + </View> + </View> + } + right={ + <Pill + label={ + alert.severity === "needs_attention" + ? t("reviewPill") + : t("fyiPill") + } + tone={ + alert.severity === "needs_attention" + ? "attention" + : "neutral" + } + /> + } + /> + + <Divider /> + + <H2>{t("whatHappened")}</H2> + <Muted>{alert.whatHappened}</Muted> + + <H2>{t("whyItMatters")}</H2> + <Muted>{alert.whyItMatters}</Muted> + + <H2>{t("suggestedNextStep")}</H2> + <Muted>{alert.suggestedAction}</Muted> + </Card> + )) + )} + </Screen> + ); +} diff --git a/app/(tabs)/contact-detail.tsx b/app/(tabs)/contact-detail.tsx new file mode 100644 index 0000000..520cc15 --- /dev/null +++ b/app/(tabs)/contact-detail.tsx @@ -0,0 +1,193 @@ +import { Ionicons } from "@expo/vector-icons"; +import { router, useLocalSearchParams } from "expo-router"; +import { ScrollView, StyleSheet, Text, View } from "react-native"; +import { t } from "../../lib/locales"; +import { colors } from "../../lib/theme"; +import { Button } from "../../lib/ui"; + +export default function ContactDetailScreen() { + const params = useLocalSearchParams<{ + contactName: string; + contactIdentifier: string; + contactType: string; + deviceName: string; + }>(); + + const { contactName, contactIdentifier, contactType, deviceName } = params; + + const getContactTypeIcon = () => { + switch (contactType) { + case "phone": + return "call"; + case "email": + return "mail"; + default: + return "person"; + } + }; + + const getContactTypeLabel = () => { + switch (contactType) { + case "phone": + return t("phoneNumber"); + case "email": + return t("emailAddress"); + default: + return t("identifier"); + } + }; + + return ( + <ScrollView + style={styles.container} + contentContainerStyle={styles.contentContainer} + > + <View style={styles.header}> + <View style={styles.iconContainer}> + <Ionicons name="person-add" size={48} color={colors.primary} /> + </View> + <Text style={styles.title}>{t("newContactAddedTitle")}</Text> + <Text style={styles.subtitle}>{t("newContactAddedSubtitle")}</Text> + </View> + + <View style={styles.detailsCard}> + <View style={styles.detailRow}> + <View style={styles.detailLabel}> + <Ionicons name="person" size={20} color={colors.onSurfaceVariant} /> + <Text style={styles.detailLabelText}>{t("contactName")}</Text> + </View> + <Text style={styles.detailValue}>{contactName || t("unknown")}</Text> + </View> + + <View style={styles.divider} /> + + <View style={styles.detailRow}> + <View style={styles.detailLabel}> + <Ionicons + name={getContactTypeIcon()} + size={20} + color={colors.onSurfaceVariant} + /> + <Text style={styles.detailLabelText}>{getContactTypeLabel()}</Text> + </View> + <Text style={styles.detailValue}> + {contactIdentifier || t("notProvided")} + </Text> + </View> + + <View style={styles.divider} /> + + <View style={styles.detailRow}> + <View style={styles.detailLabel}> + <Ionicons + name="phone-portrait" + size={20} + color={colors.onSurfaceVariant} + /> + <Text style={styles.detailLabelText}>{t("device")}</Text> + </View> + <Text style={styles.detailValue}> + {deviceName || t("unknownDevice")} + </Text> + </View> + </View> + + <View style={styles.infoCard}> + <Ionicons name="information-circle" size={24} color={colors.primary} /> + <Text style={styles.infoText}> + {t("contactAddedInfo").replace( + "{deviceName}", + deviceName || t("unknownDevice"), + )} + </Text> + </View> + + <Button title={t("backToHome")} onPress={() => router.push("/(tabs)")} /> + </ScrollView> + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + contentContainer: { + padding: 20, + paddingBottom: 40, + }, + header: { + alignItems: "center", + marginBottom: 32, + }, + iconContainer: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: colors.primaryContainer, + alignItems: "center", + justifyContent: "center", + marginBottom: 16, + }, + title: { + fontSize: 24, + fontWeight: "700", + color: colors.onBackground, + marginBottom: 8, + textAlign: "center", + }, + subtitle: { + fontSize: 14, + color: colors.onSurfaceVariant, + textAlign: "center", + }, + detailsCard: { + backgroundColor: colors.surfaceContainer, + borderRadius: 16, + padding: 20, + marginBottom: 20, + }, + detailRow: { + gap: 12, + }, + detailLabel: { + flexDirection: "row", + alignItems: "center", + gap: 8, + marginBottom: 4, + }, + detailLabelText: { + fontSize: 12, + fontWeight: "600", + color: colors.onSurfaceVariant, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + detailValue: { + fontSize: 16, + fontWeight: "500", + color: colors.onSurface, + }, + divider: { + height: 1, + backgroundColor: colors.outline, + marginVertical: 16, + }, + infoCard: { + flexDirection: "row", + gap: 12, + padding: 16, + backgroundColor: colors.primaryContainer, + borderRadius: 12, + marginBottom: 24, + }, + infoText: { + flex: 1, + fontSize: 14, + color: colors.onPrimaryContainer, + lineHeight: 20, + }, + button: { + marginTop: 8, + }, +}); diff --git a/app/(tabs)/controls.tsx b/app/(tabs)/controls.tsx new file mode 100644 index 0000000..ac44f1b --- /dev/null +++ b/app/(tabs)/controls.tsx @@ -0,0 +1,270 @@ +import { useEffect, useState } from "react"; +import { Pressable, Switch, View } from "react-native"; +import { ControlsData, getControlsData, updateSafetyControl } from "../../api"; +import { useDevice } from "../../lib/device"; +import { t } from "../../lib/locales"; +import { colors } from "../../lib/theme"; +import { + Card, + ConfirmDialog, + DeviceSelector, + Divider, + H2, + Muted, + Row, + Screen, + SelectDialog, +} from "../../lib/ui"; + +export default function ControlsScreen() { + const [data, setData] = useState<ControlsData | null>(null); + const [state, setState] = useState<Record<string, boolean>>({}); + const [communication, setCommunication] = useState<"scan" | "block">("scan"); + const [communicationModalVisible, setCommunicationModalVisible] = + useState(false); + const [confirmModalVisible, setConfirmModalVisible] = useState(false); + const [pendingSelection, setPendingSelection] = useState< + "scan" | "block" | null + >(null); + + const { + devices, + selectedDevice, + selectDevice, + isLoading: isDeviceLoading, + } = useDevice(); + + useEffect(() => { + if (selectedDevice) { + getControlsData().then((d) => { + setData(d); + setState( + Object.fromEntries( + d.safetyControls.map((c) => [c.key, c.defaultValue]), + ), + ); + const blockDefault = d.safetyControls.find( + (c) => c.key === "block_strangers", + )?.defaultValue; + setCommunication(blockDefault ? "block" : "scan"); + }); + } + }, [selectedDevice]); + + const handleToggle = (key: string, value: boolean) => { + setState((prev) => ({ ...prev, [key]: value })); + updateSafetyControl(key, value); + }; + + const openCommunicationOptions = () => { + setCommunicationModalVisible(true); + }; + + const chooseCommunication = (choice: "scan" | "block") => { + if (choice === "scan") { + setCommunication("scan"); + handleToggle("block_strangers", false); + setCommunicationModalVisible(false); + } else { + // block chosen - show confirmation + setPendingSelection("block"); + setConfirmModalVisible(true); + setCommunicationModalVisible(false); + } + }; + + const confirmBlock = () => { + setCommunication("block"); + handleToggle("block_strangers", true); + setConfirmModalVisible(false); + setPendingSelection(null); + }; + + const cancelConfirm = () => { + setConfirmModalVisible(false); + setPendingSelection(null); + }; + + if (!selectedDevice && !isDeviceLoading) + return ( + <Screen> + <DeviceSelector + devices={devices} + selectedDevice={selectedDevice} + onSelectDevice={selectDevice} + isLoading={isDeviceLoading} + /> + <Muted>{t("noDeviceSelected")}</Muted> + </Screen> + ); + + if (!data) + return ( + <Screen> + <DeviceSelector + devices={devices} + selectedDevice={selectedDevice} + onSelectDevice={selectDevice} + isLoading={isDeviceLoading} + /> + </Screen> + ); + + return ( + <Screen> + <DeviceSelector + devices={devices} + selectedDevice={selectedDevice} + onSelectDevice={selectDevice} + isLoading={isDeviceLoading} + /> + + <Card> + <H2>{t("disableBuddy")}</H2> + <Divider /> + <Row + left={ + <View style={{ gap: 4 }}> + <H2>{t("disableBuddy")}</H2> + <Muted>{t("temporarilyDisablesBuddy")}</Muted> + </View> + } + right={ + <Switch + value={!!state["disable_buddy"]} + onValueChange={(value) => handleToggle("disable_buddy", value)} + trackColor={{ false: colors.outline, true: colors.primary }} + thumbColor={colors.onPrimary} + /> + } + /> + </Card> + + <Card> + <H2>{t("contentBlocking")}</H2> + <Divider /> + <Row + left={ + <View style={{ gap: 4 }}> + <H2>{t("adultSites")}</H2> + <Muted>{t("blockAdultWebsites")}</Muted> + </View> + } + right={ + <Switch + value={!!state["adult_sites"]} + onValueChange={(value) => handleToggle("adult_sites", value)} + trackColor={{ false: colors.outline, true: colors.primary }} + thumbColor={colors.onPrimary} + /> + } + /> + </Card> + + <Card> + <H2>{t("familyLink")}</H2> + <Divider /> + <Row + left={ + <View style={{ gap: 4 }}> + <H2>{t("antiCircumvention")}</H2> + <Muted>{t("preventFamilyLinkBypasses")}</Muted> + </View> + } + right={ + <Switch + value={!!state["family_link_anti_circumvention"]} + onValueChange={(value) => + handleToggle("family_link_anti_circumvention", value) + } + trackColor={{ false: colors.outline, true: colors.primary }} + thumbColor={colors.onPrimary} + /> + } + /> + </Card> + + <Card> + <H2>{t("communication")}</H2> + <Divider /> + <Row + left={<Muted>{t("communicationWithStrangers")}</Muted>} + right={ + <Pressable onPress={openCommunicationOptions}> + <Muted> + {communication === "block" + ? t("blockAllCommunications") + : t("scanCommunicationsWithAI")} + </Muted> + </Pressable> + } + /> + <Muted style={{ marginTop: 6 }}> + {t("chooseHowBuddyShouldHandleStrangers")} + </Muted> + + <SelectDialog + visible={communicationModalVisible} + title={t("communicationWithStrangersTitle")} + options={[ + { + label: t("scanCommunicationsWithAI"), + onPress: () => chooseCommunication("scan"), + }, + { + label: t("blockAllCommunications"), + onPress: () => chooseCommunication("block"), + destructive: true, + }, + ]} + onClose={() => setCommunicationModalVisible(false)} + /> + + <ConfirmDialog + visible={confirmModalVisible} + title={t("blockAllCommunications")} + message={t("blockAllCommunicationsConfirm")} + confirmLabel={t("okay")} + cancelLabel={t("cancel")} + onConfirm={confirmBlock} + onCancel={cancelConfirm} + destructive + /> + </Card> + + <Card> + <H2>{t("notifications")}</H2> + <Divider /> + <Row + left={<Muted>{t("dangerousMessages")}</Muted>} + right={ + <Switch + value={!!state["notify_dangerous_messages"]} + onValueChange={(value) => + handleToggle("notify_dangerous_messages", value) + } + trackColor={{ false: colors.outline, true: colors.primary }} + thumbColor={colors.onPrimary} + /> + } + /> + <View style={{ paddingVertical: 12 }}> + <Divider /> + </View> + <Row + left={<Muted>{t("newContactAdded")}</Muted>} + right={ + <Switch + value={!!state["notify_new_contact_added"]} + onValueChange={(value) => + handleToggle("notify_new_contact_added", value) + } + trackColor={{ false: colors.outline, true: colors.primary }} + thumbColor={colors.onPrimary} + /> + } + /> + </Card> + </Screen> + ); +} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx new file mode 100644 index 0000000..a29ec87 --- /dev/null +++ b/app/(tabs)/index.tsx @@ -0,0 +1,135 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; +import { getHomeData, HomeData } from "../../api"; +import { useDevice } from "../../lib/device"; +import { t } from "../../lib/locales"; +import { colors } from "../../lib/theme"; +import { + Card, + DeviceSelector, + Divider, + H2, + Muted, + Pill, + Row, + Screen, +} from "../../lib/ui"; + +export default function Index() { + const [data, setData] = useState<HomeData | null>(null); + const { + devices, + selectedDevice, + selectDevice, + isLoading: isDeviceLoading, + } = useDevice(); + + useEffect(() => { + if (selectedDevice) { + getHomeData(selectedDevice.id).then(setData); + } + }, [selectedDevice]); + + if (!selectedDevice && !isDeviceLoading) + return ( + <Screen> + <DeviceSelector + devices={devices} + selectedDevice={selectedDevice} + onSelectDevice={selectDevice} + isLoading={isDeviceLoading} + /> + <Muted>{t("noDeviceSelectedPleaseLinkFirst")}</Muted> + </Screen> + ); + + if (!data) + return ( + <Screen> + <DeviceSelector + devices={devices} + selectedDevice={selectedDevice} + onSelectDevice={selectDevice} + isLoading={isDeviceLoading} + /> + </Screen> + ); + + return ( + <Screen> + <DeviceSelector + devices={devices} + selectedDevice={selectedDevice} + onSelectDevice={selectDevice} + isLoading={isDeviceLoading} + /> + + <Card> + <Row + left={ + <Row + left={ + <H2> + {data.overallStatus === "all_clear" + ? t("allClear") + : t("attentionNeeded")} + </H2> + } + right={ + <Pill + label={ + data.overallStatus === "all_clear" + ? t("today") + : t("review") + } + tone={ + data.overallStatus === "all_clear" ? "good" : "attention" + } + /> + } + /> + } + /> + <Muted> + {data.overallStatus === "all_clear" + ? t("nothingNeedsAttention") + : t("fewItemsNeedReview")} + </Muted> + + <Divider /> + + <Row + left={ + <Row + left={ + <Row + left={ + <Ionicons + name={data.deviceOnline ? "wifi" : "wifi"} + size={18} + color={ + data.deviceOnline ? colors.onBackground : colors.primary + } + /> + } + right={ + <H2> + {data.deviceOnline + ? t("deviceIsOnline") + : t("deviceIsOffline")} + </H2> + } + /> + } + /> + } + /> + <Muted> + {data.deviceOnline + ? t("protectionActive") + : t("reconnectingAutomatically")} + </Muted> + </Card> + </Screen> + ); +} diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx new file mode 100644 index 0000000..b4493b4 --- /dev/null +++ b/app/(tabs)/settings.tsx @@ -0,0 +1,221 @@ +import { Ionicons } from "@expo/vector-icons"; +import { useEffect, useState } from "react"; +import { View } from "react-native"; +import { + Device, + getDevices, + getUserProfile, + renameDevice, + UserProfile, + verifyEmail, +} from "../../api"; +import { useAuth } from "../../lib/auth"; +import { t } from "../../lib/locales"; +import { colors } from "../../lib/theme"; +import { + ActionRow, + AlertDialog, + Button, + Card, + Divider, + H2, + Pill, + PromptDialog, + Row, + Screen, +} from "../../lib/ui"; + +export default function SettingsScreen() { + const { signOut } = useAuth(); + const [devices, setDevices] = useState<Device[]>([]); + const [profile, setProfile] = useState<UserProfile | null>(null); + const [alertVisible, setAlertVisible] = useState(false); + const [renameVisible, setRenameVisible] = useState(false); + const [selectedDevice, setSelectedDevice] = useState<Device | null>(null); + const [verifyEmailVisible, setVerifyEmailVisible] = useState(false); + const [verifyEmailResult, setVerifyEmailResult] = useState<{ + visible: boolean; + success: boolean; + message: string; + }>({ visible: false, success: false, message: "" }); + + const showNotWired = () => setAlertVisible(true); + + const refreshDevices = () => { + getDevices().then(setDevices); + }; + + const refreshProfile = () => { + getUserProfile().then(setProfile); + }; + + useEffect(() => { + refreshDevices(); + refreshProfile(); + }, []); + + const handleRename = async (newName: string) => { + if (selectedDevice && newName) { + const success = await renameDevice(selectedDevice.id, newName); + if (success) { + refreshDevices(); + } + } + setRenameVisible(false); + setSelectedDevice(null); + }; + + const handleVerifyEmail = async (code: string) => { + if (code) { + const result = await verifyEmail(code); + if (result.success) { + setVerifyEmailResult({ + visible: true, + success: true, + message: t("emailVerifiedSuccessfully"), + }); + refreshProfile(); + } else { + setVerifyEmailResult({ + visible: true, + success: false, + message: result.error || t("failedToVerifyEmail"), + }); + } + } + setVerifyEmailVisible(false); + }; + + return ( + <Screen> + <AlertDialog + visible={alertVisible} + title={t("notWiredYet")} + message={t("hookThisUpLater")} + onClose={() => setAlertVisible(false)} + /> + + <PromptDialog + visible={renameVisible} + title={t("renameDevice")} + message={`${t("enterNewNameFor")} ${selectedDevice?.name}:`} + onClose={() => setRenameVisible(false)} + onSubmit={handleRename} + initialValue={selectedDevice?.name} + /> + + <PromptDialog + visible={verifyEmailVisible} + title={t("verifyEmailTitle")} + message={t("enterVerificationCode")} + onClose={() => setVerifyEmailVisible(false)} + onSubmit={handleVerifyEmail} + initialValue="" + /> + + <AlertDialog + visible={verifyEmailResult.visible} + title={verifyEmailResult.success ? t("success") : t("error")} + message={verifyEmailResult.message} + onClose={() => + setVerifyEmailResult({ ...verifyEmailResult, visible: false }) + } + /> + + <Card> + <Row + left={ + <View + style={{ flexDirection: "row", alignItems: "center", gap: 10 }} + > + <Ionicons + name="phone-portrait" + size={18} + color={colors.onBackground} + /> + <H2>{t("devices")}</H2> + </View> + } + right={ + <Pill + label={`${devices.length} ${ + devices.length !== 1 ? t("devicesPlural") : t("device") + }`} + /> + } + /> + <Divider /> + {devices.map((device, index) => ( + <View key={device.id}> + <ActionRow + title={device.name} + subtitle={`${t("lastSeen")}: ${device.lastCheck}`} + onPress={() => { + setSelectedDevice(device); + setRenameVisible(true); + }} + right={ + <View + style={{ flexDirection: "row", alignItems: "center", gap: 8 }} + > + <Pill + label={ + device.status === "online" ? t("online") : t("offline") + } + tone={device.status === "online" ? "good" : "attention"} + /> + <Ionicons + name="chevron-forward" + size={18} + color={colors.onSurfaceVariant} + /> + </View> + } + /> + {index !== devices.length - 1 && <Divider />} + </View> + ))} + <Divider /> + </Card> + + <Card> + <H2>{t("transparency")}</H2> + <Divider /> + <ActionRow + title={t("privacyAndTerms")} + subtitle={t("legalAndPrivacyInfo")} + onPress={showNotWired} + right={ + <Ionicons + name="chevron-forward" + size={18} + color={colors.onSurfaceVariant} + /> + } + /> + </Card> + + <Card> + <H2>{t("account")}</H2> + <Divider /> + {profile && !profile.emailVerified && ( + <> + <ActionRow + title={t("verifyEmail")} + subtitle={t("verifyYourEmailAddress")} + onPress={() => setVerifyEmailVisible(true)} + right={ + <Ionicons + name="mail-outline" + size={18} + color={colors.onSurfaceVariant} + /> + } + /> + </> + )} + <Button title={t("signOut")} variant="secondary" onPress={signOut} /> + </Card> + </Screen> + ); +} |