fix: login stuff for tv

This commit is contained in:
Fredrik Burmester
2026-01-16 08:31:37 +01:00
parent c1e12d5898
commit ad5148daad
5 changed files with 819 additions and 0 deletions

View File

@@ -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<TVAccountCardProps> = ({
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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#262626",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
}}
>
{/* Avatar */}
<View
style={{
width: 56,
height: 56,
backgroundColor: "#404040",
borderRadius: 28,
alignItems: "center",
justifyContent: "center",
marginRight: 20,
}}
>
<Ionicons name='person' size={28} color='white' />
</View>
{/* Account Info */}
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{account.username}
</Text>
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
marginTop: 4,
}}
>
{getSecurityText()}
</Text>
</View>
{/* Security Icon */}
<Ionicons name={getSecurityIcon()} size={24} color={Colors.primary} />
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -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<TVInputProps> = ({
label,
hasTVPreferredFocus,
style,
...props
}) => {
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<TextInput>(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 (
<View>
{label && (
<Text
style={{
fontSize: 18,
color: isFocused ? "#FFFFFF" : "#9CA3AF",
marginBottom: 8,
marginLeft: 4,
}}
>
{label}
</Text>
)}
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
}}
>
{/* Outer glow layer - only visible when focused */}
{isFocused && (
<View
style={{
position: "absolute",
top: -4,
left: -4,
right: -4,
bottom: -4,
backgroundColor: "#9334E9",
borderRadius: 20,
opacity: 0.4,
}}
/>
)}
{/* Main input container */}
<View
style={{
backgroundColor: isFocused ? "#3a3a3a" : "#1a1a1a",
borderWidth: 3,
borderColor: isFocused ? "#FFFFFF" : "#333333",
borderRadius: 16,
overflow: "hidden",
}}
>
{/* Inner highlight bar when focused */}
{isFocused && (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "#9334E9",
}}
/>
)}
<TextInput
ref={inputRef}
allowFontScaling={false}
placeholderTextColor={isFocused ? "#AAAAAA" : "#666666"}
style={[
{
height: 68,
fontSize: 26,
fontWeight: "500",
paddingHorizontal: 24,
paddingTop: isFocused ? 6 : 0,
color: "#FFFFFF",
backgroundColor: "transparent",
},
style,
]}
onFocus={handleFocus}
onBlur={handleBlur}
{...props}
/>
</View>
</Animated.View>
</Pressable>
</View>
);
};

View File

@@ -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<void>;
onPasswordLogin?: (
serverUrl: string,
username: string,
password: string,
) => Promise<void>;
onAddAccount?: (server: SavedServer) => void;
onPinRequired?: (server: SavedServer, account: SavedServerAccount) => void;
onPasswordRequired?: (
server: SavedServer,
account: SavedServerAccount,
) => void;
}
export const TVPreviousServersList: React.FC<TVPreviousServersListProps> = ({
onServerSelect,
onQuickLogin,
onAddAccount,
onPinRequired,
onPasswordRequired,
}) => {
const { t } = useTranslation();
const [_previousServers, setPreviousServers] =
useMMKVString("previousServers");
const [loadingServer, setLoadingServer] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
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 (
<View style={{ marginTop: 32 }}>
<Text
style={{
fontSize: 24,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("server.previous_servers")}
</Text>
<View style={{ gap: 12 }}>
{previousServers.map((server) => (
<TVServerCard
key={server.address}
title={server.name || server.address}
subtitle={getServerSubtitle(server)}
securityIcon={getSecurityIcon(server)}
isLoading={loadingServer === server.address}
onPress={() => handleServerPress(server)}
/>
))}
</View>
{/* TV Account Selection Modal */}
<Modal
visible={showAccountsModal}
transparent
animationType='fade'
onRequestClose={() => setShowAccountsModal(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.9)",
justifyContent: "center",
alignItems: "center",
padding: 80,
}}
>
<View
style={{
backgroundColor: "#1a1a1a",
borderRadius: 24,
padding: 40,
width: "100%",
maxWidth: 700,
}}
>
<Text
style={{
fontSize: 32,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("server.select_account")}
</Text>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginBottom: 32,
}}
>
{selectedServer?.name || selectedServer?.address}
</Text>
<View style={{ gap: 12, marginBottom: 24 }}>
{selectedServer?.accounts.map((account, index) => (
<TVAccountCard
key={account.userId}
account={account}
onPress={() =>
selectedServer &&
handleAccountLogin(selectedServer, account)
}
onLongPress={() => handleDeleteAccount(account)}
hasTVPreferredFocus={index === 0}
/>
))}
</View>
<View style={{ gap: 12 }}>
<Button
onPress={() => {
setShowAccountsModal(false);
if (selectedServer && onAddAccount) {
onAddAccount(selectedServer);
}
}}
color='purple'
>
{t("server.add_account")}
</Button>
<Button
onPress={() => setShowAccountsModal(false)}
color='black'
className='bg-neutral-800'
>
{t("common.cancel")}
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
};

View File

@@ -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<TVSaveAccountToggleProps> = ({
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 (
<Pressable
onPress={() => onValueChange(!value)}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Text
style={{
fontSize: 20,
color: "#FFFFFF",
}}
>
{label}
</Text>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: "#3f3f46", true: Colors.primary }}
thumbColor='white'
/>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -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<TVServerCardProps> = ({
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 (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isLoading}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowRadius: 16,
elevation: 8,
},
{ shadowOpacity: glowOpacity },
]}
>
<View
style={{
backgroundColor: isFocused ? "#2a2a2a" : "#1a1a1a",
borderWidth: 2,
borderColor: isFocused ? "#FFFFFF" : "transparent",
borderRadius: 16,
paddingHorizontal: 24,
paddingVertical: 20,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontWeight: "600",
color: "#FFFFFF",
}}
numberOfLines={1}
>
{title}
</Text>
{subtitle && (
<Text
style={{
fontSize: 16,
color: "#9CA3AF",
marginTop: 4,
}}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</View>
<View style={{ marginLeft: 16 }}>
{isLoading ? (
<ActivityIndicator size='small' color={Colors.primary} />
) : securityIcon ? (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Ionicons
name={securityIcon}
size={20}
color={Colors.primary}
style={{ marginRight: 8 }}
/>
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
</View>
) : (
<Ionicons
name='chevron-forward'
size={24}
color={isFocused ? "#FFFFFF" : "#6B7280"}
/>
)}
</View>
</View>
</Animated.View>
</Pressable>
);
};