diff --git a/AGENTS.md b/AGENTS.md index a37790f..7a4755b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -418,7 +418,12 @@ All PascalCase function components taking a `…Props` interface. `SavePathPickerModal` (filterable list of save paths already in use, derived from TorrentContext via `utils/save-paths.ts` — works on any qBittorrent version), `SearchCartModal` (review sheet for the Search tab's add queue — - list, per-item remove, Clear all, Checkout — see `SearchCartContext.tsx`). + list, per-item remove, Clear all, Checkout — see `SearchCartContext.tsx`), + `ServerSwitcherModal` (quick server switcher sheet, #249 — opened from a + compact badge+name in the torrents screen header; lists saved servers, marks + the connected one, taps another to call `connectToServer` directly; owns its + own transient switching-id/error state rather than threading it through the + screen, mirroring `QuickConnectPanel`'s row treatment). - **Torrent / search UI** — `TorrentCard` (`React.memo` with a **custom comparator — keep it in sync when you add a rendered field**, or the card silently stops updating; category/tag stickers use `categoryColors`/`tagColors` diff --git a/app/(tabs)/(torrents)/index.tsx b/app/(tabs)/(torrents)/index.tsx index 1b2d52e..ed10bc0 100644 --- a/app/(tabs)/(torrents)/index.tsx +++ b/app/(tabs)/(torrents)/index.tsx @@ -55,6 +55,8 @@ import { spacing, borderRadius } from '@/constants/spacing'; import { buttonStyles, buttonText } from '@/constants/buttons'; import { typography } from '@/constants/typography'; import { QuickConnectPanel } from '@/components/QuickConnectPanel'; +import { ServerSwitcherModal } from '@/components/ServerSwitcherModal'; +import { ServerIconBadge } from '@/components/ServerIconBadge'; import { useTorrentActions } from '@/hooks/useTorrentActions'; import { useGracefulError } from '@/hooks/useGracefulError'; import { getErrorMessage } from '@/utils/error'; @@ -81,6 +83,7 @@ export default function TorrentsScreen() { } = useTorrents(); const { graceError, isPendingError } = useGracefulError(error); const { + currentServer, isConnected, isLoading: serverIsLoading, isConnecting, @@ -715,30 +718,30 @@ export default function TorrentsScreen() { }, ]; - // ─── Server quick-connect state (used in not-connected early return) ──────── + // ─── Server quick-connect state (not-connected panel + the connected + // server switcher, #249 — both list the same saved servers) ──────────────── const [savedServers, setSavedServers] = useState([]); const [serversLoaded, setServersLoaded] = useState(false); const [connectingId, setConnectingId] = useState(null); const [connectErrors, setConnectErrors] = useState>({}); + const [showServerSwitcher, setShowServerSwitcher] = useState(false); - // Refetch on every focus (not just isConnected changes) so an icon/color - // edited in Settings while still disconnected shows up when this screen - // regains focus, instead of only refreshing on the next connect/disconnect. + // Refetch on every focus, connected or not, so an icon/color edited in + // Settings shows up here next time this screen regains focus rather than + // only on the next connect/disconnect. useFocusEffect( useCallback(() => { - if (!isConnected) { - setServersLoaded(false); - ServerManager.getServers() - .then((s) => { - setSavedServers(s); - setServersLoaded(true); - }) - .catch(() => { - setSavedServers([]); - setServersLoaded(true); - }); - } - }, [isConnected]), + setServersLoaded(false); + ServerManager.getServers() + .then((s) => { + setSavedServers(s); + setServersLoaded(true); + }) + .catch(() => { + setSavedServers([]); + setServersLoaded(true); + }); + }, []), ); const handleQuickConnect = useCallback( @@ -1204,6 +1207,25 @@ export default function TorrentsScreen() { ]} > + {/* Current server — opens the quick server switcher (#249) */} + {!selectMode && currentServer && ( + { + haptics.light(); + setShowServerSwitcher(true); + }} + activeOpacity={0.7} + accessibilityLabel={t('screens.torrents.switchServer')} + > + + + {currentServer.name} + + + + )} + {/* Search bar with Sort button */} {/* LEFT: Sort button — fixed 42×42 */} @@ -2142,6 +2164,18 @@ export default function TorrentsScreen() { setListDeleteConfirm(null); }} /> + + { + setShowServerSwitcher(false); + router.push('/settings/servers'); + }} + onClose={() => setShowServerSwitcher(false)} + /> ); @@ -2323,6 +2357,21 @@ const styles = StyleSheet.create({ paddingTop: spacing.sm, paddingBottom: spacing.xs, }, + serverSwitcherRow: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + gap: spacing.xs, + marginBottom: spacing.sm, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.xs, + maxWidth: '100%', + }, + serverSwitcherText: { + fontSize: 14, + fontWeight: '600', + flexShrink: 1, + }, searchRow: { flexDirection: 'row', alignItems: 'center', @@ -2387,7 +2436,9 @@ const styles = StyleSheet.create({ opacity: 0.5, }, listContent: { - paddingTop: 100, + // +36 over the skeleton's 100 to make room for the server-switcher row + // (#249) that only renders in this, the fully-connected header. + paddingTop: 136, paddingHorizontal: spacing.md, borderRadius: borderRadius.large, }, diff --git a/components/ServerSwitcherModal.tsx b/components/ServerSwitcherModal.tsx new file mode 100644 index 0000000..3077712 --- /dev/null +++ b/components/ServerSwitcherModal.tsx @@ -0,0 +1,314 @@ +/** + * ServerSwitcherModal.tsx — Quick server switcher sheet (#249). + * + * Opened from a compact badge in the torrents screen header. Lists every + * saved server, marks the connected one, and switches directly to whichever + * other one is tapped — mirrors QuickConnectPanel's row treatment (icon + * badge, address, per-row error/spinner) since that's the UI both the issue + * reporter and a commenter praised. Owns its own transient switching/error + * state, like CategoryModal owns `busy` — callers just hand it a list and an + * async `onSelectServer`. + * + * Key exports: ServerSwitcherModal + */ +import React, { useEffect, useState } from 'react'; +import { + Modal, + View, + Text, + TouchableOpacity, + ScrollView, + StyleSheet, + ActivityIndicator, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from '@/context/ThemeContext'; +import { ServerConfig } from '@/types/api'; +import { ServerIconBadge } from '@/components/ServerIconBadge'; +import { getServerIconColor, serverAddress } from '@/utils/server'; +import { withAlpha } from '@/utils/color'; +import { getErrorMessage } from '@/utils/error'; +import { haptics } from '@/utils/haptics'; +import { spacing, borderRadius } from '@/constants/spacing'; +import { shadows } from '@/constants/shadows'; +import { typography } from '@/constants/typography'; + +interface ServerSwitcherModalProps { + visible: boolean; + servers: ServerConfig[]; + currentServerId: string | null; + /** Same contract as ServerContext's connectToServer: resolves false (not a throw) on bad credentials. */ + onSelectServer: (server: ServerConfig) => Promise; + onManageServers: () => void; + onClose: () => void; +} + +export function ServerSwitcherModal({ + visible, + servers, + currentServerId, + onSelectServer, + onManageServers, + onClose, +}: ServerSwitcherModalProps) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [switchingId, setSwitchingId] = useState(null); + const [switchErrors, setSwitchErrors] = useState>({}); + + useEffect(() => { + if (visible) { + setSwitchingId(null); + setSwitchErrors({}); + } + }, [visible]); + + const handlePress = async (server: ServerConfig) => { + if (server.id === currentServerId) { + onClose(); + return; + } + haptics.medium(); + setSwitchingId(server.id); + setSwitchErrors((prev) => { + const next = { ...prev }; + delete next[server.id]; + return next; + }); + try { + const connected = await onSelectServer(server); + if (connected) { + onClose(); + } else { + setSwitchErrors((prev) => ({ ...prev, [server.id]: t('errors.checkCredentials') })); + haptics.error(); + } + } catch (err: unknown) { + setSwitchErrors((prev) => ({ ...prev, [server.id]: getErrorMessage(err) })); + haptics.error(); + } finally { + setSwitchingId(null); + } + }; + + return ( + + + + + + {t('screens.torrents.switchServer')} + + + + + + + {servers.length === 0 ? ( + + {t('screens.torrents.notConnectedSubtitle')} + + ) : ( + + {servers.map((server, index) => { + const color = getServerIconColor(server); + const addr = serverAddress(server); + const isCurrent = server.id === currentServerId; + const isSwitchingThis = switchingId === server.id; + const errMsg = switchErrors[server.id]; + const isLast = index === servers.length - 1; + return ( + + { + void handlePress(server); + }} + activeOpacity={0.7} + disabled={switchingId !== null} + accessibilityLabel={server.name} + accessibilityState={{ selected: isCurrent }} + > + + + + {server.name} + + + {server.useHttps && ( + + )} + + {addr} + + + {errMsg && ( + + {errMsg} + + )} + + {isCurrent ? ( + + + + {t('screens.torrents.currentServer')} + + + ) : isSwitchingThis ? ( + + ) : ( + + )} + + {!isLast && ( + + )} + + ); + })} + + )} + + + + + {t('screens.torrents.manageServers')} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + card: { + width: '90%', + maxWidth: 480, + maxHeight: '75%', + borderRadius: borderRadius.large, + borderWidth: StyleSheet.hairlineWidth, + padding: spacing.lg, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: spacing.sm, + marginBottom: spacing.sm, + borderBottomWidth: 1, + }, + title: { ...typography.h3, fontSize: 18 }, + list: { + flexGrow: 0, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingVertical: spacing.sm + 2, + }, + rowBody: { + flex: 1, + minWidth: 0, + }, + rowText: { + fontSize: 15, + fontWeight: '600', + marginBottom: 2, + }, + addressRow: { + flexDirection: 'row', + alignItems: 'center', + }, + rowMeta: { + fontSize: 12, + fontWeight: '400', + }, + errorText: { + fontSize: 11, + marginTop: 2, + fontWeight: '500', + }, + currentPill: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.full, + flexShrink: 0, + }, + currentPillText: { + fontSize: 12, + fontWeight: '600', + }, + divider: { + height: 1, + }, + emptyText: { + ...typography.secondary, + textAlign: 'center', + paddingVertical: spacing.xl, + }, + manageRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs + 2, + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: 1, + }, + manageRowText: { + fontSize: 14, + fontWeight: '600', + }, +}); diff --git a/locales/de/translation.json b/locales/de/translation.json index d62a255..347dd24 100644 --- a/locales/de/translation.json +++ b/locales/de/translation.json @@ -56,7 +56,10 @@ "deselectAll": "Alle abwählen", "selectMode": "Auswählen", "selectTorrent": "Torrent auswählen", - "deselectTorrent": "Torrent abwählen" + "deselectTorrent": "Torrent abwählen", + "switchServer": "Server wechseln", + "manageServers": "Server verwalten", + "currentServer": "Aktuell" }, "addTorrent": { "none": "Keiner", diff --git a/locales/en/translation.json b/locales/en/translation.json index 0734e69..75aba1d 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -77,7 +77,10 @@ "deselectAll": "Deselect All", "selectMode": "Select", "selectTorrent": "Select torrent", - "deselectTorrent": "Deselect torrent" + "deselectTorrent": "Deselect torrent", + "switchServer": "Switch Server", + "manageServers": "Manage Servers", + "currentServer": "Current" }, "addTorrent": { "none": "None", diff --git a/locales/es/translation.json b/locales/es/translation.json index 9236dab..0d66e64 100644 --- a/locales/es/translation.json +++ b/locales/es/translation.json @@ -56,7 +56,10 @@ "deselectAll": "Deseleccionar todo", "selectMode": "Seleccionar", "selectTorrent": "Seleccionar torrent", - "deselectTorrent": "Deseleccionar torrent" + "deselectTorrent": "Deseleccionar torrent", + "switchServer": "Cambiar Servidor", + "manageServers": "Administrar Servidores", + "currentServer": "Actual" }, "addTorrent": { "none": "Ninguno", diff --git a/locales/fr/translation.json b/locales/fr/translation.json index 1a6b797..7116ab6 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -56,7 +56,10 @@ "deselectAll": "Tout désélectionner", "selectMode": "Sélectionner", "selectTorrent": "Sélectionner le torrent", - "deselectTorrent": "Désélectionner le torrent" + "deselectTorrent": "Désélectionner le torrent", + "switchServer": "Changer de Serveur", + "manageServers": "Gérer les Serveurs", + "currentServer": "Actuel" }, "addTorrent": { "none": "Aucun", diff --git a/locales/ru/translation.json b/locales/ru/translation.json index ca184d9..e9873c7 100644 --- a/locales/ru/translation.json +++ b/locales/ru/translation.json @@ -77,7 +77,10 @@ "deselectAll": "Снять выделение", "selectMode": "Выбрать", "selectTorrent": "Выбрать торрент", - "deselectTorrent": "Снять выбор торрента" + "deselectTorrent": "Снять выбор торрента", + "switchServer": "Сменить сервер", + "manageServers": "Управление серверами", + "currentServer": "Текущий" }, "addTorrent": { "none": "Нет", diff --git a/locales/zh/translation.json b/locales/zh/translation.json index 1bb8b0d..fb3318b 100644 --- a/locales/zh/translation.json +++ b/locales/zh/translation.json @@ -56,7 +56,10 @@ "deselectAll": "取消全选", "selectMode": "选择", "selectTorrent": "选择种子", - "deselectTorrent": "取消选择种子" + "deselectTorrent": "取消选择种子", + "switchServer": "切换服务器", + "manageServers": "管理服务器", + "currentServer": "当前" }, "addTorrent": { "none": "无", diff --git a/tests/rn/components/ServerSwitcherModal.test.tsx b/tests/rn/components/ServerSwitcherModal.test.tsx new file mode 100644 index 0000000..26505bf --- /dev/null +++ b/tests/rn/components/ServerSwitcherModal.test.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { ServerSwitcherModal } from '@/components/ServerSwitcherModal'; +import { ServerConfig } from '@/types/api'; + +jest.mock('@/context/ThemeContext', () => ({ + useTheme: () => ({ + colors: { + surface: '#fff', + text: '#000', + textSecondary: '#666', + surfaceOutline: '#ccc', + primary: '#007aff', + background: '#fff', + success: '#34c759', + error: '#ff3b30', + }, + }), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function makeServer(overrides: Partial = {}): ServerConfig { + return { + id: 'server-1', + name: 'Home Server', + host: '192.168.1.10', + port: 8080, + username: 'admin', + password: '', + ...overrides, + }; +} + +const serverA = makeServer({ id: 'a', name: 'Home Server' }); +const serverB = makeServer({ id: 'b', name: 'Seedbox' }); + +describe('ServerSwitcherModal', () => { + it('renders nothing when not visible', async () => { + await render( + , + ); + expect(screen.queryByText('Home Server')).toBeNull(); + }); + + it('lists every saved server when visible', async () => { + await render( + , + ); + expect(screen.getByText('Home Server')).toBeTruthy(); + expect(screen.getByText('Seedbox')).toBeTruthy(); + }); + + it('marks the current server', async () => { + await render( + , + ); + expect(screen.getByText('screens.torrents.currentServer')).toBeTruthy(); + }); + + it('invokes onSelectServer with the tapped, non-current server', async () => { + const onSelectServer = jest.fn().mockResolvedValue(true); + await render( + , + ); + await fireEvent.press(screen.getByLabelText('Seedbox')); + await waitFor(() => expect(onSelectServer).toHaveBeenCalledWith(serverB)); + }); + + it('closes the modal instead of reconnecting when the current server is tapped', async () => { + const onSelectServer = jest.fn(); + const onClose = jest.fn(); + await render( + , + ); + await fireEvent.press(screen.getByLabelText('Home Server')); + expect(onSelectServer).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('closes the modal once switching to another server succeeds', async () => { + const onSelectServer = jest.fn().mockResolvedValue(true); + const onClose = jest.fn(); + await render( + , + ); + await fireEvent.press(screen.getByLabelText('Seedbox')); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it('shows an inline error and keeps the modal open when switching fails', async () => { + const onSelectServer = jest.fn().mockResolvedValue(false); + const onClose = jest.fn(); + await render( + , + ); + await fireEvent.press(screen.getByLabelText('Seedbox')); + await waitFor(() => expect(screen.getByText('errors.checkCredentials')).toBeTruthy()); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onManageServers when the footer row is tapped', async () => { + const onManageServers = jest.fn(); + await render( + , + ); + await fireEvent.press(screen.getByText('screens.torrents.manageServers')); + expect(onManageServers).toHaveBeenCalled(); + }); + + it('calls onClose when the backdrop is tapped', async () => { + const onClose = jest.fn(); + await render( + , + ); + await fireEvent.press(screen.getByLabelText('common.close')); + expect(onClose).toHaveBeenCalled(); + }); +});