Initial commit: OpenCode mobile app (extracted from opencode monorepo)

This commit is contained in:
Ubuntu
2026-05-17 19:52:54 +00:00
commit 1843a7c37e
84 changed files with 9303 additions and 0 deletions

47
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { Tabs } from "expo-router"
import { useColorScheme } from "react-native"
import { Ionicons } from "@expo/vector-icons"
export default function TabLayout() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: isDark ? "#ffffff" : "#0a0a0a",
tabBarInactiveTintColor: isDark ? "#666666" : "#999999",
tabBarStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
borderTopColor: isDark ? "#1a1a1a" : "#e5e5e5",
},
headerStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
},
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Sessions",
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="connections"
options={{
title: "Connections",
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/>
</Tabs>
)
}

387
app/(tabs)/connections.tsx Normal file
View File

@@ -0,0 +1,387 @@
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useConnections } from "../../src/stores/connections"
import { useSettings } from "../../src/stores/settings"
import type { ServerConnection } from "../../src/lib/types"
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200] as const
function ConnectionItem({
connection,
isDark,
isActive,
onSelect,
onEdit,
onDelete,
}: {
connection: ServerConnection
isDark: boolean
isActive: boolean
onSelect: () => void
onEdit: () => void
onDelete: () => void
}) {
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
const handleLongPress = () => {
Alert.alert(connection.name, "What would you like to do?", [
{ text: "Cancel", style: "cancel" },
{ text: "Edit", onPress: onEdit },
{ text: "Delete", style: "destructive", onPress: onDelete },
])
}
return (
<TouchableOpacity
style={[
styles.connectionItem,
isDark && styles.connectionItemDark,
isActive && styles.connectionItemActive,
isActive && isDark && styles.connectionItemActiveDark,
]}
onPress={onSelect}
onLongPress={handleLongPress}
>
<View style={styles.connectionIcon}>
<Ionicons name={typeIcon} size={24} color={isActive ? "#22c55e" : isDark ? "#888888" : "#666666"} />
</View>
<View style={styles.connectionContent}>
<View style={styles.connectionHeader}>
<Text
style={[
styles.connectionName,
isDark && styles.textDark,
isActive && styles.connectionNameActive,
isActive && isDark && styles.connectionNameActiveDark,
]}
>
{connection.name}
</Text>
{isActive && (
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>Active</Text>
</View>
)}
</View>
<Text
style={[styles.connectionUrl, isDark && styles.metaDark, isActive && styles.connectionUrlActive]}
numberOfLines={1}
>
{connection.url}
</Text>
{connection.lastConnected && (
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
</Text>
)}
</View>
<TouchableOpacity onPress={onEdit} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Ionicons name="ellipsis-vertical" size={20} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
</TouchableOpacity>
)
}
export default function ConnectionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
const { pageSize, setPageSize } = useSettings()
const handleDelete = (connection: ServerConnection) => {
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => removeConnection(connection.id),
},
])
}
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<FlatList
data={connections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ConnectionItem
connection={item}
isDark={isDark}
isActive={activeConnection?.id === item.id}
onSelect={() => setActiveConnection(item.id)}
onEdit={() => router.push(`/connection/${item.id}`)}
onDelete={() => handleDelete(item)}
/>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
Add a connection to your OpenCode server
</Text>
</View>
}
ListHeaderComponent={
<View style={[styles.header, isDark && styles.headerDark]}>
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
</View>
}
ListFooterComponent={
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>Preferences</Text>
<View style={styles.settingRow}>
<View style={styles.settingLabel}>
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.settingText, isDark && styles.textDark]}>Messages per page</Text>
</View>
<View style={styles.pagePicker}>
{PAGE_SIZE_OPTIONS.map((size) => (
<TouchableOpacity
key={size}
style={[
styles.pageOption,
isDark && styles.pageOptionDark,
pageSize === size && styles.pageOptionActive,
pageSize === size && isDark && styles.pageOptionActiveDark,
]}
onPress={() => setPageSize(size)}
>
<Text
style={[
styles.pageOptionText,
isDark && styles.metaDark,
pageSize === size && styles.pageOptionTextActive,
]}
>
{size}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
How many messages to load when opening a session. Lower = faster.
</Text>
</View>
}
contentContainerStyle={connections.length === 0 ? styles.emptyContent : undefined}
/>
{/* FAB to add connection */}
<TouchableOpacity style={[styles.fab, isDark && styles.fabDark]} onPress={() => router.push("/connection/add")}>
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
headerDark: {
borderBottomColor: "#1a1a1a",
},
headerText: {
fontSize: 13,
color: "#666666",
},
connectionItem: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
connectionItemDark: {
borderBottomColor: "#1a1a1a",
},
connectionItemActive: {
backgroundColor: "#f0fdf4",
},
connectionItemActiveDark: {
backgroundColor: "#1a2e1a",
},
connectionIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#f5f5f5",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
connectionContent: {
flex: 1,
},
connectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
connectionName: {
fontSize: 16,
fontWeight: "600",
color: "#0a0a0a",
},
connectionNameActive: {
color: "#0a0a0a",
},
connectionNameActiveDark: {
color: "#ffffff",
},
textDark: {
color: "#ffffff",
},
activeBadge: {
backgroundColor: "#22c55e",
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
activeBadgeDark: {
backgroundColor: "#16a34a",
},
activeBadgeText: {
color: "#ffffff",
fontSize: 11,
fontWeight: "600",
},
activeBadgeTextDark: {
color: "#ffffff",
},
connectionUrl: {
fontSize: 13,
color: "#666666",
marginTop: 2,
},
connectionUrlActive: {
color: "#888888",
},
connectionMeta: {
fontSize: 12,
color: "#999999",
marginTop: 4,
},
metaDark: {
color: "#888888",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
emptyTitle: {
fontSize: 20,
fontWeight: "600",
marginTop: 16,
color: "#0a0a0a",
},
emptySubtitle: {
fontSize: 14,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
emptyContent: {
flex: 1,
},
fab: {
position: "absolute",
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
fabDark: {
backgroundColor: "#ffffff",
},
settingsSection: {
padding: 16,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
marginTop: 16,
gap: 10,
},
settingsSectionDark: {
borderTopColor: "#1a1a1a",
},
settingsTitle: {
fontSize: 15,
fontWeight: "700",
color: "#0a0a0a",
},
settingRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
settingLabel: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
settingText: {
fontSize: 14,
color: "#0a0a0a",
},
pagePicker: {
flexDirection: "row",
gap: 6,
},
pageOption: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 8,
borderWidth: 1,
borderColor: "#e5e5e5",
backgroundColor: "#f5f5f5",
},
pageOptionDark: {
borderColor: "#2a2a2a",
backgroundColor: "#1a1a1a",
},
pageOptionActive: {
backgroundColor: "#0a0a0a",
borderColor: "#0a0a0a",
},
pageOptionActiveDark: {
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
},
pageOptionText: {
fontSize: 13,
fontWeight: "500",
color: "#666666",
},
pageOptionTextActive: {
color: "#ffffff",
},
settingHint: {
fontSize: 12,
color: "#999999",
},
})

836
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,836 @@
import { useEffect, useCallback, useState, useRef } from "react"
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
useColorScheme,
RefreshControl,
ActivityIndicator,
Modal,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
} from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSessions } from "../../src/stores/sessions"
import { useConnections } from "../../src/stores/connections"
import type BottomSheet from "@gorhom/bottom-sheet"
import type { Session } from "../../src/lib/sdk"
import { DirectorySwitcher } from "../../src/components/chat"
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return "Just now"
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
return date.toLocaleDateString()
}
function SessionItem({
session,
isDark,
onRename,
onDelete,
}: {
session: Session
isDark: boolean
onRename: () => void
onDelete: () => void
}) {
const onPress = () => {
router.push(`/session/${session.id}`)
}
const onLongPress = () => {
Alert.alert(session.title || "Untitled Session", undefined, [
{ text: "Cancel", style: "cancel" },
{ text: "Rename", onPress: onRename },
{ text: "Delete", style: "destructive", onPress: onDelete },
])
}
// Extract short directory name from session
const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null
return (
<TouchableOpacity
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
onPress={onPress}
onLongPress={onLongPress}
>
<View style={styles.sessionContent}>
<View style={styles.sessionHeader}>
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
{session.title || "Untitled Session"}
</Text>
</View>
<View style={styles.sessionMetaRow}>
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
{formatTime(session.time.updated)}
{session.summary && ` · ${session.summary.files} files`}
</Text>
{shortDir && (
<View style={styles.sessionDirBadge}>
<Ionicons name="folder-outline" size={12} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.sessionDirText, isDark && styles.metaDark]}>{shortDir}</Text>
</View>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
)
}
// Get short directory name (last folder or project name)
function getShortPath(
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
): string {
if (!project) return ""
if (project.name) return project.name
if (!project.path?.absolute) return ""
const parts = project.path.absolute.split("/").filter(Boolean)
return parts[parts.length - 1] || project.path.absolute
}
export default function SessionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const [showNewSession, setShowNewSession] = useState(false)
const [customDir, setCustomDir] = useState("")
const [isCreating, setIsCreating] = useState(false)
const [renaming, setRenaming] = useState<Session | null>(null)
const [renameText, setRenameText] = useState("")
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
const {
activeConnection,
client,
currentProject,
serverHome,
refreshProject,
clientForDirectory,
switchDirectory,
addRecentDirectory,
recentDirectories,
} = useConnections()
const dirSheetRef = useRef<BottomSheet>(null)
const [refreshing, setRefreshing] = useState(false)
const handleSwitchDirectory = useCallback(
async (dir?: string) => {
await switchDirectory(dir)
loadSessions()
refreshProject()
},
[switchDirectory, loadSessions, refreshProject],
)
useEffect(() => {
if (client) {
loadSessions()
refreshProject()
}
}, [client])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([loadSessions(), refreshProject()])
setRefreshing(false)
}, [])
const handleRename = useCallback((session: Session) => {
setRenameText(session.title || "")
setRenaming(session)
}, [])
const submitRename = useCallback(async () => {
const title = renameText.trim()
if (!title || !renaming || !client) return
await client.session.update(renaming.id, { title })
setRenaming(null)
setRenameText("")
loadSessions()
}, [renaming, renameText, client, loadSessions])
const handleDelete = useCallback(
(session: Session) => {
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteSession(session.id)
},
},
])
},
[deleteSession],
)
const onCreateSession = async () => {
const session = await createSession()
if (session) {
router.push(`/session/${session.id}`)
}
}
const onCreateInDirectory = async (dir?: string) => {
if (!activeConnection) return
setIsCreating(true)
// If a custom directory is specified, use a one-off client for that directory
// so we don't mutate the connection's default project
if (dir && dir.trim()) {
const dirClient = clientForDirectory(dir.trim())
if (!dirClient) {
setIsCreating(false)
return
}
try {
const session = await dirClient.session.create({})
addRecentDirectory(dir.trim())
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push({ pathname: `/session/[id]`, params: { id: session.id, directory: dir.trim() } })
}
} catch (error) {
console.error("Failed to create session in directory:", error)
Alert.alert("Error", "Failed to create session in that directory.")
setIsCreating(false)
}
return
}
const session = await createSession()
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push(`/session/${session.id}`)
}
}
const onFabPress = () => {
// Quick create in current project
onCreateSession()
}
const onFabLongPress = () => {
// Show modal with more options
setCustomDir("")
setShowNewSession(true)
}
if (!activeConnection) {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => router.push("/connection/add")}
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
</TouchableOpacity>
</View>
)
}
const shortPath = getShortPath(currentProject)
return (
<View style={[styles.container, isDark && styles.containerDark]}>
{/* Connection indicator — tap to switch project */}
<TouchableOpacity
style={[styles.connectionBar, isDark && styles.connectionBarDark]}
onPress={() => dirSheetRef.current?.expand()}
onLongPress={() => router.push("/(tabs)/connections")}
activeOpacity={0.7}
>
<View style={styles.connectionInfo}>
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} />
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
{activeConnection.name}
</Text>
{shortPath && (
<>
<Ionicons name="folder" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.projectPath, isDark && styles.metaDark]} numberOfLines={1}>
{shortPath}
</Text>
</>
)}
</View>
<Ionicons name="swap-horizontal-outline" size={16} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
{error && (
<View style={styles.errorBar}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<FlatList
data={sessions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SessionItem
session={item}
isDark={isDark}
onRename={() => handleRename(item)}
onDelete={() => handleDelete(item)}
/>
)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
}
ListEmptyComponent={
isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : (
<View style={styles.emptyList}>
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
</View>
)
}
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
/>
{/* FAB to create new session */}
<TouchableOpacity
style={[styles.fab, isDark && styles.fabDark]}
onPress={onFabPress}
onLongPress={onFabLongPress}
delayLongPress={500}
>
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
{/* New Session Info Modal */}
<Modal visible={showNewSession} animationType="slide" transparent>
<KeyboardAvoidingView style={styles.modalOverlay} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
<TouchableOpacity onPress={() => setShowNewSession(false)}>
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
</View>
<View style={styles.modalBody}>
{/* Current directory */}
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Directory</Text>
<View style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}>
<Ionicons name="folder" size={20} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
</Text>
</View>
{/* Custom directory input */}
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
Or use a different folder
</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={customDir}
onChangeText={(text) => {
// Expand ~ to server home directory
if (serverHome && text.startsWith("~/")) {
setCustomDir(serverHome + text.slice(1))
} else if (serverHome && text === "~") {
setCustomDir(serverHome)
} else {
setCustomDir(text)
}
}}
autoCapitalize="none"
autoCorrect={false}
/>
{/* Quick path shortcuts */}
{serverHome && (
<View style={styles.pathChips}>
<TouchableOpacity
style={[styles.pathChip, isDark && styles.pathChipDark]}
onPress={() => setCustomDir(serverHome)}
>
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.pathChip, isDark && styles.pathChipDark]}
onPress={() => setCustomDir(serverHome + "/")}
>
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~/</Text>
</TouchableOpacity>
</View>
)}
</View>
<View style={styles.modalActions}>
{customDir.trim() ? (
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButtonPrimary,
isDark && styles.modalButtonPrimaryDark,
styles.modalButtonFull,
]}
onPress={() => onCreateInDirectory(customDir)}
disabled={isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
</Text>
)}
</TouchableOpacity>
) : (
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButtonPrimary,
isDark && styles.modalButtonPrimaryDark,
styles.modalButtonFull,
]}
onPress={() => onCreateInDirectory()}
disabled={isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
Create Session
</Text>
)}
</TouchableOpacity>
)}
</View>
</View>
</KeyboardAvoidingView>
</Modal>
{/* Rename modal */}
<Modal visible={!!renaming} animationType="fade" transparent>
<KeyboardAvoidingView
style={[styles.modalOverlay, { justifyContent: "center" }]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
value={renameText}
onChangeText={setRenameText}
onSubmitEditing={submitRename}
returnKeyType="done"
autoFocus
selectTextOnFocus
autoCapitalize="sentences"
autoCorrect={false}
/>
<View style={styles.renameActions}>
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
<Text style={styles.renameBtnCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
onPress={submitRename}
disabled={!renameText.trim()}
>
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
</KeyboardAvoidingView>
</Modal>
{/* Directory switcher bottom sheet */}
<DirectorySwitcher
sheetRef={dirSheetRef}
current={activeConnection?.directory}
recents={recentDirectories}
serverHome={serverHome}
isDark={isDark}
onSwitch={handleSwitchDirectory}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
connectionBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
connectionBarDark: {
borderBottomColor: "#1a1a1a",
},
connectionInfo: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
connectionDot: {
width: 8,
height: 8,
borderRadius: 4,
},
connectionName: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
},
connectionUrl: {
fontSize: 12,
color: "#666666",
},
projectPath: {
fontSize: 13,
color: "#666666",
flex: 1,
},
errorBar: {
backgroundColor: "#fef2f2",
padding: 12,
borderBottomWidth: 1,
borderBottomColor: "#fecaca",
},
errorText: {
color: "#dc2626",
fontSize: 14,
},
sessionItem: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
sessionItemDark: {
borderBottomColor: "#1a1a1a",
},
sessionContent: {
flex: 1,
},
sessionHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 2,
},
sessionTitle: {
fontSize: 16,
fontWeight: "500",
color: "#0a0a0a",
marginBottom: 4,
},
textDark: {
color: "#ffffff",
},
sessionMeta: {
fontSize: 13,
color: "#666666",
},
sessionMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
sessionDirBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
sessionDirText: {
fontSize: 11,
color: "#666666",
},
metaDark: {
color: "#888888",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
backgroundColor: "#ffffff",
},
emptyTitle: {
fontSize: 20,
fontWeight: "600",
marginTop: 16,
color: "#0a0a0a",
},
emptySubtitle: {
fontSize: 14,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
addButton: {
backgroundColor: "#0a0a0a",
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
marginTop: 24,
},
addButtonDark: {
backgroundColor: "#ffffff",
},
addButtonText: {
color: "#ffffff",
fontWeight: "600",
},
addButtonTextDark: {
color: "#0a0a0a",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
emptyList: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
emptyListText: {
fontSize: 16,
color: "#666666",
},
emptyContent: {
flex: 1,
},
fab: {
position: "absolute",
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
fabDark: {
backgroundColor: "#ffffff",
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalDismiss: {
flex: 1,
},
modalContent: {
backgroundColor: "#ffffff",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
paddingBottom: 40,
},
modalContentDark: {
backgroundColor: "#1a1a1a",
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
color: "#0a0a0a",
},
modalBody: {
marginBottom: 24,
},
modalLabel: {
fontSize: 13,
fontWeight: "600",
color: "#666666",
marginBottom: 8,
textTransform: "uppercase",
},
modalDirBox: {
flexDirection: "row",
alignItems: "center",
gap: 12,
backgroundColor: "#f5f5f5",
padding: 16,
borderRadius: 12,
},
modalDirBoxDark: {
backgroundColor: "#2a2a2a",
},
modalDirText: {
fontSize: 15,
color: "#0a0a0a",
flex: 1,
},
modalInput: {
backgroundColor: "#f5f5f5",
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
color: "#0a0a0a",
},
modalInputDark: {
backgroundColor: "#2a2a2a",
color: "#ffffff",
},
pathChips: {
flexDirection: "row",
gap: 8,
marginTop: 8,
},
pathChip: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e5f0",
borderRadius: 16,
},
pathChipDark: {
backgroundColor: "#2a2040",
},
pathChipText: {
fontSize: 13,
fontWeight: "600",
color: "#6d28d9",
},
pathChipTextDark: {
color: "#c4b5fd",
},
modalHint: {
fontSize: 13,
color: "#666666",
marginTop: 12,
},
modalActions: {
flexDirection: "row",
gap: 12,
},
modalButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
borderRadius: 12,
},
modalButtonSecondary: {
backgroundColor: "#f5f5f5",
},
modalButtonSecondaryDark: {
backgroundColor: "#2a2a2a",
},
modalButtonPrimary: {
backgroundColor: "#0a0a0a",
},
modalButtonPrimaryDark: {
backgroundColor: "#ffffff",
},
modalButtonText: {
fontSize: 15,
fontWeight: "600",
color: "#0a0a0a",
},
modalButtonTextPrimary: {
fontSize: 15,
fontWeight: "600",
color: "#ffffff",
},
modalButtonTextPrimaryDark: {
color: "#0a0a0a",
},
modalButtonFull: {
flex: 0,
width: "100%",
},
// Rename modal
renameCard: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 20,
marginHorizontal: 32,
gap: 16,
},
renameCardDark: {
backgroundColor: "#1a1a1a",
},
renameTitle: {
fontSize: 17,
fontWeight: "600",
color: "#0a0a0a",
},
renameActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 12,
},
renameBtn: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
renameBtnCancel: {
backgroundColor: "transparent",
},
renameBtnCancelText: {
fontSize: 15,
fontWeight: "600",
color: "#888888",
},
})

