← 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,
},
});