Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
274 lines
9.3 KiB
TypeScript
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,
|
|
},
|
|
});
|