289
app/(tabs)/settings.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { useCallback, useState } from "react"
import {
View,
Text,
ScrollView,
TouchableOpacity,
Switch,
StyleSheet,
useColorScheme,
Linking,
Alert,
} from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useAuth } from "../../src/stores/auth"
import { useSettings } from "../../src/stores/settings"
import {
categories,
categoryMeta,
setup as setupNotifications,
granted as notificationsGranted,
} from "../../src/lib/notifications"
import type { Category } from "../../src/lib/notifications"
function SettingRow({
icon,
label,
description,
isDark,
right,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap
label: string
description?: string
isDark: boolean
right?: React.ReactNode
onPress?: () => void
}) {
const content = (
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
<View style={[styles.settingIcon, isDark && styles.settingIconDark]}>
<Ionicons name={icon} size={22} color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingLabel, isDark && styles.textDark]}>{label}</Text>
{description && <Text style={[styles.settingDescription, isDark && styles.metaDark]}>{description}</Text>}
</View>
{right}
</View>
)
if (onPress) {
return <TouchableOpacity onPress={onPress}>{content}</TouchableOpacity>
}
return content
}
function SettingSection({ title, children, isDark }: { title: string; children: React.ReactNode; isDark: boolean }) {
return (
<View style={styles.section}>
<Text style={[styles.sectionTitle, isDark && styles.sectionTitleDark]}>{title}</Text>
<View style={[styles.sectionContent, isDark && styles.sectionContentDark]}>{children}</View>
</View>
)
}
export default function SettingsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
const { notifications, setNotification } = useSettings()
const [osGranted, setOsGranted] = useState<boolean | null>(null)
// Check OS permission state on first toggle attempt
const handleToggle = useCallback(
async (category: Category, enabled: boolean) => {
if (enabled) {
const ok = await setupNotifications()
setOsGranted(ok)
if (!ok) {
Alert.alert(
"Notifications Disabled",
"Enable notifications for OpenCode in your device settings to receive alerts.",
)
return
}
}
setNotification(category, enabled)
},
[setNotification],
)
// Lazy-check OS permission for status display
if (osGranted === null) {
notificationsGranted().then(setOsGranted)
}
return (
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
<SettingSection title="Security" isDark={isDark}>
<SettingRow
icon="finger-print"
label="Require Biometric to Open"
description={
hasBiometrics ? "Use Face ID or Touch ID to unlock the app" : "Biometric authentication not available"
}
isDark={isDark}
right={
<Switch
value={settings.requireBiometric}
onValueChange={(value) => updateSettings({ requireBiometric: value })}
disabled={!hasBiometrics}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
<SettingRow
icon="lock-closed"
label="Require Biometric to Send"
description="Authenticate before sending messages"
isDark={isDark}
right={
<Switch
value={settings.requireBiometricForMessages}
onValueChange={(value) => updateSettings({ requireBiometricForMessages: value })}
disabled={!hasBiometrics || !settings.requireBiometric}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
{settings.requireBiometric && (
<SettingRow
icon="exit"
label="Lock App Now"
description="Require authentication to reopen"
isDark={isDark}
onPress={lock}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
)}
</SettingSection>
<SettingSection title="Notifications" isDark={isDark}>
{categories.map((category) => {
const meta = categoryMeta[category]
return (
<SettingRow
key={category}
icon={meta.icon as keyof typeof Ionicons.glyphMap}
label={meta.label}
description={meta.description}
isDark={isDark}
right={
<Switch
value={notifications[category]}
onValueChange={(value) => handleToggle(category, value)}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
)
})}
{osGranted === false && (
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
Notifications are disabled at the system level. Enable them in Settings to receive alerts.
</Text>
</View>
)}
</SettingSection>
<SettingSection title="About" isDark={isDark}>
<SettingRow icon="information-circle" label="Version" description="1.0.0" isDark={isDark} />
<SettingRow
icon="logo-github"
label="GitHub"
description="View source code"
isDark={isDark}
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<SettingRow
icon="document-text"
label="Documentation"
description="Learn how to use OpenCode"
isDark={isDark}
onPress={() => Linking.openURL("https://opencode.ai/docs")}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
</SettingSection>
<View style={styles.footer}>
<Text style={[styles.footerText, isDark && styles.metaDark]}>OpenCode Mobile</Text>
<Text style={[styles.footerText, isDark && styles.metaDark]}>
Connect to your AI coding assistant from anywhere
</Text>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
content: {
paddingBottom: 32,
},
section: {
marginTop: 24,
},
sectionTitle: {
fontSize: 13,
fontWeight: "600",
color: "#666666",
marginLeft: 16,
marginBottom: 8,
textTransform: "uppercase",
},
sectionTitleDark: {
color: "#888888",
},
sectionContent: {
backgroundColor: "#ffffff",
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: "#e5e5e5",
},
sectionContentDark: {
backgroundColor: "#1a1a1a",
borderColor: "#2a2a2a",
},
settingRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
settingRowDark: {
borderBottomColor: "#2a2a2a",
},
settingIcon: {
width: 36,
height: 36,
borderRadius: 8,
backgroundColor: "#f5f5f5",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
settingIconDark: {
backgroundColor: "#2a2a2a",
},
settingContent: {
flex: 1,
},
settingLabel: {
fontSize: 16,
color: "#0a0a0a",
},
textDark: {
color: "#ffffff",
},
settingDescription: {
fontSize: 13,
color: "#666666",
marginTop: 2,
},
metaDark: {
color: "#888888",
},
footer: {
alignItems: "center",
padding: 32,
},
footerText: {
fontSize: 13,
color: "#999999",
textAlign: "center",
},
})