From ad5148daadb76bac3c45c871103bd942cdcaf3cc Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 08:31:37 +0100 Subject: [PATCH] fix: login stuff for tv --- components/login/TVAccountCard.tsx | 151 +++++++++++ components/login/TVInput.tsx | 136 ++++++++++ components/login/TVPreviousServersList.tsx | 284 +++++++++++++++++++++ components/login/TVSaveAccountToggle.tsx | 100 ++++++++ components/login/TVServerCard.tsx | 148 +++++++++++ 5 files changed, 819 insertions(+) create mode 100644 components/login/TVAccountCard.tsx create mode 100644 components/login/TVInput.tsx create mode 100644 components/login/TVPreviousServersList.tsx create mode 100644 components/login/TVSaveAccountToggle.tsx create mode 100644 components/login/TVServerCard.tsx diff --git a/components/login/TVAccountCard.tsx b/components/login/TVAccountCard.tsx new file mode 100644 index 00000000..2ef2d913 --- /dev/null +++ b/components/login/TVAccountCard.tsx @@ -0,0 +1,151 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Animated, Easing, Pressable, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; +import type { SavedServerAccount } from "@/utils/secureCredentials"; + +interface TVAccountCardProps { + account: SavedServerAccount; + onPress: () => void; + onLongPress?: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVAccountCard: React.FC = ({ + account, + onPress, + onLongPress, + hasTVPreferredFocus, +}) => { + const { t } = useTranslation(); + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.03 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.6 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + const getSecurityIcon = (): keyof typeof Ionicons.glyphMap => { + switch (account.securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + const getSecurityText = (): string => { + switch (account.securityType) { + case "pin": + return t("save_account.pin_code"); + case "password": + return t("save_account.password"); + default: + return t("save_account.no_protection"); + } + }; + + return ( + + + + {/* Avatar */} + + + + + {/* Account Info */} + + + {account.username} + + + {getSecurityText()} + + + + {/* Security Icon */} + + + + + ); +}; diff --git a/components/login/TVInput.tsx b/components/login/TVInput.tsx new file mode 100644 index 00000000..7ecd57f4 --- /dev/null +++ b/components/login/TVInput.tsx @@ -0,0 +1,136 @@ +import React, { useRef, useState } from "react"; +import { + Animated, + Easing, + Pressable, + TextInput, + type TextInputProps, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; + +interface TVInputProps extends TextInputProps { + label?: string; + hasTVPreferredFocus?: boolean; +} + +export const TVInput: React.FC = ({ + label, + hasTVPreferredFocus, + style, + ...props +}) => { + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); + const scale = useRef(new Animated.Value(1)).current; + + const animateFocus = (focused: boolean) => { + Animated.timing(scale, { + toValue: focused ? 1.02 : 1, + duration: 200, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + + {label && ( + + {label} + + )} + inputRef.current?.focus()} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {/* Outer glow layer - only visible when focused */} + {isFocused && ( + + )} + + {/* Main input container */} + + {/* Inner highlight bar when focused */} + {isFocused && ( + + )} + + + + + + + ); +}; diff --git a/components/login/TVPreviousServersList.tsx b/components/login/TVPreviousServersList.tsx new file mode 100644 index 00000000..aa65b5dd --- /dev/null +++ b/components/login/TVPreviousServersList.tsx @@ -0,0 +1,284 @@ +import { Ionicons } from "@expo/vector-icons"; +import type React from "react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Modal, View } from "react-native"; +import { useMMKVString } from "react-native-mmkv"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { + deleteAccountCredential, + getPreviousServers, + type SavedServer, + type SavedServerAccount, +} from "@/utils/secureCredentials"; +import { TVAccountCard } from "./TVAccountCard"; +import { TVServerCard } from "./TVServerCard"; + +interface TVPreviousServersListProps { + onServerSelect: (server: SavedServer) => void; + onQuickLogin?: (serverUrl: string, userId: string) => Promise; + onPasswordLogin?: ( + serverUrl: string, + username: string, + password: string, + ) => Promise; + onAddAccount?: (server: SavedServer) => void; + onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void; + onPasswordRequired?: ( + server: SavedServer, + account: SavedServerAccount, + ) => void; +} + +export const TVPreviousServersList: React.FC = ({ + onServerSelect, + onQuickLogin, + onAddAccount, + onPinRequired, + onPasswordRequired, +}) => { + const { t } = useTranslation(); + 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]); + + const refreshServers = () => { + const servers = getPreviousServers(); + setPreviousServers(JSON.stringify(servers)); + }; + + const handleAccountLogin = async ( + server: SavedServer, + account: SavedServerAccount, + ) => { + setShowAccountsModal(false); + + switch (account.securityType) { + case "none": + if (onQuickLogin) { + setLoadingServer(server.address); + try { + await onQuickLogin(server.address, account.userId); + } catch { + Alert.alert( + t("server.session_expired"), + t("server.please_login_again"), + [{ text: t("common.ok"), onPress: () => onServerSelect(server) }], + ); + } finally { + setLoadingServer(null); + } + } + break; + + case "pin": + if (onPinRequired) { + onPinRequired(server, account); + } + break; + + case "password": + if (onPasswordRequired) { + onPasswordRequired(server, account); + } + break; + } + }; + + const handleServerPress = (server: SavedServer) => { + if (loadingServer) return; + + const accountCount = server.accounts?.length || 0; + + if (accountCount === 0) { + onServerSelect(server); + } else if (accountCount === 1) { + handleAccountLogin(server, server.accounts[0]); + } else { + setSelectedServer(server); + setShowAccountsModal(true); + } + }; + + const getServerSubtitle = (server: SavedServer): string | undefined => { + const accountCount = server.accounts?.length || 0; + + if (accountCount > 1) { + return t("server.accounts_count", { count: accountCount }); + } + if (accountCount === 1) { + return `${server.accounts[0].username} • ${t("server.saved")}`; + } + return server.name ? server.address : undefined; + }; + + const getSecurityIcon = ( + server: SavedServer, + ): keyof typeof Ionicons.glyphMap | null => { + const accountCount = server.accounts?.length || 0; + if (accountCount === 0) return null; + + if (accountCount > 1) { + return "people"; + } + + const account = server.accounts[0]; + switch (account.securityType) { + case "pin": + return "keypad"; + case "password": + return "lock-closed"; + default: + return "key"; + } + }; + + 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 ( + + + {t("server.previous_servers")} + + + + {previousServers.map((server) => ( + handleServerPress(server)} + /> + ))} + + + {/* 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/TVSaveAccountToggle.tsx b/components/login/TVSaveAccountToggle.tsx new file mode 100644 index 00000000..5d07bb8d --- /dev/null +++ b/components/login/TVSaveAccountToggle.tsx @@ -0,0 +1,100 @@ +import React, { useRef, useState } from "react"; +import { Animated, Easing, Pressable, Switch, View } from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; + +interface TVSaveAccountToggleProps { + value: boolean; + onValueChange: (value: boolean) => void; + label: string; + hasTVPreferredFocus?: boolean; +} + +export const TVSaveAccountToggle: React.FC = ({ + value, + onValueChange, + label, + hasTVPreferredFocus, +}) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.03 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.6 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + onValueChange(!value)} + onFocus={handleFocus} + onBlur={handleBlur} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + + {label} + + + + + + ); +}; diff --git a/components/login/TVServerCard.tsx b/components/login/TVServerCard.tsx new file mode 100644 index 00000000..7178e869 --- /dev/null +++ b/components/login/TVServerCard.tsx @@ -0,0 +1,148 @@ +import { Ionicons } from "@expo/vector-icons"; +import React, { useRef, useState } from "react"; +import { + ActivityIndicator, + Animated, + Easing, + Pressable, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { Colors } from "@/constants/Colors"; + +interface TVServerCardProps { + title: string; + subtitle?: string; + securityIcon?: keyof typeof Ionicons.glyphMap | null; + isLoading?: boolean; + onPress: () => void; + hasTVPreferredFocus?: boolean; +} + +export const TVServerCard: React.FC = ({ + title, + subtitle, + securityIcon, + isLoading, + onPress, + hasTVPreferredFocus, +}) => { + const [isFocused, setIsFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + const glowOpacity = useRef(new Animated.Value(0)).current; + + const animateFocus = (focused: boolean) => { + Animated.parallel([ + Animated.timing(scale, { + toValue: focused ? 1.05 : 1, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(glowOpacity, { + toValue: focused ? 0.7 : 0, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(); + }; + + const handleFocus = () => { + setIsFocused(true); + animateFocus(true); + }; + + const handleBlur = () => { + setIsFocused(false); + animateFocus(false); + }; + + return ( + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + + {isLoading ? ( + + ) : securityIcon ? ( + + + + + ) : ( + + )} + + + + + ); +};