Extends the i18n infra from #97 (Settings screen only) to the rest of the app: session chat screen, connection add/edit/list screens, sessions list (incl. directory grouping from #96), navigation titles, notifications category metadata, error boundary, telemetry consent modal, auth gate, and every chat UI component (permission/question prompts, status indicator, model/variant pickers, directory switcher/browser, reasoning block, tool call card, session info). - 244 new keys added to en.json/zh-Hans.json with reviewed, natural Simplified Chinese (not machine-garbage), keeping key sets identical. - User content, server URLs, code snippets, log/error-detail text, and diagnostics-classify.ts (pure dependency-free module feeding Sentry/ support reports) are intentionally left untranslated per scope. - Interpolation used for counts/names (e.g. reconnect attempt, files count, connection name in delete confirmations); categoryMeta/ CONNECTION_TYPES switched to labelKey indirection since they're module-level constants evaluated before i18next is guaranteed ready. - Added src/lib/i18n/catalog-parity.test.ts (node --test) asserting en.json/zh-Hans.json expose identical key sets and no empty values, to catch future locale drift. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
401 lines
11 KiB
TypeScript
401 lines
11 KiB
TypeScript
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
|
|
import { router } from "expo-router"
|
|
import { Ionicons } from "@expo/vector-icons"
|
|
import { useTranslation } from "react-i18next"
|
|
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 { t } = useTranslation()
|
|
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
|
|
|
|
const handleLongPress = () => {
|
|
Alert.alert(connection.name, t("connectionsList.actionsAlert.message"), [
|
|
{ text: t("common.cancel"), style: "cancel" },
|
|
{ text: t("connectionsList.actionsAlert.edit"), onPress: onEdit },
|
|
{ text: t("common.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]}>
|
|
{t("connectionsList.activeBadge")}
|
|
</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]}>
|
|
{t("connectionsList.lastConnected", { date: 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 { t } = useTranslation()
|
|
|
|
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
|
|
const { pageSize, setPageSize } = useSettings()
|
|
|
|
const handleDelete = (connection: ServerConnection) => {
|
|
Alert.alert(
|
|
t("connection.edit.alerts.deleteTitle"),
|
|
t("connection.edit.alerts.deleteMessage", { name: connection.name }),
|
|
[
|
|
{ text: t("common.cancel"), style: "cancel" },
|
|
{
|
|
text: t("common.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]}>{t("connectionsList.empty.title")}</Text>
|
|
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
|
{t("connectionsList.empty.subtitle")}
|
|
</Text>
|
|
</View>
|
|
}
|
|
ListHeaderComponent={
|
|
<View style={[styles.header, isDark && styles.headerDark]}>
|
|
<Text style={[styles.headerText, isDark && styles.metaDark]}>{t("connectionsList.header")}</Text>
|
|
</View>
|
|
}
|
|
ListFooterComponent={
|
|
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
|
|
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>
|
|
{t("connectionsList.preferences.title")}
|
|
</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]}>
|
|
{t("connectionsList.preferences.pageSizeLabel")}
|
|
</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]}>
|
|
{t("connectionsList.preferences.pageSizeHint")}
|
|
</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",
|
|
},
|
|
})
|