diff --git a/artifacts/mobile/app.json b/artifacts/mobile/app.json index 0653863..e27cbea 100644 --- a/artifacts/mobile/app.json +++ b/artifacts/mobile/app.json @@ -37,7 +37,8 @@ "expo-web-browser" ], "extra": { - "apiDomain": "${EXPO_PUBLIC_DOMAIN}" + "apiDomain": "${EXPO_PUBLIC_DOMAIN}", + "gitCommitHash": "${EXPO_PUBLIC_GIT_SHA}" }, "experiments": { "typedRoutes": true, diff --git a/artifacts/mobile/app/(tabs)/_layout.tsx b/artifacts/mobile/app/(tabs)/_layout.tsx index 0b6061f..c744df3 100644 --- a/artifacts/mobile/app/(tabs)/_layout.tsx +++ b/artifacts/mobile/app/(tabs)/_layout.tsx @@ -1,11 +1,11 @@ import { BlurView } from "expo-blur"; import { isLiquidGlassAvailable } from "expo-glass-effect"; -import { Tabs } from "expo-router"; +import { Link, Tabs, router } from "expo-router"; import { Icon, Label, NativeTabs } from "expo-router/unstable-native-tabs"; import { SymbolView } from "expo-symbols"; -import { Feather, MaterialCommunityIcons } from "@expo/vector-icons"; +import { Feather, MaterialCommunityIcons, Ionicons } from "@expo/vector-icons"; import React from "react"; -import { Platform, StyleSheet, View, useColorScheme } from "react-native"; +import { Platform, Pressable, StyleSheet, View, useColorScheme } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Colors } from "@/constants/colors"; @@ -13,16 +13,16 @@ import { Colors } from "@/constants/colors"; function NativeTabLayout() { return ( - - + + - - + + - - + + @@ -39,8 +39,7 @@ function ClassicTabLayout() { ) : isWeb ? ( @@ -61,52 +60,53 @@ function ClassicTabLayout() { /> ) : ( - ), - }} + ),\ + }}\ > (\n \n ({ opacity: pressed ? 0.5 : 1 })}> + \n \n \n ), tabBarIcon: ({ color, size }) => isIOS ? ( - + ) : ( - - ), - }} + + ),\ + }}\ /> isIOS ? ( - + ) : ( - - ), - }} + + ),\ + }}\ /> isIOS ? ( - + ) : ( - - ), - }} + + ),\ + }}\ /> ); } export default function TabLayout() { - if (isLiquidGlassAvailable()) { - return ; - } - return ; + if (isLiquidGlassAvailable()) {\n return (\n \n (\n \n ({ opacity: pressed ? 0.5 : 1 })}>\n \n \n \n ),\n }}\n />\n \n \n \n );\n } + return ;\ } diff --git a/artifacts/mobile/app/_layout.tsx b/artifacts/mobile/app/_layout.tsx index 53c26c3..4eb88fc 100644 --- a/artifacts/mobile/app/_layout.tsx +++ b/artifacts/mobile/app/_layout.tsx @@ -50,6 +50,7 @@ function RootLayoutNav() { + ); } diff --git a/artifacts/mobile/app/settings.tsx b/artifacts/mobile/app/settings.tsx new file mode 100644 index 0000000..b0f38f5 --- /dev/null +++ b/artifacts/mobile/app/settings.tsx @@ -0,0 +1,249 @@ +import { Stack } from 'expo-router'; +import { View, Text, StyleSheet, ScrollView, TextInput, Switch, Pressable, Linking, Platform } from 'react-native'; +import { useState, useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; +import Constants from 'expo-constants'; +import { useTimmy } from '@/context/TimmyContext'; +import { Ionicons } from '@expo/vector-icons'; +import { ConnectionBadge } from '@/components/ConnectionBadge'; +import { Colors } from '@/constants/colors'; + +const STORAGE_KEYS = { + SERVER_URL: 'settings_server_url', + NOTIFICATIONS_JOB_COMPLETION: 'settings_notifications_job_completion', + NOTIFICATIONS_LOW_BALANCE: 'settings_notifications_low_balance', + NOSTR_PRIVATE_KEY: 'settings_nostr_private_key', // Use SecureStore for this +}; + +export default function SettingsScreen() { + const { apiBaseUrl, setApiBaseUrl, isConnected, nostrPublicKey, connectNostr, disconnectNostr } = useTimmy(); + const C = Colors.dark; + + const [serverUrl, setServerUrl] = useState(apiBaseUrl); + const [jobCompletionNotifications, setJobCompletionNotifications] = useState(false); + const [lowBalanceWarning, setLowBalanceWarning] = useState(false); + const [currentNpub, setCurrentNpub] = useState(nostrPublicKey); + + useEffect(() => { + // Load settings from AsyncStorage and SecureStore + const loadSettings = async () => { + const storedServerUrl = await AsyncStorage.getItem(STORAGE_KEYS.SERVER_URL); + if (storedServerUrl) { + setServerUrl(storedServerUrl); + } + const storedJobCompletion = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_JOB_COMPLETION); + if (storedJobCompletion !== null) { + setJobCompletionNotifications(JSON.parse(storedJobCompletion)); + } + const storedLowBalance = await AsyncStorage.getItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE); + if (storedLowBalance !== null) { + setLowBalanceWarning(JSON.parse(storedLowBalance)); + } + // Nostr npub is handled by TimmyContext, so we just use the provided nostrPublicKey + setCurrentNpub(nostrPublicKey); + }; + loadSettings(); + }, [nostrPublicKey]); + + // Update apiBaseUrl in context when serverUrl changes and is saved + useEffect(() => { + if (serverUrl !== apiBaseUrl) { + setApiBaseUrl(serverUrl); + AsyncStorage.setItem(STORAGE_KEYS.SERVER_URL, serverUrl); + } + }, [serverUrl, setApiBaseUrl, apiBaseUrl]); + + const handleServerUrlChange = (text: string) => { + setServerUrl(text); + }; + + const toggleJobCompletionNotifications = async () => { + const newValue = !jobCompletionNotifications; + setJobCompletionNotifications(newValue); + await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_JOB_COMPLETION, JSON.stringify(newValue)); + }; + + const toggleLowBalanceWarning = async () => { + const newValue = !lowBalanceWarning; + setLowBalanceWarning(newValue); + await AsyncStorage.setItem(STORAGE_KEYS.NOTIFICATIONS_LOW_BALANCE, JSON.stringify(newValue)); + }; + + const handleConnectNostr = async () => { + // This will ideally link to a dedicated Nostr connection flow + console.log('Connect Nostr button pressed'); + // For now, simulate connection if not connected + if (!currentNpub) { + // This is a placeholder. Real implementation would involve generating/importing keys. + const simulatedNpub = 'npub1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + connectNostr(simulatedNpub, 'private_key_placeholder'); // Pass a placeholder private key + setCurrentNpub(simulatedNpub); + // In a real app, the private key would be securely stored and managed by the context + // For now, just a placeholder to show connected state + } + }; + + const handleDisconnectNostr = async () => { + await disconnectNostr(); + setCurrentNpub(null); + }; + + 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'; + + const openGiteaLink = () => { + Linking.openURL(giteaRepoUrl); + }; + + return ( + + + + Connection + + Server URL + + + + + + + Notifications + + Job Completion Push Notifications + + + + Low Balance Warning + + + + Identity + + Nostr Public Key + + {currentNpub ? `${currentNpub.substring(0, 10)}...${currentNpub.substring(currentNpub.length - 5)}` : 'Not connected'} + + + + {!currentNpub ? ( + [styles.button, { backgroundColor: C.accent, opacity: pressed ? 0.8 : 1 }]}> + Connect Nostr + + ) : ( + [styles.button, { backgroundColor: C.destructive, opacity: pressed ? 0.8 : 1 }]}> + Disconnect Nostr + + )} + + + About + + App Version + {appVersion} + + + Build Commit Hash + {buildCommitHash} + + [styles.linkButton, { opacity: pressed ? 0.8 : 1 }]}> + + View project on Gitea + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.dark.background, // Use background color from Colors + }, + scrollContent: { + padding: 20, + paddingBottom: 40, + }, + sectionHeader: { + fontSize: 18, + fontWeight: 'bold', + color: Colors.dark.text, + marginTop: 20, + marginBottom: 10, + }, + settingItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 0.5, + borderBottomColor: Colors.dark.border, + }, + settingLabel: { + fontSize: 16, + color: Colors.dark.text, + flex: 1, + }, + settingValue: { + fontSize: 16, + }, + serverUrlContainer: { + flexDirection: 'row', + alignItems: 'center', + flex: 2, + }, + input: { + flex: 1, + borderWidth: 1, + borderColor: Colors.dark.border, + 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, + }, +}); diff --git a/artifacts/mobile/package.json b/artifacts/mobile/package.json index 397d016..eb860e5 100644 --- a/artifacts/mobile/package.json +++ b/artifacts/mobile/package.json @@ -4,7 +4,7 @@ "private": true, "main": "expo-router/entry", "scripts": { - "dev": "EXPO_PACKAGER_PROXY_URL=https://$REPLIT_EXPO_DEV_DOMAIN EXPO_PUBLIC_DOMAIN=$REPLIT_DEV_DOMAIN EXPO_PUBLIC_REPL_ID=$REPL_ID REACT_NATIVE_PACKAGER_HOSTNAME=$REPLIT_DEV_DOMAIN pnpm exec expo start --localhost --port $PORT", + "dev": "pnpm exec expo start --localhost --port 8081", "build": "node scripts/build.js", "serve": "node server/serve.js", "typecheck": "tsc -p tsconfig.json --noEmit" diff --git a/artifacts/mobile/scripts/build.js b/artifacts/mobile/scripts/build.js index 6cce2d1..9f269b7 100644 --- a/artifacts/mobile/scripts/build.js +++ b/artifacts/mobile/scripts/build.js @@ -1,6 +1,6 @@ const fs = require("fs"); const path = require("path"); -const { spawn } = require("child_process"); +const { spawn, execSync } = require("child_process"); const { Readable } = require("stream"); const { pipeline } = require("stream/promises"); @@ -127,6 +127,15 @@ function getExpoPublicReplId() { return process.env.REPL_ID || process.env.EXPO_PUBLIC_REPL_ID; } +function getGitSha() { + try { + return execSync("git rev-parse HEAD", { cwd: workspaceRoot }).toString().trim(); + } catch (error) { + console.warn("Could not get git commit hash:", error.message); + return "unknown"; + } +} + async function startMetro(expoPublicDomain, expoPublicReplId) { const isRunning = await checkMetroHealth(); if (isRunning) { @@ -136,10 +145,12 @@ async function startMetro(expoPublicDomain, expoPublicReplId) { console.log("Starting Metro..."); console.log(`Setting EXPO_PUBLIC_DOMAIN=${expoPublicDomain}`); + const gitSha = getGitSha(); const env = { ...process.env, EXPO_PUBLIC_DOMAIN: expoPublicDomain, EXPO_PUBLIC_REPL_ID: expoPublicReplId, + EXPO_PUBLIC_GIT_SHA: gitSha, }; if (expoPublicReplId) {