← back to Govarbitrage

apps/mobile/app/(tabs)/settings.tsx

453 lines

/**
 * Settings screen — API base URL, Basic auth credentials, connection test.
 * Credentials stored in expo-secure-store (encrypted on device).
 */
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
  ActivityIndicator,
  Alert,
  KeyboardAvoidingView,
  Platform,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import * as AppleAuthentication from "expo-apple-authentication";
import { Colors, Radius, Spacing, Typography } from "../../constants/theme";
import {
  loadSettings,
  saveSettings,
  loadAppleAccount,
  type AppSettings,
  type AppleAccount,
} from "../../lib/settings";
import { testConnection, type ConnectionTestResult } from "../../lib/api";
import { signInWithApple, signOut, isAppleSignInAvailable } from "../../lib/auth";

export default function SettingsScreen() {
  const [settings, setSettings] = useState<AppSettings>({
    baseUrl: "https://auctions.agentabrams.com",
    username: "admin",
    password: "",
  });
  const [saved, setSaved] = useState(false);
  const [testing, setTesting] = useState(false);
  const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
  const [account, setAccount] = useState<AppleAccount | null>(null);
  const [appleAvailable, setAppleAvailable] = useState(false);
  const [signingIn, setSigningIn] = useState(false);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    loadSettings()
      .then(setSettings)
      .catch(() =>
        Alert.alert(
          "Settings unavailable",
          "Could not read saved settings from secure storage. Using defaults."
        )
      );
    loadAppleAccount().then(setAccount).catch(() => setAccount(null));
    isAppleSignInAvailable().then(setAppleAvailable).catch(() => setAppleAvailable(false));
    // Clear the "Saved!" timer if the user navigates away before it fires.
    return () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
    };
  }, []);

  const handleAppleSignIn = useCallback(async () => {
    // Persist the current base URL first so sign-in hits the right server.
    if (!/^https?:\/\//i.test(settings.baseUrl.trim())) {
      Alert.alert("Invalid URL", "Base URL must start with http:// or https://");
      return;
    }
    setSigningIn(true);
    try {
      await saveSettings(settings);
      const acct = await signInWithApple();
      setAccount(acct);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      // The user cancelling the native sheet is not an error worth alerting.
      if (!/canceled|cancelled|ERR_REQUEST_CANCELED/i.test(msg)) {
        Alert.alert("Sign in failed", msg);
      }
    } finally {
      setSigningIn(false);
    }
  }, [settings]);

  const handleSignOut = useCallback(async () => {
    try {
      await signOut();
      setAccount(null);
    } catch {
      Alert.alert("Sign out failed", "Could not clear the session. Try again.");
    }
  }, []);

  const handleSave = useCallback(async () => {
    if (!/^https?:\/\//i.test(settings.baseUrl.trim())) {
      Alert.alert("Invalid URL", "Base URL must start with http:// or https://");
      return;
    }
    try {
      await saveSettings(settings);
      setSaved(true);
      setTestResult(null);
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => setSaved(false), 2000);
    } catch {
      Alert.alert(
        "Save failed",
        "Could not save settings to secure storage. Check device storage and try again."
      );
    }
  }, [settings]);

  const handleTest = useCallback(async () => {
    if (!/^https?:\/\//i.test(settings.baseUrl.trim())) {
      Alert.alert("Invalid URL", "Base URL must start with http:// or https://");
      return;
    }
    setTesting(true);
    setTestResult(null);
    try {
      // Save first so the test uses current values
      await saveSettings(settings);
      const result = await testConnection();
      setTestResult(result);
    } catch {
      setTestResult({ ok: false, latencyMs: 0, error: "Could not save settings before testing." });
    } finally {
      setTesting(false);
    }
  }, [settings]);

  return (
    <SafeAreaView style={styles.root} edges={["bottom"]}>
      <KeyboardAvoidingView
        behavior={Platform.OS === "ios" ? "padding" : "height"}
        style={{ flex: 1 }}
      >
        <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">

          {/* Account — Sign in with Apple (optional) */}
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Account</Text>
            {account ? (
              <View style={styles.accountCard}>
                <Text style={styles.accountName}>
                  {account.name || "Signed in with Apple"}
                </Text>
                {account.email ? (
                  <Text style={styles.accountEmail}>{account.email}</Text>
                ) : null}
                <Pressable
                  style={[styles.btn, styles.btnSecondary, { marginTop: Spacing.sm }]}
                  onPress={handleSignOut}
                >
                  <Text style={styles.btnText}>Sign Out</Text>
                </Pressable>
              </View>
            ) : (
              <>
                <Text style={styles.sectionNote}>
                  Sign in to save preferences across devices. Browsing works without
                  an account.
                </Text>
                {appleAvailable ? (
                  signingIn ? (
                    <View style={[styles.btn, styles.appleButton]}>
                      <ActivityIndicator size="small" color="#000" />
                    </View>
                  ) : (
                    <AppleAuthentication.AppleAuthenticationButton
                      buttonType={
                        AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN
                      }
                      buttonStyle={
                        AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
                      }
                      cornerRadius={Radius.md}
                      style={styles.appleButton}
                      onPress={handleAppleSignIn}
                    />
                  )
                ) : (
                  <Text style={styles.fieldHint}>
                    Sign in with Apple is available on iOS devices.
                  </Text>
                )}
              </>
            )}
          </View>

          {/* API Config */}
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>API Configuration</Text>

            <View style={styles.field}>
              <Text style={styles.fieldLabel}>Base URL</Text>
              <TextInput
                style={styles.input}
                value={settings.baseUrl}
                onChangeText={(v) => setSettings((s) => ({ ...s, baseUrl: v }))}
                placeholder="https://auctions.agentabrams.com"
                placeholderTextColor={Colors.textMuted}
                autoCapitalize="none"
                autoCorrect={false}
                keyboardType="url"
              />
              <Text style={styles.fieldHint}>
                Points at the GovArbitrage API. Change only to use a different server.
              </Text>
            </View>
          </View>

          {/* Auth */}
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Authentication (optional)</Text>
            <Text style={styles.sectionNote}>
              Leave blank to use the default server — its catalog is public, so no
              login is needed. Only fill these in if you point the app at your own
              self-hosted server. When set, they are sent as an Authorization:
              Basic header and stored encrypted in device Secure Store.
            </Text>

            <View style={styles.field}>
              <Text style={styles.fieldLabel}>Username</Text>
              <TextInput
                style={styles.input}
                value={settings.username}
                onChangeText={(v) => setSettings((s) => ({ ...s, username: v }))}
                placeholder="admin"
                placeholderTextColor={Colors.textMuted}
                autoCapitalize="none"
                autoCorrect={false}
              />
            </View>

            <View style={styles.field}>
              <Text style={styles.fieldLabel}>Password</Text>
              <TextInput
                style={styles.input}
                value={settings.password}
                onChangeText={(v) => setSettings((s) => ({ ...s, password: v }))}
                placeholder="••••••••"
                placeholderTextColor={Colors.textMuted}
                secureTextEntry
                autoCapitalize="none"
                autoCorrect={false}
              />
            </View>
          </View>

          {/* Save button */}
          <Pressable
            style={[styles.btn, styles.btnPrimary, saved && styles.btnSuccess]}
            onPress={handleSave}
          >
            <Text style={styles.btnText}>{saved ? "Saved!" : "Save Settings"}</Text>
          </Pressable>

          {/* Connection test */}
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>Connection Test</Text>

            <Pressable
              style={[styles.btn, styles.btnSecondary]}
              onPress={handleTest}
              disabled={testing}
            >
              {testing ? (
                <ActivityIndicator size="small" color={Colors.textPrimary} />
              ) : (
                <Text style={styles.btnText}>Test Connection</Text>
              )}
            </Pressable>

            {testResult && (
              <View
                style={[
                  styles.testResult,
                  { borderColor: testResult.ok ? Colors.profit : Colors.loss },
                ]}
              >
                <View style={styles.testResultRow}>
                  <View
                    style={[
                      styles.statusDot,
                      { backgroundColor: testResult.ok ? Colors.profit : Colors.loss },
                    ]}
                  />
                  <Text style={styles.testResultStatus}>
                    {testResult.ok ? "Connected" : "Failed"}
                  </Text>
                  <Text style={styles.testResultLatency}>{testResult.latencyMs}ms</Text>
                </View>
                {testResult.tier && (
                  <Text style={styles.testResultDetail}>Tier: {testResult.tier}</Text>
                )}
                {testResult.error && (
                  <Text style={styles.testResultError}>{testResult.error}</Text>
                )}
              </View>
            )}
          </View>

          {/* App info */}
          <View style={styles.section}>
            <Text style={styles.sectionTitle}>About</Text>
            <Text style={styles.infoLine}>GovArbitrage Mobile</Text>
            <Text style={styles.infoLine}>com.abrams.govarbitrage</Text>
            <Text style={styles.infoLine}>Expo SDK 57 / React Native 0.86</Text>
          </View>

        </ScrollView>
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    backgroundColor: Colors.bg,
  },
  content: {
    padding: Spacing.lg,
    gap: Spacing.lg,
    paddingBottom: Spacing.xxl,
  },
  section: {
    gap: Spacing.md,
  },
  sectionTitle: {
    fontSize: Typography.sizes.xs,
    fontWeight: "700",
    color: Colors.textMuted,
    textTransform: "uppercase",
    letterSpacing: 1,
    marginBottom: Spacing.xs,
  },
  sectionNote: {
    fontSize: Typography.sizes.xs,
    color: Colors.textSecondary,
    lineHeight: 16,
  },
  field: {
    gap: Spacing.xs,
  },
  fieldLabel: {
    fontSize: Typography.sizes.sm,
    color: Colors.textSecondary,
    fontWeight: "600",
  },
  input: {
    backgroundColor: Colors.surface,
    borderWidth: 1,
    borderColor: Colors.border,
    borderRadius: Radius.md,
    paddingHorizontal: Spacing.md,
    paddingVertical: Spacing.sm + 2,
    fontSize: Typography.sizes.base,
    color: Colors.textPrimary,
    fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
  },
  fieldHint: {
    fontSize: Typography.sizes.xs,
    color: Colors.textMuted,
  },
  btn: {
    paddingVertical: Spacing.md,
    borderRadius: Radius.md,
    alignItems: "center",
    justifyContent: "center",
    minHeight: 44,
  },
  btnPrimary: {
    backgroundColor: Colors.accent,
  },
  btnSecondary: {
    backgroundColor: Colors.surfaceAlt,
    borderWidth: 1,
    borderColor: Colors.border,
  },
  btnSuccess: {
    backgroundColor: Colors.profit,
  },
  btnText: {
    fontSize: Typography.sizes.base,
    fontWeight: "700",
    color: Colors.textPrimary,
  },
  testResult: {
    backgroundColor: Colors.surface,
    borderWidth: 1,
    borderRadius: Radius.md,
    padding: Spacing.md,
    gap: Spacing.sm,
  },
  testResultRow: {
    flexDirection: "row",
    alignItems: "center",
    gap: Spacing.sm,
  },
  statusDot: {
    width: 10,
    height: 10,
    borderRadius: 5,
  },
  testResultStatus: {
    fontSize: Typography.sizes.base,
    fontWeight: "700",
    color: Colors.textPrimary,
    flex: 1,
  },
  testResultLatency: {
    fontSize: Typography.sizes.sm,
    color: Colors.textMuted,
    fontVariant: ["tabular-nums"],
  },
  testResultDetail: {
    fontSize: Typography.sizes.sm,
    color: Colors.textSecondary,
  },
  testResultError: {
    fontSize: Typography.sizes.sm,
    color: Colors.loss,
    lineHeight: 18,
  },
  infoLine: {
    fontSize: Typography.sizes.sm,
    color: Colors.textMuted,
    fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
  },
  appleButton: {
    height: 48,
    width: "100%",
  },
  accountCard: {
    backgroundColor: Colors.surface,
    borderWidth: 1,
    borderColor: Colors.border,
    borderRadius: Radius.md,
    padding: Spacing.md,
    gap: Spacing.xs,
  },
  accountName: {
    fontSize: Typography.sizes.base,
    fontWeight: "700",
    color: Colors.textPrimary,
  },
  accountEmail: {
    fontSize: Typography.sizes.sm,
    color: Colors.textMuted,
  },
});