From 7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 Mon Sep 17 00:00:00 2001 From: JustZvan Date: Fri, 6 Feb 2026 13:22:33 +0100 Subject: feat: initial commit! --- .gitignore | 43 + .vscode/extensions.json | 1 + .vscode/settings.json | 7 + README.md | 50 + api/activity.ts | 23 + api/alerts.ts | 18 + api/client.ts | 147 + api/controls.ts | 101 + api/devices.ts | 38 + api/home.ts | 31 + api/index.ts | 8 + api/settings.ts | 42 + api/types.ts | 53 + app.json | 69 + app/(auth)/_layout.tsx | 29 + app/(auth)/signin.tsx | 165 + app/(auth)/signup.tsx | 185 + app/(auth)/welcome.tsx | 64 + app/(tabs)/_layout.tsx | 188 + app/(tabs)/alerts.tsx | 114 + app/(tabs)/contact-detail.tsx | 193 + app/(tabs)/controls.tsx | 270 + app/(tabs)/index.tsx | 135 + app/(tabs)/settings.tsx | 221 + app/_layout.tsx | 15 + assets/images/dog-logo.png | Bin 0 -> 1505 bytes assets/images/favicon.png | Bin 0 -> 19922 bytes assets/images/ic_launcher_background.png | Bin 0 -> 1202 bytes assets/images/ic_launcher_foreground.png | Bin 0 -> 4642 bytes assets/images/ic_launcher_monochrome.png | Bin 0 -> 4642 bytes assets/images/icon.png | Bin 0 -> 19922 bytes assets/images/splash-icon.png | Bin 0 -> 12642 bytes declarations.d.ts | 6 + eas.json | 21 + eslint.config.js | 10 + google-services.json | 48 + lib/auth.tsx | 81 + lib/device.tsx | 90 + lib/locales.ts | 247 + lib/notifications.ts | 206 + lib/theme.ts | 24 + lib/ui.tsx | 846 +++ metro.config.js | 5 + package.json | 53 + pnpm-lock.yaml | 9205 ++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + tsconfig.json | 19 + 47 files changed, 13072 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 api/activity.ts create mode 100644 api/alerts.ts create mode 100644 api/client.ts create mode 100644 api/controls.ts create mode 100644 api/devices.ts create mode 100644 api/home.ts create mode 100644 api/index.ts create mode 100644 api/settings.ts create mode 100644 api/types.ts create mode 100644 app.json create mode 100644 app/(auth)/_layout.tsx create mode 100644 app/(auth)/signin.tsx create mode 100644 app/(auth)/signup.tsx create mode 100644 app/(auth)/welcome.tsx create mode 100644 app/(tabs)/_layout.tsx create mode 100644 app/(tabs)/alerts.tsx create mode 100644 app/(tabs)/contact-detail.tsx create mode 100644 app/(tabs)/controls.tsx create mode 100644 app/(tabs)/index.tsx create mode 100644 app/(tabs)/settings.tsx create mode 100644 app/_layout.tsx create mode 100644 assets/images/dog-logo.png create mode 100644 assets/images/favicon.png create mode 100644 assets/images/ic_launcher_background.png create mode 100644 assets/images/ic_launcher_foreground.png create mode 100644 assets/images/ic_launcher_monochrome.png create mode 100644 assets/images/icon.png create mode 100644 assets/images/splash-icon.png create mode 100644 declarations.d.ts create mode 100644 eas.json create mode 100644 eslint.config.js create mode 100644 google-services.json create mode 100644 lib/auth.tsx create mode 100644 lib/device.tsx create mode 100644 lib/locales.ts create mode 100644 lib/notifications.ts create mode 100644 lib/theme.ts create mode 100644 lib/ui.tsx create mode 100644 metro.config.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8c6c2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +app-example + +# generated native folders +/ios +/android diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..48dd63f --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Welcome to your Expo app 👋 + +This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). + +## Get started + +1. Install dependencies + + ```bash + npm install + ``` + +2. Start the app + + ```bash + npx expo start + ``` + +In the output, you'll find options to open the app in a + +- [development build](https://docs.expo.dev/develop/development-builds/introduction/) +- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) +- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) +- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo + +You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). + +## Get a fresh project + +When you're ready, run: + +```bash +npm run reset-project +``` + +This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. + +## Learn more + +To learn more about developing your project with Expo, look at the following resources: + +- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). +- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. + +## Join the community + +Join our community of developers creating universal apps. + +- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. +- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. diff --git a/api/activity.ts b/api/activity.ts new file mode 100644 index 0000000..a36c666 --- /dev/null +++ b/api/activity.ts @@ -0,0 +1,23 @@ +import { apiClient } from "./client"; +import { ActivityData } from "./types"; + +export async function getActivityData(): Promise { + try { + const response = await apiClient.get<{ + success: boolean; + period: string; + metrics: ActivityData["metrics"]; + }>("/parent/activity"); + + return { + period: response.period, + metrics: response.metrics, + }; + } catch (e) { + console.error("Failed to fetch activity data", e); + return { + period: "Last 7 days", + metrics: [], + }; + } +} diff --git a/api/alerts.ts b/api/alerts.ts new file mode 100644 index 0000000..8fbcb07 --- /dev/null +++ b/api/alerts.ts @@ -0,0 +1,18 @@ +import { apiClient } from "./client"; +import { Alert } from "./types"; + +export async function getAlerts(): Promise { + try { + console.log("getAlerts: Making API call to /parent/alerts"); + const response = await apiClient.get<{ + success: boolean; + alerts: Alert[]; + }>("/parent/alerts"); + + console.log("getAlerts: API response:", response); + return response.alerts; + } catch (e) { + console.error("Failed to fetch alerts", e); + return []; + } +} diff --git a/api/client.ts b/api/client.ts new file mode 100644 index 0000000..703b1f0 --- /dev/null +++ b/api/client.ts @@ -0,0 +1,147 @@ +import Constants from "expo-constants"; +import * as SecureStore from "expo-secure-store"; + +function getApiBaseUrl() { + const envUrl = (process as any)?.env?.API_BASE_URL; + const extraUrl = + (Constants.manifest as any)?.extra?.API_BASE_URL || + (Constants.expoConfig as any)?.extra?.API_BASE_URL; + if (envUrl) return envUrl; + if (extraUrl) return extraUrl; + + throw new Error("API_BASE_URL is not defined in environment or Expo config"); +} + +const API_BASE_URL = getApiBaseUrl(); + +const AUTH_TOKEN_KEY = "buddy_auth_token"; +const SELECTED_DEVICE_KEY = "buddy_selected_device"; + +class ApiClient { + private token: string | null = null; + private selectedDeviceId: string | null = null; + + async initialize() { + try { + this.token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY); + this.selectedDeviceId = + await SecureStore.getItemAsync(SELECTED_DEVICE_KEY); + } catch (e) { + console.error("Failed to load auth token", e); + } + } + + async setToken(token: string) { + this.token = token; + await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token); + } + + async clearToken() { + this.token = null; + await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY); + } + + async setSelectedDevice(deviceId: string) { + this.selectedDeviceId = deviceId; + await SecureStore.setItemAsync(SELECTED_DEVICE_KEY, deviceId); + } + + getSelectedDeviceId(): string | null { + return this.selectedDeviceId; + } + + isAuthenticated(): boolean { + return this.token !== null; + } + + private async request( + method: "GET" | "POST" | "PUT" | "DELETE", + endpoint: string, + body?: any, + ): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (this.token) { + headers["Authorization"] = `Bearer ${this.token}`; + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ reason: "Unknown error" })); + throw new Error(error.reason || `HTTP ${response.status}`); + } + + return response.json(); + } + + async get(endpoint: string): Promise { + return this.request("GET", endpoint); + } + + async post(endpoint: string, body?: any): Promise { + return this.request("POST", endpoint, body); + } + + async delete(endpoint: string, body?: any): Promise { + return this.request("DELETE", endpoint, body); + } + + // Auth endpoints + async signIn( + email: string, + password: string, + ): Promise<{ success: boolean; token?: string; reason?: string }> { + const result = await this.post<{ + success: boolean; + token: string; + reason: string; + }>("/signin", { + email, + password, + }); + + if (result.success && result.token) { + await this.setToken(result.token); + } + + return result; + } + + async signUp( + email: string, + password: string, + ): Promise<{ success: boolean; token?: string; reason?: string }> { + const result = await this.post<{ + success: boolean; + token: string; + reason: string; + }>("/signup", { + email, + password, + }); + + if (result.success && result.token) { + await this.setToken(result.token); + } + + return result; + } + + async signOut() { + await this.clearToken(); + } +} + +export const apiClient = new ApiClient(); + +// Initialize on import +apiClient.initialize(); diff --git a/api/controls.ts b/api/controls.ts new file mode 100644 index 0000000..53db7a1 --- /dev/null +++ b/api/controls.ts @@ -0,0 +1,101 @@ +import { apiClient } from "./client"; +import { ControlsData, SafetyControl } from "./types"; + +export async function getControlsData(): Promise { + const deviceId = apiClient.getSelectedDeviceId(); + + if (!deviceId) { + // Return default controls if no device selected + return { + safetyControls: getDefaultControls(), + }; + } + + try { + const response = await apiClient.get<{ + success: boolean; + safetyControls: SafetyControl[]; + }>(`/parent/controls/${deviceId}`); + + return { + safetyControls: response.safetyControls, + }; + } catch (e) { + console.error("Failed to fetch controls data", e); + return { + safetyControls: getDefaultControls(), + }; + } +} + +export async function updateSafetyControl( + key: string, + value: boolean +): Promise { + const deviceId = apiClient.getSelectedDeviceId(); + + if (!deviceId) { + console.warn("No device selected, cannot update control"); + return; + } + + try { + await apiClient.post(`/parent/controls/${deviceId}`, { key, value }); + } catch (e) { + console.error(`Failed to update ${key}`, e); + throw e; + } +} + +function getDefaultControls(): SafetyControl[] { + return [ + { + key: "disable_buddy", + title: "Disable Buddy", + description: "Temporarily disable Buddy", + defaultValue: false, + }, + { + key: "adult_sites", + title: "Adult sites", + description: "Block adult websites.", + defaultValue: true, + }, + { + key: "family_link_anti_circumvention", + title: "Anti-Circumvention", + description: "Prevent disabling of Family Link protections.", + defaultValue: false, + }, + { + key: "filtering", + title: "Content filtering", + description: "Block unsafe or adult content.", + defaultValue: true, + }, + { + key: "new_people", + title: "New contact alerts", + description: "Get notified when your child chats with someone new.", + defaultValue: true, + }, + { + key: "block_strangers", + title: "Block communications with strangers", + description: "Block or scan communications with strangers.", + defaultValue: false, + }, + { + key: "notify_dangerous_messages", + title: "Dangerous messages notifications", + description: "Notify when messages are potentially dangerous.", + defaultValue: true, + }, + { + key: "notify_new_contact_added", + title: "New contact added notifications", + description: "Notify when a new contact is added.", + defaultValue: true, + }, + ]; +} diff --git a/api/devices.ts b/api/devices.ts new file mode 100644 index 0000000..e8bbb92 --- /dev/null +++ b/api/devices.ts @@ -0,0 +1,38 @@ +import { apiClient } from "./client"; +import { Device } from "./types"; + +export async function getDevices(): Promise { + try { + const response = await apiClient.get<{ + success: boolean; + devices: Device[]; + }>("/parent/devices"); + + return response.devices; + } catch (e) { + console.error("Failed to fetch devices", e); + return []; + } +} + +interface RenameDeviceResponse { + success: boolean; +} + +export async function renameDevice( + deviceId: string, + name: string +): Promise { + try { + const response: RenameDeviceResponse = await apiClient.post( + `/parent/device/${deviceId}/rename`, + { + name, + } + ); + return response.success; + } catch (e) { + console.error("Failed to rename device", e); + return false; + } +} diff --git a/api/home.ts b/api/home.ts new file mode 100644 index 0000000..89e1cca --- /dev/null +++ b/api/home.ts @@ -0,0 +1,31 @@ +import { apiClient } from "./client"; +import { HomeData } from "./types"; + +export async function getHomeData(deviceId?: string): Promise { + try { + const endpoint = deviceId ? `/parent/home/${deviceId}` : "/parent/home"; + const response = await apiClient.get<{ + success: boolean; + overallStatus: HomeData["overallStatus"]; + deviceOnline: boolean; + alertStats: HomeData["alertStats"]; + }>(endpoint); + + return { + overallStatus: response.overallStatus, + deviceOnline: response.deviceOnline, + alertStats: response.alertStats, + }; + } catch (e) { + console.error("Failed to fetch home data", e); + // Return default data on error + return { + overallStatus: "all_clear", + deviceOnline: false, + alertStats: { + last24Hours: 0, + thisWeekReviewed: 0, + }, + }; + } +} diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..05a61aa --- /dev/null +++ b/api/index.ts @@ -0,0 +1,8 @@ +export * from "./activity"; +export * from "./alerts"; +export * from "./client"; +export * from "./controls"; +export * from "./devices"; +export * from "./home"; +export * from "./settings"; +export * from "./types"; diff --git a/api/settings.ts b/api/settings.ts new file mode 100644 index 0000000..2823afa --- /dev/null +++ b/api/settings.ts @@ -0,0 +1,42 @@ +import { apiClient } from "./client"; + +interface VerifyEmailResponse { + success: boolean; + reason?: string; +} + +export async function verifyEmail( + code: string, +): Promise<{ success: boolean; error?: string }> { + try { + const response: VerifyEmailResponse = await apiClient.post( + "/parent/verifyemail", + { code }, + ); + return { success: response.success }; + } catch (e) { + console.error("Failed to verify email", e); + return { + success: false, + error: e instanceof Error ? e.message : "Failed to verify email", + }; + } +} + +export interface UserProfile { + email: string; + emailVerified: boolean; +} + +export async function getUserProfile(): Promise { + try { + const response = await apiClient.get<{ + success: boolean; + profile: UserProfile; + }>("/parent/profile"); + return response.profile; + } catch (e) { + console.error("Failed to fetch user profile", e); + return null; + } +} diff --git a/api/types.ts b/api/types.ts new file mode 100644 index 0000000..7338d4b --- /dev/null +++ b/api/types.ts @@ -0,0 +1,53 @@ +export type OverallStatus = "all_clear" | "attention"; + +export type AlertSeverity = "gentle" | "needs_attention"; + +export type Alert = { + id: string; + title: string; + timeLabel: string; + whatHappened: string; + whyItMatters: string; + suggestedAction: string; + severity: AlertSeverity; +}; + +export type HomeData = { + overallStatus: OverallStatus; + deviceOnline: boolean; + alertStats: { + last24Hours: number; + thisWeekReviewed: number; + }; +}; + +export type ActivityMetric = { + id: string; + icon: "chatbubbles" | "people" | "time"; + title: string; + description: string; + level: "Normal" | "Low" | "High" | "Better" | "Worse"; +}; + +export type ActivityData = { + period: string; + metrics: ActivityMetric[]; +}; + +export type Device = { + id: string; + name: string; + status: "online" | "offline"; + lastCheck: string; +}; + +export type SafetyControl = { + key: string; + title: string; + description: string; + defaultValue: boolean; +}; + +export type ControlsData = { + safetyControls: SafetyControl[]; +}; diff --git a/app.json b/app.json new file mode 100644 index 0000000..3e60a1f --- /dev/null +++ b/app.json @@ -0,0 +1,69 @@ +{ + "expo": { + "name": "Buddy for Parents", + "slug": "buddy-for-parents", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "buddy-for-parents", + "userInterfaceStyle": "dark", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "sh.lajo.buddyforparents", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } + }, + "extra": { + "API_BASE_URL": "https://buddy-dev.justzvan.click", + "router": {}, + "eas": { + "projectId": "69fef292-00c4-4045-93ec-3bd05d7e8dac" + } + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#020202", + "foregroundImage": "./assets/images/ic_launcher_foreground.png", + "backgroundImage": "./assets/images/ic_launcher_background.png", + "monochromeImage": "./assets/images/ic_launcher_monochrome.png" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false, + "package": "sh.lajo.buddyforparents", + "googleServicesFile": "google-services.json" + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#020202", + "dark": { + "backgroundColor": "#020202" + } + } + ], + "expo-localization", + [ + "expo-notifications", + { + "icon": "./assets/images/ic_launcher_foreground.png", + "color": "#020202" + } + ] + ], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + } + } +} 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 ; + } + + if (isAuthenticated) { + return ; + } + + return ( + + + + + + ); +} 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 ( + + + + router.back()} + > + + + + +

{t("signIn")}

+
+ + + + + + + {error ? {error} : null} + +