summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-04-06 15:32:51 +0200
committerJustZvan <justzvan@justzvan.xyz>2026-04-06 15:32:51 +0200
commit3273e7a0fbbce82f4ce6cacbcdb7b6d6848f6c1b (patch)
tree7662ab528c950e5b3605d3f134dc89f399417c8d /app
parent7eb8ccae48b0cc18a9dcaa9c3626a02df8e6d919 (diff)
feat: gallery scanning preferences
Diffstat (limited to 'app')
-rw-r--r--app/(auth)/_layout.tsx1
-rw-r--r--app/(auth)/reset-password.tsx267
-rw-r--r--app/(auth)/signin.tsx40
-rw-r--r--app/(auth)/signup.tsx43
-rw-r--r--app/(tabs)/controls.tsx90
5 files changed, 421 insertions, 20 deletions
diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx
index e18b629..3d5e604 100644
--- a/app/(auth)/_layout.tsx
+++ b/app/(auth)/_layout.tsx
@@ -24,6 +24,7 @@ export default function AuthLayout() {
<Stack.Screen name="welcome" />
<Stack.Screen name="signin" />
<Stack.Screen name="signup" />
+ <Stack.Screen name="reset-password" />
</Stack>
);
}
diff --git a/app/(auth)/reset-password.tsx b/app/(auth)/reset-password.tsx
new file mode 100644
index 0000000..a5f8388
--- /dev/null
+++ b/app/(auth)/reset-password.tsx
@@ -0,0 +1,267 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router, useLocalSearchParams } from "expo-router";
+import { useEffect, useMemo, useState } from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { apiClient } from "../../api/client";
+import { t } from "../../lib/locales";
+import { colors } from "../../lib/theme";
+import { Button, H1, Muted, TextInput } from "../../lib/ui";
+
+type Mode = "request" | "confirm";
+
+export default function ResetPassword() {
+ const params = useLocalSearchParams<{ token?: string | string[] }>();
+ const tokenFromParams = useMemo(
+ () => (Array.isArray(params.token) ? params.token[0] : params.token) || "",
+ [params.token],
+ );
+ const [mode, setMode] = useState<Mode>(tokenFromParams ? "confirm" : "request");
+ const [email, setEmail] = useState("");
+ const [token, setToken] = useState(tokenFromParams);
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+ const [emailError, setEmailError] = useState("");
+ const [tokenError, setTokenError] = useState("");
+ const [passwordError, setPasswordError] = useState("");
+ const [confirmPasswordError, setConfirmPasswordError] = useState("");
+
+ useEffect(() => {
+ if (!tokenFromParams) return;
+ setToken(tokenFromParams);
+ setMode("confirm");
+ }, [tokenFromParams]);
+
+ const resetFieldErrors = () => {
+ setEmailError("");
+ setTokenError("");
+ setPasswordError("");
+ setConfirmPasswordError("");
+ };
+
+ const validateRequest = () => {
+ resetFieldErrors();
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ setEmailError(t("invalidEmail"));
+ return false;
+ }
+
+ return true;
+ };
+
+ const validateConfirm = () => {
+ resetFieldErrors();
+ let valid = true;
+
+ if (!token.trim()) {
+ setTokenError(t("resetTokenRequired"));
+ 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 handleRequestReset = async () => {
+ if (!validateRequest()) return;
+
+ setLoading(true);
+ setError("");
+ setSuccess("");
+
+ try {
+ const result = await apiClient.requestPasswordReset(email.trim());
+ if (!result.success) {
+ setError(result.reason || t("resetPasswordError"));
+ return;
+ }
+
+ setSuccess(t("passwordResetEmailSent"));
+ } catch {
+ setError(t("resetPasswordError"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmReset = async () => {
+ if (!validateConfirm()) return;
+
+ setLoading(true);
+ setError("");
+ setSuccess("");
+
+ try {
+ const result = await apiClient.confirmPasswordReset(token.trim(), password);
+ if (!result.success) {
+ setError(result.reason || t("resetPasswordError"));
+ return;
+ }
+
+ setSuccess(t("passwordResetSuccess"));
+ setTimeout(() => router.replace("/(auth)/signin"), 500);
+ } catch {
+ setError(t("resetPasswordError"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const switchMode = () => {
+ resetFieldErrors();
+ setError("");
+ setSuccess("");
+ setMode((current) => (current === "request" ? "confirm" : "request"));
+ };
+
+ 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("resetPassword")}</H1>
+ <Muted>{t("resetPasswordHelp")}</Muted>
+ </View>
+
+ <View style={styles.form}>
+ {mode === "request" ? (
+ <TextInput
+ label={t("email")}
+ value={email}
+ onChangeText={setEmail}
+ placeholder={t("emailPlaceholder")}
+ keyboardType="email-address"
+ autoCapitalize="none"
+ error={emailError}
+ />
+ ) : (
+ <>
+ <TextInput
+ label={t("resetPasswordToken")}
+ value={token}
+ onChangeText={setToken}
+ placeholder={t("resetPasswordTokenPlaceholder")}
+ error={tokenError}
+ />
+
+ <TextInput
+ label={t("newPassword")}
+ value={password}
+ onChangeText={setPassword}
+ placeholder={t("newPasswordPlaceholder")}
+ secureTextEntry
+ error={passwordError}
+ />
+
+ <TextInput
+ label={t("confirmNewPassword")}
+ value={confirmPassword}
+ onChangeText={setConfirmPassword}
+ placeholder={t("confirmPasswordPlaceholder")}
+ secureTextEntry
+ error={confirmPasswordError}
+ />
+ </>
+ )}
+
+ {error ? <Text style={styles.error}>{error}</Text> : null}
+ {success ? <Text style={styles.success}>{success}</Text> : null}
+
+ <Button
+ title={mode === "request" ? t("sendResetLink") : t("resetPassword")}
+ onPress={mode === "request" ? handleRequestReset : handleConfirmReset}
+ loading={loading}
+ disabled={loading}
+ />
+
+ <Button
+ title={
+ mode === "request"
+ ? t("switchToResetWithToken")
+ : t("switchToRequestReset")
+ }
+ variant="secondary"
+ onPress={switchMode}
+ disabled={loading}
+ />
+
+ <Button
+ title={t("backToSignIn")}
+ variant="text"
+ onPress={() => router.replace("/(auth)/signin")}
+ disabled={loading}
+ />
+ </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,
+ gap: 8,
+ },
+ form: {
+ gap: 20,
+ },
+ error: {
+ color: colors.primary,
+ fontSize: 14,
+ textAlign: "center",
+ },
+ success: {
+ color: colors.onBackground,
+ fontSize: 14,
+ textAlign: "center",
+ },
+});
diff --git a/app/(auth)/signin.tsx b/app/(auth)/signin.tsx
index 1eac63b..e2d0eda 100644
--- a/app/(auth)/signin.tsx
+++ b/app/(auth)/signin.tsx
@@ -12,12 +12,18 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "../../lib/auth";
+import { useGoogleAuth } from "../../lib/google-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 {
+ continueWithGoogle,
+ isLoading: googleLoading,
+ error: googleError,
+ } = useGoogleAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -55,7 +61,7 @@ export default function SignIn() {
if (!result.success) {
setError(result.reason || t("signInError"));
}
- } catch (e) {
+ } catch {
setError(t("signInError"));
} finally {
setLoading(false);
@@ -103,14 +109,36 @@ export default function SignIn() {
error={passwordError}
/>
+ <TouchableOpacity
+ onPress={() => router.push("/(auth)/reset-password")}
+ style={styles.forgotPasswordLinkContainer}
+ >
+ <Text style={styles.link}>{t("forgotPassword")}</Text>
+ </TouchableOpacity>
+
{error ? <Text style={styles.error}>{error}</Text> : null}
<Button
title={t("signIn")}
onPress={handleSignIn}
loading={loading}
- disabled={loading}
+ disabled={loading || googleLoading}
+ />
+
+ <Button
+ title={t("continueWithGoogle")}
+ variant="secondary"
+ onPress={continueWithGoogle}
+ loading={googleLoading}
+ disabled={loading || googleLoading}
/>
+ <Muted style={styles.googleHelp}>
+ {t("googleAuthUnifiedHint")}
+ </Muted>
+
+ {googleError ? (
+ <Text style={styles.error}>{googleError}</Text>
+ ) : null}
</View>
<View style={styles.footer}>
@@ -151,6 +179,10 @@ const styles = StyleSheet.create({
fontSize: 14,
textAlign: "center",
},
+ googleHelp: {
+ textAlign: "center",
+ paddingHorizontal: 8,
+ },
footer: {
flexDirection: "row",
justifyContent: "center",
@@ -162,4 +194,8 @@ const styles = StyleSheet.create({
color: colors.primary,
fontWeight: "700",
},
+ forgotPasswordLinkContainer: {
+ alignItems: "flex-end",
+ marginTop: -8,
+ },
});
diff --git a/app/(auth)/signup.tsx b/app/(auth)/signup.tsx
index 127ea9f..eded0b6 100644
--- a/app/(auth)/signup.tsx
+++ b/app/(auth)/signup.tsx
@@ -2,22 +2,28 @@ import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
- KeyboardAvoidingView,
- Platform,
- ScrollView,
- StyleSheet,
- Text,
- TouchableOpacity,
- View,
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useAuth } from "../../lib/auth";
+import { useGoogleAuth } from "../../lib/google-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 {
+ continueWithGoogle,
+ isLoading: googleLoading,
+ error: googleError,
+ } = useGoogleAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -66,7 +72,7 @@ export default function SignUp() {
if (!result.success) {
setError(result.reason || t("signUpError"));
}
- } catch (e) {
+ } catch {
setError(t("signUpError"));
} finally {
setLoading(false);
@@ -129,8 +135,23 @@ export default function SignUp() {
title={t("signUp")}
onPress={handleSignUp}
loading={loading}
- disabled={loading}
+ disabled={loading || googleLoading}
/>
+
+ <Button
+ title={t("continueWithGoogle")}
+ variant="secondary"
+ onPress={continueWithGoogle}
+ loading={googleLoading}
+ disabled={loading || googleLoading}
+ />
+ <Muted style={styles.googleHelp}>
+ {t("googleAuthUnifiedHint")}
+ </Muted>
+
+ {googleError ? (
+ <Text style={styles.error}>{googleError}</Text>
+ ) : null}
</View>
<View style={styles.footer}>
@@ -171,6 +192,10 @@ const styles = StyleSheet.create({
fontSize: 14,
textAlign: "center",
},
+ googleHelp: {
+ textAlign: "center",
+ paddingHorizontal: 8,
+ },
footer: {
flexDirection: "row",
justifyContent: "center",
diff --git a/app/(tabs)/controls.tsx b/app/(tabs)/controls.tsx
index ac44f1b..3e41b6a 100644
--- a/app/(tabs)/controls.tsx
+++ b/app/(tabs)/controls.tsx
@@ -1,6 +1,11 @@
import { useEffect, useState } from "react";
import { Pressable, Switch, View } from "react-native";
-import { ControlsData, getControlsData, updateSafetyControl } from "../../api";
+import {
+ ControlsData,
+ SafetyControlValue,
+ getControlsData,
+ updateSafetyControl,
+} from "../../api";
import { useDevice } from "../../lib/device";
import { t } from "../../lib/locales";
import { colors } from "../../lib/theme";
@@ -18,14 +23,16 @@ import {
export default function ControlsScreen() {
const [data, setData] = useState<ControlsData | null>(null);
- const [state, setState] = useState<Record<string, boolean>>({});
+ const [state, setState] = useState<Record<string, SafetyControlValue>>({});
const [communication, setCommunication] = useState<"scan" | "block">("scan");
const [communicationModalVisible, setCommunicationModalVisible] =
useState(false);
+ const [galleryScanningMode, setGalleryScanningMode] = useState<
+ "delete" | "notify" | "none"
+ >("none");
+ const [galleryScanningModalVisible, setGalleryScanningModalVisible] =
+ useState(false);
const [confirmModalVisible, setConfirmModalVisible] = useState(false);
- const [pendingSelection, setPendingSelection] = useState<
- "scan" | "block" | null
- >(null);
const {
devices,
@@ -34,6 +41,16 @@ export default function ControlsScreen() {
isLoading: isDeviceLoading,
} = useDevice();
+ const normalizeGalleryScanningMode = (
+ value: SafetyControlValue | undefined,
+ ): "delete" | "notify" | "none" => {
+ if (value === "delete" || value === "notify" || value === "none") {
+ return value;
+ }
+
+ return "none";
+ };
+
useEffect(() => {
if (selectedDevice) {
getControlsData().then((d) => {
@@ -47,15 +64,34 @@ export default function ControlsScreen() {
(c) => c.key === "block_strangers",
)?.defaultValue;
setCommunication(blockDefault ? "block" : "scan");
+ const galleryDefault = d.safetyControls.find(
+ (c) => c.key === "gallery_scanning_mode",
+ )?.defaultValue;
+ setGalleryScanningMode(normalizeGalleryScanningMode(galleryDefault));
});
}
}, [selectedDevice]);
- const handleToggle = (key: string, value: boolean) => {
+ const handleToggle = (key: string, value: SafetyControlValue) => {
setState((prev) => ({ ...prev, [key]: value }));
updateSafetyControl(key, value);
};
+ const handleGalleryScanningModeChange = (
+ value: "delete" | "notify" | "none",
+ ) => {
+ setGalleryScanningMode(value);
+ handleToggle("gallery_scanning_mode", value);
+ setGalleryScanningModalVisible(false);
+ };
+
+ const galleryScanningLabel =
+ galleryScanningMode === "delete"
+ ? t("galleryScanningDelete")
+ : galleryScanningMode === "notify"
+ ? t("galleryScanningNotify")
+ : t("galleryScanningNone");
+
const openCommunicationOptions = () => {
setCommunicationModalVisible(true);
};
@@ -67,7 +103,6 @@ export default function ControlsScreen() {
setCommunicationModalVisible(false);
} else {
// block chosen - show confirmation
- setPendingSelection("block");
setConfirmModalVisible(true);
setCommunicationModalVisible(false);
}
@@ -77,12 +112,10 @@ export default function ControlsScreen() {
setCommunication("block");
handleToggle("block_strangers", true);
setConfirmModalVisible(false);
- setPendingSelection(null);
};
const cancelConfirm = () => {
setConfirmModalVisible(false);
- setPendingSelection(null);
};
if (!selectedDevice && !isDeviceLoading)
@@ -265,6 +298,45 @@ export default function ControlsScreen() {
}
/>
</Card>
+
+ <Card>
+ <H2>{t("galleryScanning")}</H2>
+ <Divider />
+ <Row
+ left={
+ <View style={{ gap: 4 }}>
+ <H2>{t("galleryScanning")}</H2>
+ <Muted>{t("scanGalleryForInappropriateImages")}</Muted>
+ </View>
+ }
+ right={
+ <Pressable onPress={() => setGalleryScanningModalVisible(true)}>
+ <Muted>{galleryScanningLabel}</Muted>
+ </Pressable>
+ }
+ />
+
+ <SelectDialog
+ visible={galleryScanningModalVisible}
+ title={t("galleryScanningTitle")}
+ options={[
+ {
+ label: t("galleryScanningNone"),
+ onPress: () => handleGalleryScanningModeChange("none"),
+ },
+ {
+ label: t("galleryScanningNotify"),
+ onPress: () => handleGalleryScanningModeChange("notify"),
+ },
+ {
+ label: t("galleryScanningDelete"),
+ onPress: () => handleGalleryScanningModeChange("delete"),
+ destructive: true,
+ },
+ ]}
+ onClose={() => setGalleryScanningModalVisible(false)}
+ />
+ </Card>
</Screen>
);
}