summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-02-06 13:22:33 +0100
committerJustZvan <justzvan@justzvan.xyz>2026-02-06 13:22:33 +0100
commit7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 (patch)
tree57b7dd06ac9aa7053c671d916f7183e3b4fa9410 /app
feat: initial commit!
Diffstat (limited to 'app')
-rw-r--r--app/(auth)/_layout.tsx29
-rw-r--r--app/(auth)/signin.tsx165
-rw-r--r--app/(auth)/signup.tsx185
-rw-r--r--app/(auth)/welcome.tsx64
-rw-r--r--app/(tabs)/_layout.tsx188
-rw-r--r--app/(tabs)/alerts.tsx114
-rw-r--r--app/(tabs)/contact-detail.tsx193
-rw-r--r--app/(tabs)/controls.tsx270
-rw-r--r--app/(tabs)/index.tsx135
-rw-r--r--app/(tabs)/settings.tsx221
-rw-r--r--app/_layout.tsx15
11 files changed, 1579 insertions, 0 deletions
diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx
new file mode 100644
index 0000000..e18b629
--- /dev/null
+++ b/app/(auth)/_layout.tsx
@@ -0,0 +1,29 @@
+import { Redirect, Stack } from "expo-router";
+import { useAuth } from "../../lib/auth";
+import { colors } from "../../lib/theme";
+import { LoadingScreen } from "../../lib/ui";
+
+export default function AuthLayout() {
+ const { isLoading, isAuthenticated } = useAuth();
+
+ if (isLoading) {
+ return <LoadingScreen />;
+ }
+
+ if (isAuthenticated) {
+ return <Redirect href="/(tabs)" />;
+ }
+
+ return (
+ <Stack
+ screenOptions={{
+ headerShown: false,
+ contentStyle: { backgroundColor: colors.background },
+ }}
+ >
+ <Stack.Screen name="welcome" />
+ <Stack.Screen name="signin" />
+ <Stack.Screen name="signup" />
+ </Stack>
+ );
+}
diff --git a/app/(auth)/signin.tsx b/app/(auth)/signin.tsx
new file mode 100644
index 0000000..1eac63b
--- /dev/null
+++ b/app/(auth)/signin.tsx
@@ -0,0 +1,165 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
+import { useState } from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useAuth } from "../../lib/auth";
+import { t } from "../../lib/locales";
+import { colors } from "../../lib/theme";
+import { Button, H1, Muted, TextInput } from "../../lib/ui";
+
+export default function SignIn() {
+ const { signIn } = useAuth();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [emailError, setEmailError] = useState("");
+ const [passwordError, setPasswordError] = useState("");
+
+ const validateForm = () => {
+ let valid = true;
+ setEmailError("");
+ setPasswordError("");
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ setEmailError(t("invalidEmail"));
+ valid = false;
+ }
+
+ if (!password) {
+ setPasswordError(t("passwordRequired"));
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ const handleSignIn = async () => {
+ if (!validateForm()) return;
+
+ setLoading(true);
+ setError("");
+
+ try {
+ const result = await signIn(email, password);
+ if (!result.success) {
+ setError(result.reason || t("signInError"));
+ }
+ } catch (e) {
+ setError(t("signInError"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <SafeAreaView style={styles.container}>
+ <KeyboardAvoidingView
+ style={styles.keyboardView}
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
+ >
+ <ScrollView
+ contentContainerStyle={styles.scrollContent}
+ keyboardShouldPersistTaps="handled"
+ >
+ <TouchableOpacity
+ style={styles.backButton}
+ onPress={() => router.back()}
+ >
+ <Ionicons name="arrow-back" size={24} color={colors.onBackground} />
+ </TouchableOpacity>
+
+ <View style={styles.header}>
+ <H1>{t("signIn")}</H1>
+ </View>
+
+ <View style={styles.form}>
+ <TextInput
+ label={t("email")}
+ value={email}
+ onChangeText={setEmail}
+ placeholder={t("emailPlaceholder")}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ error={emailError}
+ />
+
+ <TextInput
+ label={t("password")}
+ value={password}
+ onChangeText={setPassword}
+ placeholder={t("passwordPlaceholder")}
+ secureTextEntry
+ error={passwordError}
+ />
+
+ {error ? <Text style={styles.error}>{error}</Text> : null}
+
+ <Button
+ title={t("signIn")}
+ onPress={handleSignIn}
+ loading={loading}
+ disabled={loading}
+ />
+ </View>
+
+ <View style={styles.footer}>
+ <Muted>{t("dontHaveAccount")}</Muted>
+ <TouchableOpacity onPress={() => router.replace("/(auth)/signup")}>
+ <Text style={styles.link}>{t("signUp")}</Text>
+ </TouchableOpacity>
+ </View>
+ </ScrollView>
+ </KeyboardAvoidingView>
+ </SafeAreaView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ keyboardView: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ padding: 24,
+ },
+ backButton: {
+ marginBottom: 16,
+ },
+ header: {
+ marginBottom: 32,
+ },
+ form: {
+ gap: 20,
+ },
+ error: {
+ color: colors.primary,
+ fontSize: 14,
+ textAlign: "center",
+ },
+ footer: {
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 32,
+ },
+ link: {
+ color: colors.primary,
+ fontWeight: "700",
+ },
+});
diff --git a/app/(auth)/signup.tsx b/app/(auth)/signup.tsx
new file mode 100644
index 0000000..127ea9f
--- /dev/null
+++ b/app/(auth)/signup.tsx
@@ -0,0 +1,185 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
+import { useState } from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useAuth } from "../../lib/auth";
+import { t } from "../../lib/locales";
+import { colors } from "../../lib/theme";
+import { Button, H1, Muted, TextInput } from "../../lib/ui";
+
+export default function SignUp() {
+ const { signUp } = useAuth();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [emailError, setEmailError] = useState("");
+ const [passwordError, setPasswordError] = useState("");
+ const [confirmPasswordError, setConfirmPasswordError] = useState("");
+
+ const validateForm = () => {
+ let valid = true;
+ setEmailError("");
+ setPasswordError("");
+ setConfirmPasswordError("");
+
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ setEmailError(t("invalidEmail"));
+ valid = false;
+ }
+
+ if (!password) {
+ setPasswordError(t("passwordRequired"));
+ valid = false;
+ } else if (password.length < 8) {
+ setPasswordError(t("passwordTooShort"));
+ valid = false;
+ }
+
+ if (password !== confirmPassword) {
+ setConfirmPasswordError(t("passwordsDoNotMatch"));
+ valid = false;
+ }
+
+ return valid;
+ };
+
+ const handleSignUp = async () => {
+ if (!validateForm()) return;
+
+ setLoading(true);
+ setError("");
+
+ try {
+ const result = await signUp(email, password);
+ if (!result.success) {
+ setError(result.reason || t("signUpError"));
+ }
+ } catch (e) {
+ setError(t("signUpError"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <SafeAreaView style={styles.container}>
+ <KeyboardAvoidingView
+ style={styles.keyboardView}
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
+ >
+ <ScrollView
+ contentContainerStyle={styles.scrollContent}
+ keyboardShouldPersistTaps="handled"
+ >
+ <TouchableOpacity
+ style={styles.backButton}
+ onPress={() => router.back()}
+ >
+ <Ionicons name="arrow-back" size={24} color={colors.onBackground} />
+ </TouchableOpacity>
+
+ <View style={styles.header}>
+ <H1>{t("createAccount")}</H1>
+ </View>
+
+ <View style={styles.form}>
+ <TextInput
+ label={t("email")}
+ value={email}
+ onChangeText={setEmail}
+ placeholder={t("emailPlaceholder")}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ error={emailError}
+ />
+
+ <TextInput
+ label={t("password")}
+ value={password}
+ onChangeText={setPassword}
+ placeholder={t("passwordPlaceholder")}
+ secureTextEntry
+ error={passwordError}
+ />
+
+ <TextInput
+ label={t("confirmPassword")}
+ value={confirmPassword}
+ onChangeText={setConfirmPassword}
+ placeholder={t("confirmPasswordPlaceholder")}
+ secureTextEntry
+ error={confirmPasswordError}
+ />
+
+ {error ? <Text style={styles.error}>{error}</Text> : null}
+
+ <Button
+ title={t("signUp")}
+ onPress={handleSignUp}
+ loading={loading}
+ disabled={loading}
+ />
+ </View>
+
+ <View style={styles.footer}>
+ <Muted>{t("alreadyHaveAccount")}</Muted>
+ <TouchableOpacity onPress={() => router.replace("/(auth)/signin")}>
+ <Text style={styles.link}>{t("signIn")}</Text>
+ </TouchableOpacity>
+ </View>
+ </ScrollView>
+ </KeyboardAvoidingView>
+ </SafeAreaView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ keyboardView: {
+ flex: 1,
+ },
+ scrollContent: {
+ flexGrow: 1,
+ padding: 24,
+ },
+ backButton: {
+ marginBottom: 16,
+ },
+ header: {
+ marginBottom: 32,
+ },
+ form: {
+ gap: 20,
+ },
+ error: {
+ color: colors.primary,
+ fontSize: 14,
+ textAlign: "center",
+ },
+ footer: {
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 32,
+ },
+ link: {
+ color: colors.primary,
+ fontWeight: "700",
+ },
+});
diff --git a/app/(auth)/welcome.tsx b/app/(auth)/welcome.tsx
new file mode 100644
index 0000000..9062c12
--- /dev/null
+++ b/app/(auth)/welcome.tsx
@@ -0,0 +1,64 @@
+import { router } from "expo-router";
+import { Image, StyleSheet, View } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { t } from "../../lib/locales";
+import { colors } from "../../lib/theme";
+import { Button, H1, Muted } from "../../lib/ui";
+
+import dogLogo from "../../assets/images/dog-logo.png";
+
+export default function Welcome() {
+ return (
+ <SafeAreaView style={styles.container}>
+ <View style={styles.content}>
+ <View style={styles.hero}>
+ <View style={styles.iconContainer}>
+ <Image source={dogLogo} style={{ width: 100, height: 100 }} />
+ </View>
+ <H1>{t("welcomeTitle")}</H1>
+ <Muted style={styles.subtitle}>{t("welcomeSubtitle")}</Muted>
+ </View>
+
+ <View style={styles.actions}>
+ <Button
+ title={t("getStarted")}
+ onPress={() => router.push("/(auth)/signup")}
+ />
+ <Button
+ title={t("signIn")}
+ variant="secondary"
+ onPress={() => router.push("/(auth)/signin")}
+ />
+ </View>
+ </View>
+ </SafeAreaView>
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.background,
+ },
+ content: {
+ flex: 1,
+ padding: 24,
+ justifyContent: "space-between",
+ },
+ hero: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 16,
+ },
+ iconContainer: {
+ marginBottom: 24,
+ },
+ subtitle: {
+ textAlign: "center",
+ paddingHorizontal: 20,
+ },
+ actions: {
+ gap: 12,
+ },
+});
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>
+ );
+}
diff --git a/app/_layout.tsx b/app/_layout.tsx
new file mode 100644
index 0000000..f3adf63
--- /dev/null
+++ b/app/_layout.tsx
@@ -0,0 +1,15 @@
+import { Slot } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { AuthProvider } from "../lib/auth";
+
+export default function RootLayout() {
+ return (
+ <SafeAreaProvider>
+ <StatusBar style="light" />
+ <AuthProvider>
+ <Slot />
+ </AuthProvider>
+ </SafeAreaProvider>
+ );
+}