Files
timmy-tower/artifacts/mobile/app/settings.tsx
2026-03-24 02:36:05 +00:00

274 lines
9.3 KiB
TypeScript

import { Stack } from "expo-router";
import {
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { useState, useEffect } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons";
import { useTimmy } from "@/context/TimmyContext";
import { useNostr, truncateNpub } from "@/context/NostrContext";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { NostrConnectModal } from "@/components/NostrConnectModal";
import { Colors } from "@/constants/colors";
const NOTIF_JOB_KEY = "settings.notifications_job_completion";
const NOTIF_BALANCE_KEY = "settings.notifications_low_balance";
export default function SettingsScreen() {
const C = Colors.dark;
const { apiBaseUrl, setApiBaseUrl, isConnected } = useTimmy();
const { npub, nostrConnected, signerType, disconnect: disconnectNostr } = useNostr();
const [serverUrl, setServerUrl] = useState(apiBaseUrl);
const [jobCompletionNotifications, setJobCompletionNotifications] = useState(false);
const [lowBalanceWarning, setLowBalanceWarning] = useState(false);
const [nostrModalVisible, setNostrModalVisible] = useState(false);
// Sync local serverUrl with context value (e.g. on first load from AsyncStorage)
useEffect(() => {
setServerUrl(apiBaseUrl);
}, [apiBaseUrl]);
useEffect(() => {
AsyncStorage.multiGet([NOTIF_JOB_KEY, NOTIF_BALANCE_KEY])
.then(([[, job], [, balance]]) => {
if (job !== null) setJobCompletionNotifications(JSON.parse(job));
if (balance !== null) setLowBalanceWarning(JSON.parse(balance));
})
.catch(() => {});
}, []);
const handleServerUrlBlur = () => {
if (serverUrl !== apiBaseUrl) {
setApiBaseUrl(serverUrl);
}
};
const toggleJobCompletion = async () => {
const next = !jobCompletionNotifications;
setJobCompletionNotifications(next);
await AsyncStorage.setItem(NOTIF_JOB_KEY, JSON.stringify(next));
};
const toggleLowBalance = async () => {
const next = !lowBalanceWarning;
setLowBalanceWarning(next);
await AsyncStorage.setItem(NOTIF_BALANCE_KEY, JSON.stringify(next));
};
const handleDisconnectNostr = async () => {
await disconnectNostr();
};
const appVersion = Constants.expoConfig?.version ?? "N/A";
const buildCommitHash = Constants.expoConfig?.extra?.["gitCommitHash"] ?? "N/A";
const giteaRepoUrl = "http://143.198.27.163:3000/replit/timmy-tower";
return (
<View style={[styles.container, { backgroundColor: C.background }]}>
<Stack.Screen
options={{
title: "Settings",
headerShown: true,
headerStyle: { backgroundColor: C.surface },
headerTintColor: C.text,
}}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* ── Connection ──────────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>Connection</Text>
<View style={styles.settingItem}>
<Text style={[styles.settingLabel, { color: C.text }]}>Server URL</Text>
<View style={styles.serverUrlContainer}>
<TextInput
style={[styles.input, { color: C.text, backgroundColor: C.field, borderColor: C.border }]}
value={serverUrl}
onChangeText={setServerUrl}
onBlur={handleServerUrlBlur}
placeholder="Enter server URL"
placeholderTextColor={C.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<ConnectionBadge isConnected={isConnected} />
</View>
</View>
{/* ── Notifications ────────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>Notifications</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Job Completion</Text>
<Switch
trackColor={{ false: C.surface, true: C.accentGlow }}
thumbColor={Platform.OS === "android" ? C.text : ""}
ios_backgroundColor={C.field}
onValueChange={toggleJobCompletion}
value={jobCompletionNotifications}
/>
</View>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Low Balance Warning</Text>
<Switch
trackColor={{ false: C.surface, true: C.accentGlow }}
thumbColor={Platform.OS === "android" ? C.text : ""}
ios_backgroundColor={C.field}
onValueChange={toggleLowBalance}
value={lowBalanceWarning}
/>
</View>
{/* ── Nostr Identity ───────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>Identity</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Nostr Public Key</Text>
<Text style={[styles.settingValue, { color: C.textMuted }]}>
{npub ? truncateNpub(npub) : "Not connected"}
</Text>
</View>
{nostrConnected && signerType && (
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Signer</Text>
<Text style={[styles.settingValue, { color: C.textSecondary }]}>
{signerType === "amber" ? "Amber (NIP-55)" : "nsec key"}
</Text>
</View>
)}
<View style={styles.buttonContainer}>
{!nostrConnected ? (
<Pressable
onPress={() => setNostrModalVisible(true)}
style={({ pressed }) => [
styles.button,
{ backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 },
]}
>
<Text style={[styles.buttonText, { color: C.textInverted }]}>
Connect Nostr Identity
</Text>
</Pressable>
) : (
<Pressable
onPress={handleDisconnectNostr}
style={({ pressed }) => [
styles.button,
{ backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 },
]}
>
<Text style={[styles.buttonText, { color: C.textInverted }]}>
Disconnect Nostr
</Text>
</Pressable>
)}
</View>
{/* ── About ───────────────────────────────────────────────────── */}
<Text style={[styles.sectionHeader, { color: C.text }]}>About</Text>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>App Version</Text>
<Text style={[styles.settingValue, { color: C.text }]}>{appVersion}</Text>
</View>
<View style={[styles.settingItem, { borderBottomColor: C.border }]}>
<Text style={[styles.settingLabel, { color: C.text }]}>Build Commit</Text>
<Text style={[styles.settingValue, { color: C.text }]}>{buildCommitHash}</Text>
</View>
<Pressable
onPress={() => Linking.openURL(giteaRepoUrl)}
style={({ pressed }) => [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}
>
<Ionicons name="link" size={16} color={C.text} />
<Text style={[styles.linkButtonText, { color: C.link }]}>
View project on Gitea
</Text>
</Pressable>
</ScrollView>
<NostrConnectModal
visible={nostrModalVisible}
onClose={() => setNostrModalVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
padding: 20,
paddingBottom: 40,
},
sectionHeader: {
fontSize: 18,
fontWeight: "bold",
marginTop: 20,
marginBottom: 10,
},
settingItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
borderBottomWidth: 0.5,
},
settingLabel: {
fontSize: 16,
flex: 1,
},
settingValue: {
fontSize: 14,
flexShrink: 1,
textAlign: "right",
marginLeft: 8,
},
serverUrlContainer: {
flexDirection: "row",
alignItems: "center",
flex: 2,
},
input: {
flex: 1,
borderWidth: 1,
borderRadius: 8,
padding: 8,
fontSize: 14,
marginRight: 10,
},
buttonContainer: {
marginTop: 20,
alignItems: "flex-start",
},
button: {
paddingVertical: 10,
paddingHorizontal: 15,
borderRadius: 8,
},
buttonText: {
fontSize: 16,
fontWeight: "bold",
},
linkButton: {
flexDirection: "row",
alignItems: "center",
marginTop: 15,
paddingVertical: 8,
},
linkButtonText: {
marginLeft: 5,
fontSize: 16,
},
});