From 2c0a9b6cd9fe86a3e7303ea6ee586c942674aa7d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Jan 2026 12:12:20 +0100 Subject: [PATCH] feat(tv): migrate login to white design with navigation modals --- .claude/agents/tv-validator.md | 103 ++++++ app/_layout.tsx | 16 + app/tv-account-select-modal.tsx | 159 +++++++++ app/tv-server-action-modal.tsx | 250 +++++++++++++ components/Button.tsx | 2 +- components/login/TVAccountCard.tsx | 5 +- components/login/TVInput.tsx | 15 +- components/login/TVLogin.tsx | 117 +++--- components/login/TVPINEntryModal.tsx | 4 +- components/login/TVPasswordEntryModal.tsx | 16 +- components/login/TVPreviousServersList.tsx | 395 ++++----------------- components/login/TVSaveAccountModal.tsx | 12 +- components/login/TVSaveAccountToggle.tsx | 7 +- components/login/TVServerCard.tsx | 7 +- hooks/useTVAccountSelectModal.ts | 34 ++ hooks/useTVServerActionModal.ts | 29 ++ utils/atoms/tvAccountSelectModal.ts | 14 + utils/atoms/tvServerActionModal.ts | 10 + 18 files changed, 757 insertions(+), 438 deletions(-) create mode 100644 .claude/agents/tv-validator.md create mode 100644 app/tv-account-select-modal.tsx create mode 100644 app/tv-server-action-modal.tsx create mode 100644 hooks/useTVAccountSelectModal.ts create mode 100644 hooks/useTVServerActionModal.ts create mode 100644 utils/atoms/tvAccountSelectModal.ts create mode 100644 utils/atoms/tvServerActionModal.ts diff --git a/.claude/agents/tv-validator.md b/.claude/agents/tv-validator.md new file mode 100644 index 00000000..a38dd751 --- /dev/null +++ b/.claude/agents/tv-validator.md @@ -0,0 +1,103 @@ +--- +name: tv-validator +description: Use this agent to review TV platform code for correct patterns and conventions. Use proactively after writing or modifying TV components. Validates focus handling, modal patterns, typography, list components, and other TV-specific requirements. +tools: Read, Glob, Grep +model: haiku +color: blue +--- + +You are a TV platform code reviewer for Streamyfin, a React Native app with Apple TV and Android TV support. Review code for correct TV patterns and flag violations. + +## Critical Rules to Check + +### 1. No .tv.tsx File Suffix +The `.tv.tsx` suffix does NOT work in this project. Metro bundler doesn't resolve it. + +**Violation**: Creating files like `MyComponent.tv.tsx` expecting auto-resolution +**Correct**: Use `Platform.isTV` conditional rendering in the main file: +```typescript +if (Platform.isTV) { + return ; +} +return ; +``` + +### 2. No FlashList on TV +FlashList has focus issues on TV. Use FlatList instead. + +**Violation**: ` +) : ( + +)} +``` + +### 3. Modal Pattern +Never use overlay/absolute-positioned modals on TV. They break back button handling. + +**Violation**: `position: "absolute"` or `Modal` component for TV overlays +**Correct**: Use navigation-based pattern: +- Create Jotai atom for state +- Hook that sets atom and calls `router.push()` +- Page in `app/(auth)/` that reads atom +- `Stack.Screen` with `presentation: "transparentModal"` + +### 4. Typography +All TV text must use `TVTypography` component. + +**Violation**: Raw `` in TV components +**Correct**: `...` + +### 5. No Purple Accent Colors +TV uses white for focus states, not purple. + +**Violation**: Purple/violet colors in TV focused states +**Correct**: White (`#fff`, `white`) for focused states with `expo-blur` for backgrounds + +### 6. Focus Handling +- Only ONE element should have `hasTVPreferredFocus={true}` +- Focusable items need `disabled={isModalOpen}` when overlays are visible +- Use `onFocus`/`onBlur` with scale animations +- Add padding for scale animations (focus scale clips without it) + +### 7. List Configuration +TV lists need: +- `removeClippedSubviews={false}` +- `overflow: "visible"` on containers +- Sufficient padding for focus scale animations + +### 8. Horizontal Padding +Use `TV_HORIZONTAL_PADDING` constant (60), not old `TV_SCALE_PADDING` (20). + +### 9. Focus Guide Navigation +For non-adjacent sections, use `TVFocusGuideView` with `destinations` prop. +Use `useState` for refs (not `useRef`) to trigger re-renders. + +## Review Process + +1. Read the file(s) to review +2. Check each rule above +3. Report violations with: + - Line number + - What's wrong + - How to fix it +4. If no violations, confirm the code follows TV patterns + +## Output Format + +``` +## TV Validation Results + +### ✓ Passes +- [List of rules that pass] + +### ✗ Violations +- **[Rule Name]** (line X): [Description] + Fix: [How to correct it] + +### Recommendations +- [Optional suggestions for improvement] +``` diff --git a/app/_layout.tsx b/app/_layout.tsx index ad2ca991..9b824133 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -466,6 +466,22 @@ function Layout() { animation: "fade", }} /> + + { + overlayOpacity.setValue(0); + contentScale.setValue(0.9); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(contentScale, { + toValue: 1, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvAccountSelectModalAtom, null); + }; + }, [overlayOpacity, contentScale]); + + const handleClose = () => { + router.back(); + }; + + if (!modalState) { + return null; + } + + return ( + + + + + + {t("server.select_account")} + + + {modalState.server.name || modalState.server.address} + + + {isReady && ( + <> + + {modalState.server.accounts?.map((account, index) => ( + { + modalState.onAccountSelect(account); + router.back(); + }} + onLongPress={() => { + modalState.onDeleteAccount(account); + }} + hasTVPreferredFocus={index === 0} + /> + ))} + + + + + + + + )} + + + + + ); +} diff --git a/app/tv-server-action-modal.tsx b/app/tv-server-action-modal.tsx new file mode 100644 index 00000000..24a9637c --- /dev/null +++ b/app/tv-server-action-modal.tsx @@ -0,0 +1,250 @@ +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Animated, + Easing, + Pressable, + ScrollView, + TVFocusGuideView, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import useRouter from "@/hooks/useAppRouter"; +import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal"; +import { store } from "@/utils/store"; + +// Action card component +const TVServerActionCard: React.FC<{ + label: string; + icon: keyof typeof Ionicons.glyphMap; + variant?: "default" | "destructive"; + hasTVPreferredFocus?: boolean; + onPress: () => void; +}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const typography = useScaledTVTypography(); + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const isDestructive = variant === "destructive"; + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + {label} + + + + ); +}; + +export default function TVServerActionModalPage() { + const typography = useScaledTVTypography(); + const router = useRouter(); + const modalState = useAtomValue(tvServerActionModalAtom); + const { t } = useTranslation(); + + const [isReady, setIsReady] = useState(false); + const overlayOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(200)).current; + + // Animate in on mount + useEffect(() => { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(200); + + Animated.parallel([ + Animated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + + const timer = setTimeout(() => setIsReady(true), 100); + return () => { + clearTimeout(timer); + store.set(tvServerActionModalAtom, null); + }; + }, [overlayOpacity, sheetTranslateY]); + + const handleLogin = () => { + modalState?.onLogin(); + router.back(); + }; + + const handleDelete = () => { + modalState?.onDelete(); + router.back(); + }; + + const handleClose = () => { + router.back(); + }; + + if (!modalState) { + return null; + } + + return ( + + + + + {/* Title */} + + {modalState.server.name || modalState.server.address} + + + {/* Horizontal options */} + {isReady && ( + + + + + + )} + + + + + ); +} diff --git a/components/Button.tsx b/components/Button.tsx index 03e9296d..3b5d6351 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -132,7 +132,7 @@ export const Button: React.FC> = ({ = ({ style={[ { transform: [{ scale }], - shadowColor: "#a855f7", + shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowRadius: 16, elevation: 8, @@ -143,7 +142,7 @@ export const TVAccountCard: React.FC = ({ {/* Security Icon */} - + diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx index 40c2b8d3..2e9435f1 100644 --- a/components/login/TVInput.tsx +++ b/components/login/TVInput.tsx @@ -58,20 +58,25 @@ export const TVInput: React.FC = ({ { const api = useAtomValue(apiAtom); const navigation = useNavigation(); const params = useLocalSearchParams(); + const { showServerActionModal } = useTVServerActionModal(); const { setServer, login, @@ -152,20 +146,13 @@ export const TVLogin: React.FC = () => { const [selectedAccount, setSelectedAccount] = useState(null); - // Server action sheet state - const [showServerActionSheet, setShowServerActionSheet] = useState(false); - const [actionSheetServer, setActionSheetServer] = - useState(null); + // Server login trigger state const [loginTriggerServer, setLoginTriggerServer] = useState(null); - const [actionSheetKey, setActionSheetKey] = useState(0); // Track if any modal is open to disable background focus const isAnyModalOpen = - showSaveModal || - pinModalVisible || - passwordModalVisible || - showServerActionSheet; + showSaveModal || pinModalVisible || passwordModalVisible; // Auto login from URL params useEffect(() => { @@ -319,48 +306,38 @@ export const TVLogin: React.FC = () => { } }; - // Server action sheet handlers + // Server action sheet handler const handleServerAction = (server: SavedServer) => { - setActionSheetServer(server); - setActionSheetKey((k) => k + 1); // Force remount to reset focus - setShowServerActionSheet(true); - }; - - const handleServerActionLogin = () => { - setShowServerActionSheet(false); - if (actionSheetServer) { - // Trigger the login flow in TVPreviousServersList - setLoginTriggerServer(actionSheetServer); - // Reset the trigger after a tick to allow re-triggering the same server - setTimeout(() => setLoginTriggerServer(null), 0); - } - }; - - const handleServerActionDelete = () => { - if (!actionSheetServer) return; - - Alert.alert( - t("server.remove_server"), - t("server.remove_server_description", { - server: actionSheetServer.name || actionSheetServer.address, - }), - [ - { - text: t("common.cancel"), - style: "cancel", - onPress: () => setShowServerActionSheet(false), - }, - { - text: t("common.delete"), - style: "destructive", - onPress: async () => { - await removeServerFromList(actionSheetServer.address); - setShowServerActionSheet(false); - setActionSheetServer(null); - }, - }, - ], - ); + showServerActionModal({ + server, + onLogin: () => { + // Trigger the login flow in TVPreviousServersList + setLoginTriggerServer(server); + // Reset the trigger after a tick to allow re-triggering the same server + setTimeout(() => setLoginTriggerServer(null), 0); + }, + onDelete: () => { + Alert.alert( + t("server.remove_server"), + t("server.remove_server_description", { + server: server.name || server.address, + }), + [ + { + text: t("common.cancel"), + style: "cancel", + }, + { + text: t("common.delete"), + style: "destructive", + onPress: async () => { + await removeServerFromList(server.address); + }, + }, + ], + ); + }, + }); }; const checkUrl = useCallback(async (url: string) => { @@ -493,7 +470,7 @@ export const TVLogin: React.FC = () => { {serverName ? ( <> {`${t("login.login_to_title")} `} - {serverName} + {serverName} ) : ( t("login.login_title") @@ -558,6 +535,7 @@ export const TVLogin: React.FC = () => { onPress={handleLogin} loading={loading} disabled={!credentials.username.trim() || loading} + color='white' > {t("login.login_button")} @@ -595,7 +573,7 @@ export const TVLogin: React.FC = () => { {/* Logo */} @@ -645,6 +623,7 @@ export const TVLogin: React.FC = () => { onPress={() => handleConnect(serverURL)} loading={loadingServerCheck} disabled={loadingServerCheck || !serverURL.trim()} + color='white' > {t("server.connect_button")} @@ -706,16 +685,6 @@ export const TVLogin: React.FC = () => { onSubmit={handlePasswordSubmit} username={selectedAccount?.username || ""} /> - - {/* Server Action Sheet */} - setShowServerActionSheet(false)} - /> ); }; diff --git a/components/login/TVPINEntryModal.tsx b/components/login/TVPINEntryModal.tsx index 25d9ce74..821bf689 100644 --- a/components/login/TVPINEntryModal.tsx +++ b/components/login/TVPINEntryModal.tsx @@ -49,7 +49,7 @@ const TVForgotPINButton: React.FC<{ paddingVertical: 10, borderRadius: 8, backgroundColor: focused - ? "rgba(168, 85, 247, 0.2)" + ? "rgba(255, 255, 255, 0.15)" : "transparent", }, ]} @@ -57,7 +57,7 @@ const TVForgotPINButton: React.FC<{ diff --git a/components/login/TVPasswordEntryModal.tsx b/components/login/TVPasswordEntryModal.tsx index 1473cf86..3e5574a6 100644 --- a/components/login/TVPasswordEntryModal.tsx +++ b/components/login/TVPasswordEntryModal.tsx @@ -47,10 +47,10 @@ const TVSubmitButton: React.FC<{ animatedStyle, { backgroundColor: focused - ? "#a855f7" + ? "#fff" : isDisabled ? "#4a4a4a" - : "#7c3aed", + : "rgba(255,255,255,0.15)", paddingHorizontal: 24, paddingVertical: 14, borderRadius: 10, @@ -64,14 +64,18 @@ const TVSubmitButton: React.FC<{ ]} > {loading ? ( - + ) : ( <> - + @@ -119,7 +123,7 @@ const TVPasswordInput: React.FC<{ backgroundColor: "#1F2937", borderRadius: 12, borderWidth: 2, - borderColor: focused ? "#6366F1" : "#374151", + borderColor: focused ? "#fff" : "#374151", paddingHorizontal: 16, paddingVertical: 14, }, diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx index 1903709e..cc9ad1ff 100644 --- a/components/login/TVPreviousServersList.tsx +++ b/components/login/TVPreviousServersList.tsx @@ -1,210 +1,20 @@ import { Ionicons } from "@expo/vector-icons"; -import { BlurView } from "expo-blur"; import type React from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Alert, - Animated, - Easing, - Modal, - Pressable, - ScrollView, - View, -} from "react-native"; +import { Alert, View } from "react-native"; import { useMMKVString } from "react-native-mmkv"; -import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; +import { useScaledTVTypography } from "@/constants/TVTypography"; +import { useTVAccountSelectModal } from "@/hooks/useTVAccountSelectModal"; import { deleteAccountCredential, getPreviousServers, type SavedServer, type SavedServerAccount, } from "@/utils/secureCredentials"; -import { TVAccountCard } from "./TVAccountCard"; import { TVServerCard } from "./TVServerCard"; -// Action card for server action sheet (Apple TV style) -const TVServerActionCard: React.FC<{ - label: string; - icon: keyof typeof Ionicons.glyphMap; - variant?: "default" | "destructive"; - hasTVPreferredFocus?: boolean; - onPress: () => void; -}> = ({ label, icon, variant = "default", hasTVPreferredFocus, onPress }) => { - const [focused, setFocused] = useState(false); - const scale = useRef(new Animated.Value(1)).current; - - const animateTo = (v: number) => - Animated.timing(scale, { - toValue: v, - duration: 150, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }).start(); - - const isDestructive = variant === "destructive"; - - return ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - - {label} - - - - ); -}; - -// Server action sheet component (bottom sheet with horizontal scrolling) -const TVServerActionSheet: React.FC<{ - visible: boolean; - server: SavedServer | null; - onLogin: () => void; - onDelete: () => void; - onClose: () => void; -}> = ({ visible, server, onLogin, onDelete, onClose }) => { - const { t } = useTranslation(); - - if (!server) return null; - - return ( - - - - - {/* Title */} - - {server.name || server.address} - - - {/* Horizontal options */} - - - - - - - - - - ); -}; - interface TVPreviousServersListProps { onServerSelect: (server: SavedServer) => void; onQuickLogin?: (serverUrl: string, userId: string) => Promise; @@ -227,9 +37,6 @@ interface TVPreviousServersListProps { disabled?: boolean; } -// Export the action sheet for use in parent components -export { TVServerActionSheet }; - export const TVPreviousServersList: React.FC = ({ onServerSelect, onQuickLogin, @@ -241,37 +48,16 @@ export const TVPreviousServersList: React.FC = ({ disabled = false, }) => { const { t } = useTranslation(); + const typography = useScaledTVTypography(); + const { showAccountSelectModal } = useTVAccountSelectModal(); const [_previousServers, setPreviousServers] = useMMKVString("previousServers"); const [loadingServer, setLoadingServer] = useState(null); - const [selectedServer, setSelectedServer] = useState( - null, - ); - const [showAccountsModal, setShowAccountsModal] = useState(false); const previousServers = useMemo(() => { return JSON.parse(_previousServers || "[]") as SavedServer[]; }, [_previousServers]); - // When parent triggers login via loginServerOverride, execute the login flow - useEffect(() => { - if (loginServerOverride) { - const accountCount = loginServerOverride.accounts?.length || 0; - - if (accountCount === 0) { - onServerSelect(loginServerOverride); - } else if (accountCount === 1) { - handleAccountLogin( - loginServerOverride, - loginServerOverride.accounts[0], - ); - } else { - setSelectedServer(loginServerOverride); - setShowAccountsModal(true); - } - } - }, [loginServerOverride]); - const refreshServers = () => { const servers = getPreviousServers(); setPreviousServers(JSON.stringify(servers)); @@ -281,8 +67,6 @@ export const TVPreviousServersList: React.FC = ({ server: SavedServer, account: SavedServerAccount, ) => { - setShowAccountsModal(false); - switch (account.securityType) { case "none": if (onQuickLogin) { @@ -315,6 +99,58 @@ export const TVPreviousServersList: React.FC = ({ } }; + const handleDeleteAccount = async ( + server: SavedServer, + account: SavedServerAccount, + ) => { + Alert.alert( + t("server.remove_saved_login"), + t("server.remove_account_description", { username: account.username }), + [ + { text: t("common.cancel"), style: "cancel" }, + { + text: t("common.remove"), + style: "destructive", + onPress: async () => { + await deleteAccountCredential(server.address, account.userId); + refreshServers(); + }, + }, + ], + ); + }; + + const showAccountSelection = (server: SavedServer) => { + showAccountSelectModal({ + server, + onAccountSelect: (account) => handleAccountLogin(server, account), + onAddAccount: () => { + if (onAddAccount) { + onAddAccount(server); + } + }, + onDeleteAccount: (account) => handleDeleteAccount(server, account), + }); + }; + + // When parent triggers login via loginServerOverride, execute the login flow + useEffect(() => { + if (loginServerOverride) { + const accountCount = loginServerOverride.accounts?.length || 0; + + if (accountCount === 0) { + onServerSelect(loginServerOverride); + } else if (accountCount === 1) { + handleAccountLogin( + loginServerOverride, + loginServerOverride.accounts[0], + ); + } else { + showAccountSelection(loginServerOverride); + } + } + }, [loginServerOverride]); + const handleServerPress = (server: SavedServer) => { if (loadingServer) return; @@ -331,8 +167,7 @@ export const TVPreviousServersList: React.FC = ({ } else if (accountCount === 1) { handleAccountLogin(server, server.accounts[0]); } else { - setSelectedServer(server); - setShowAccountsModal(true); + showAccountSelection(server); } }; @@ -369,39 +204,13 @@ export const TVPreviousServersList: React.FC = ({ } }; - const handleDeleteAccount = async (account: SavedServerAccount) => { - if (!selectedServer) return; - - Alert.alert( - t("server.remove_saved_login"), - t("server.remove_account_description", { username: account.username }), - [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("common.remove"), - style: "destructive", - onPress: async () => { - await deleteAccountCredential( - selectedServer.address, - account.userId, - ); - refreshServers(); - if (selectedServer.accounts.length <= 1) { - setShowAccountsModal(false); - } - }, - }, - ], - ); - }; - if (!previousServers.length) return null; return ( = ({ /> ))} - - {/* TV Account Selection Modal */} - setShowAccountsModal(false)} - > - - - - {t("server.select_account")} - - - {selectedServer?.name || selectedServer?.address} - - - - {selectedServer?.accounts.map((account, index) => ( - - selectedServer && - handleAccountLogin(selectedServer, account) - } - onLongPress={() => handleDeleteAccount(account)} - hasTVPreferredFocus={index === 0} - /> - ))} - - - - - - - - - ); }; diff --git a/components/login/TVSaveAccountModal.tsx b/components/login/TVSaveAccountModal.tsx index a1c7d55c..39f4fb8c 100644 --- a/components/login/TVSaveAccountModal.tsx +++ b/components/login/TVSaveAccountModal.tsx @@ -75,10 +75,10 @@ const TVSaveButton: React.FC<{ animatedStyle, { backgroundColor: focused - ? "#a855f7" + ? "#fff" : disabled ? "#4a4a4a" - : "#7c3aed", + : "rgba(255,255,255,0.15)", paddingHorizontal: 24, paddingVertical: 14, borderRadius: 10, @@ -89,11 +89,15 @@ const TVSaveButton: React.FC<{ }, ]} > - + diff --git a/components/login/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx index 85ccc3f1..fc843256 100644 --- a/components/login/TVSaveAccountToggle.tsx +++ b/components/login/TVSaveAccountToggle.tsx @@ -1,7 +1,6 @@ import React, { useRef, useState } from "react"; import { Animated, Easing, Pressable, View } from "react-native"; import { Text } from "@/components/common/Text"; -import { Colors } from "@/constants/Colors"; interface TVSaveAccountToggleProps { value: boolean; @@ -62,7 +61,7 @@ export const TVSaveAccountToggle: React.FC = ({ style={[ { transform: [{ scale }], - shadowColor: "#a855f7", + shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowRadius: 16, elevation: 8, @@ -97,7 +96,7 @@ export const TVSaveAccountToggle: React.FC = ({ width: 60, height: 34, borderRadius: 17, - backgroundColor: value ? Colors.primary : "#3f3f46", + backgroundColor: value ? "#fff" : "#3f3f46", justifyContent: "center", paddingHorizontal: 3, }} @@ -107,7 +106,7 @@ export const TVSaveAccountToggle: React.FC = ({ width: 28, height: 28, borderRadius: 14, - backgroundColor: "white", + backgroundColor: value ? "#000" : "#fff", alignSelf: value ? "flex-end" : "flex-start", }} /> diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx index b75b91ac..4325cdd6 100644 --- a/components/login/TVServerCard.tsx +++ b/components/login/TVServerCard.tsx @@ -8,7 +8,6 @@ import { View, } from "react-native"; import { Text } from "@/components/common/Text"; -import { Colors } from "@/constants/Colors"; interface TVServerCardProps { title: string; @@ -75,7 +74,7 @@ export const TVServerCard: React.FC = ({ style={[ { transform: [{ scale }], - shadowColor: "#a855f7", + shadowColor: "#fff", shadowOffset: { width: 0, height: 0 }, shadowRadius: 16, elevation: 8, @@ -123,13 +122,13 @@ export const TVServerCard: React.FC = ({ {isLoading ? ( - + ) : securityIcon ? ( void; + onAddAccount: () => void; + onDeleteAccount: (account: SavedServerAccount) => void; +} + +export const useTVAccountSelectModal = () => { + const router = useRouter(); + + const showAccountSelectModal = useCallback( + (params: ShowAccountSelectModalParams) => { + store.set(tvAccountSelectModalAtom, { + server: params.server, + onAccountSelect: params.onAccountSelect, + onAddAccount: params.onAddAccount, + onDeleteAccount: params.onDeleteAccount, + }); + router.push("/tv-account-select-modal"); + }, + [router], + ); + + return { showAccountSelectModal }; +}; diff --git a/hooks/useTVServerActionModal.ts b/hooks/useTVServerActionModal.ts new file mode 100644 index 00000000..f0da43f1 --- /dev/null +++ b/hooks/useTVServerActionModal.ts @@ -0,0 +1,29 @@ +import { useCallback } from "react"; +import useRouter from "@/hooks/useAppRouter"; +import { tvServerActionModalAtom } from "@/utils/atoms/tvServerActionModal"; +import type { SavedServer } from "@/utils/secureCredentials"; +import { store } from "@/utils/store"; + +interface ShowServerActionModalParams { + server: SavedServer; + onLogin: () => void; + onDelete: () => void; +} + +export const useTVServerActionModal = () => { + const router = useRouter(); + + const showServerActionModal = useCallback( + (params: ShowServerActionModalParams) => { + store.set(tvServerActionModalAtom, { + server: params.server, + onLogin: params.onLogin, + onDelete: params.onDelete, + }); + router.push("/tv-server-action-modal"); + }, + [router], + ); + + return { showServerActionModal }; +}; diff --git a/utils/atoms/tvAccountSelectModal.ts b/utils/atoms/tvAccountSelectModal.ts new file mode 100644 index 00000000..3cafa61e --- /dev/null +++ b/utils/atoms/tvAccountSelectModal.ts @@ -0,0 +1,14 @@ +import { atom } from "jotai"; +import type { + SavedServer, + SavedServerAccount, +} from "@/utils/secureCredentials"; + +export type TVAccountSelectModalState = { + server: SavedServer; + onAccountSelect: (account: SavedServerAccount) => void; + onAddAccount: () => void; + onDeleteAccount: (account: SavedServerAccount) => void; +} | null; + +export const tvAccountSelectModalAtom = atom(null); diff --git a/utils/atoms/tvServerActionModal.ts b/utils/atoms/tvServerActionModal.ts new file mode 100644 index 00000000..38d99e83 --- /dev/null +++ b/utils/atoms/tvServerActionModal.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai"; +import type { SavedServer } from "@/utils/secureCredentials"; + +export type TVServerActionModalState = { + server: SavedServer; + onLogin: () => void; + onDelete: () => void; +} | null; + +export const tvServerActionModalAtom = atom(null);