From 60ca025ecefd39b2f1ecd10d0a3b051b0485b6c9 Mon Sep 17 00:00:00 2001 From: albyalex96 Date: Thu, 5 Mar 2026 19:29:09 +0100 Subject: [PATCH] Improved Localization in AI Screens --- src/i18n/locales/en.json | 12 +- src/i18n/locales/it.json | 26 +- src/screens/AIChatScreen.tsx | 2543 ++++++++++++++++-------------- src/screens/AISettingsScreen.tsx | 1330 +++++++++------- 4 files changed, 2172 insertions(+), 1739 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 99b00f22..0f5c4bc5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -14,6 +14,7 @@ "try_again": "Try Again", "go_back": "Go Back", "settings": "Settings", + "remove":"Remove", "close": "Close", "enable": "Enable", "disable": "Disable", @@ -926,7 +927,11 @@ "confirm_remove_title": "Remove API Key", "confirm_remove_msg": "Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.", "success_removed": "API key removed successfully", - "error_remove": "Failed to remove API key" + "error_remove": "Failed to remove API key", + "model":"Model", + "using": "Using", + "free_routing":"(free automatic routing)", + "paid_routing":"Use a custom OpenRouter model ID (useful for paid plans)." }, "catalog_settings": { "title": "Catalogs", @@ -1523,5 +1528,8 @@ "user_id": "User ID", "display_name": "Display Name", "display_name_placeholder": "Add a display name" - } + }, + "ai_chat_screen":{ + "loading":"Loading AI context..." + } } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c1bd75c1..353916df 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -15,6 +15,7 @@ "go_back": "Torna indietro", "settings": "Impostazioni", "close": "Chiudi", + "remove":"Rimuovi", "enable": "Abilita", "disable": "Disabilita", "show_more": "Mostra altro", @@ -510,7 +511,7 @@ "overview": "PANORAMICA", "no_categories": "Nessuna categoria", "pre_installed": "PRE-INSTALLATO", - "already_installed":"Questo addon è già installato. Installazioni multiple sono permesse solo per addon che aggiungono provider di flussi video." + "already_installed": "Questo addon è già installato. Installazioni multiple sono permesse solo per addon che aggiungono provider di flussi video." }, "trakt": { "title": "Impostazioni Trakt", @@ -946,7 +947,11 @@ "confirm_remove_title": "Rimuovi Chiave API", "confirm_remove_msg": "Sei sicuro di voler rimuovere la tua chiave API OpenRouter? Questo disabiliterà le funzioni di chat IA.", "success_removed": "Chiave API rimossa con successo", - "error_remove": "Impossibile rimuovere la chiave API" + "error_remove": "Impossibile rimuovere la chiave API", + "model":"Modello", + "using":"Usando", + "free_routing":"(Routing automatico gratuito)", + "paid_routing":"Usa un ID di modello Openrouter (utile per piani a pagamento)." }, "catalog_settings": { "title": "Cataloghi", @@ -1537,11 +1542,14 @@ "no_logs_captured": "Nessun log catturato." } }, - "account_manager":{ - "sign_out":"Esci", - "sign_out_desc":"", - "user_id":"ID Utente", - "display_name":"Nickname", - "display_name_placeholder":"Aggiungi un nickname" - } + "account_manager": { + "sign_out": "Esci", + "sign_out_desc": "", + "user_id": "ID Utente", + "display_name": "Nickname", + "display_name_placeholder": "Aggiungi un nickname" + }, + "ai_chat_screen": { + "loading": "Caricamento contesto AI in corso..." + } } diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx index a669bb5c..c51106a4 100644 --- a/src/screens/AIChatScreen.tsx +++ b/src/screens/AIChatScreen.tsx @@ -1,20 +1,31 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, +} from 'react'; import { - View, - Text, - StyleSheet, - TextInput, - TouchableOpacity, - ScrollView, - StatusBar, - KeyboardAvoidingView, - Platform, - Dimensions, - ActivityIndicator, - Keyboard, + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + ScrollView, + StatusBar, + KeyboardAvoidingView, + Platform, + Dimensions, + ActivityIndicator, + Keyboard, } from 'react-native'; import CustomAlert from '../components/CustomAlert'; -import { useRoute, useNavigation, RouteProp, useFocusEffect } from '@react-navigation/native'; +import { + useRoute, + useNavigation, + RouteProp, + useFocusEffect, +} from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import FastImage from '@d11/react-native-fast-image'; @@ -23,1232 +34,1482 @@ import { LinearGradient } from 'expo-linear-gradient'; // Lazy-safe community blur import (avoid bundling issues on web) let AndroidBlurView: any = null; if (Platform.OS === 'android') { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - AndroidBlurView = require('@react-native-community/blur').BlurView; - } catch (_) { - AndroidBlurView = null; - } + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + AndroidBlurView = require('@react-native-community/blur').BlurView; + } catch (_) { + AndroidBlurView = null; + } } // Optional iOS Glass effect (expo-glass-effect) with safe fallback for AIChatScreen let GlassViewComp: any = null; let liquidGlassAvailable = false; if (Platform.OS === 'ios') { - try { - // Dynamically require so app still runs if the package isn't installed yet - const glass = require('expo-glass-effect'); - GlassViewComp = glass.GlassView; - liquidGlassAvailable = typeof glass.isLiquidGlassAvailable === 'function' ? glass.isLiquidGlassAvailable() : false; - } catch { - GlassViewComp = null; - liquidGlassAvailable = false; - } + try { + // Dynamically require so app still runs if the package isn't installed yet + const glass = require('expo-glass-effect'); + GlassViewComp = glass.GlassView; + liquidGlassAvailable = + typeof glass.isLiquidGlassAvailable === 'function' + ? glass.isLiquidGlassAvailable() + : false; + } catch { + GlassViewComp = null; + liquidGlassAvailable = false; + } } -import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; -import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, createSeriesContext, generateConversationStarters } from '../services/aiService'; +import { + useSafeAreaInsets, + SafeAreaView, +} from 'react-native-safe-area-context'; +import { + aiService, + ChatMessage, + ContentContext, + createMovieContext, + createEpisodeContext, + createSeriesContext, + generateConversationStarters, +} from '../services/aiService'; import { tmdbService } from '../services/tmdbService'; import Markdown from 'react-native-markdown-display'; import Animated, { - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming, - withRepeat, - withSequence, - withDelay, - interpolate, - Extrapolate, - runOnJS + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, + withRepeat, + withSequence, + withDelay, + interpolate, + Extrapolate, + runOnJS, } from 'react-native-reanimated'; +import { useTranslation } from 'react-i18next'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; type AIChatRouteParams = { - AIChat: { - contentId: string; - contentType: 'movie' | 'series'; - episodeId?: string; - seasonNumber?: number; - episodeNumber?: number; - title: string; - }; + AIChat: { + contentId: string; + contentType: 'movie' | 'series'; + episodeId?: string; + seasonNumber?: number; + episodeNumber?: number; + title: string; + }; }; type AIChatScreenRouteProp = RouteProp; interface ChatBubbleProps { - message: ChatMessage; - isLast: boolean; + message: ChatMessage; + isLast: boolean; } // Animated typing dot component -const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color }) => { - const opacity = useSharedValue(0.3); - const scale = useSharedValue(1); +const TypingDot: React.FC<{ delay: number; color: string }> = ({ + delay, + color, +}) => { + const opacity = useSharedValue(0.3); + const scale = useSharedValue(1); - useEffect(() => { - opacity.value = withDelay( - delay, - withRepeat( - withSequence( - withTiming(1, { duration: 400 }), - withTiming(0.3, { duration: 400 }) - ), - -1, - false - ) - ); - scale.value = withDelay( - delay, - withRepeat( - withSequence( - withTiming(1.2, { duration: 400 }), - withTiming(1, { duration: 400 }) - ), - -1, - false - ) - ); - }, []); + useEffect(() => { + opacity.value = withDelay( + delay, + withRepeat( + withSequence( + withTiming(1, { duration: 400 }), + withTiming(0.3, { duration: 400 }), + ), + -1, + false, + ), + ); + scale.value = withDelay( + delay, + withRepeat( + withSequence( + withTiming(1.2, { duration: 400 }), + withTiming(1, { duration: 400 }), + ), + -1, + false, + ), + ); + }, []); - const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - transform: [{ scale: scale.value }], - })); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }], + })); - return ( - - ); + return ( + + ); }; -const ChatBubble: React.FC = React.memo(({ message, isLast }) => { - const { currentTheme } = useTheme(); - const isUser = message.role === 'user'; +const ChatBubble: React.FC = React.memo( + ({ message, isLast }) => { + const { currentTheme } = useTheme(); + const isUser = message.role === 'user'; - const bubbleAnimation = useSharedValue(0); + const bubbleAnimation = useSharedValue(0); - useEffect(() => { - bubbleAnimation.value = withSpring(1, { damping: 18, stiffness: 100 }); - }, []); + useEffect(() => { + bubbleAnimation.value = withSpring(1, { damping: 18, stiffness: 100 }); + }, []); - const animatedStyle = useAnimatedStyle(() => ({ - opacity: bubbleAnimation.value, - transform: [ - { - translateY: interpolate( - bubbleAnimation.value, - [0, 1], - [16, 0], - Extrapolate.CLAMP - ) - }, - { - scale: interpolate( - bubbleAnimation.value, - [0, 1], - [0.95, 1], - Extrapolate.CLAMP - ) - } - ] - })); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: bubbleAnimation.value, + transform: [ + { + translateY: interpolate( + bubbleAnimation.value, + [0, 1], + [16, 0], + Extrapolate.CLAMP, + ), + }, + { + scale: interpolate( + bubbleAnimation.value, + [0, 1], + [0.95, 1], + Extrapolate.CLAMP, + ), + }, + ], + })); - return ( - - {!isUser && ( - - - - - - )} + return ( + + {!isUser && ( + + + + + + )} - - {!isUser && ( - - {Platform.OS === 'android' && AndroidBlurView - ? - : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable - ? - : } - - - )} - {isUser ? ( - - {message.content} - - ) : ( - - {message.content} - - )} - - {new Date(message.timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })} - - + + {!isUser && ( + + {Platform.OS === 'android' && AndroidBlurView ? ( + + ) : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + )} + + + )} + {isUser ? ( + + {message.content} + + ) : ( + + {message.content} + + )} + + {new Date(message.timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + - {isUser && ( - - - - )} - - ); -}, (prev, next) => { - return ( - prev.isLast === next.isLast && - prev.message.id === next.message.id && - prev.message.role === next.message.role && - prev.message.content === next.message.content && - prev.message.timestamp === next.message.timestamp - ); -}); + {isUser && ( + + + + )} + + ); + }, + (prev, next) => { + return ( + prev.isLast === next.isLast && + prev.message.id === next.message.id && + prev.message.role === next.message.role && + prev.message.content === next.message.content && + prev.message.timestamp === next.message.timestamp + ); + }, +); interface SuggestionChipProps { - text: string; - onPress: () => void; - index: number; + text: string; + onPress: () => void; + index: number; } -const SuggestionChip: React.FC = React.memo(({ text, onPress, index }) => { - const { currentTheme } = useTheme(); - const animValue = useSharedValue(0); +const SuggestionChip: React.FC = React.memo( + ({ text, onPress, index }) => { + const { currentTheme } = useTheme(); + const animValue = useSharedValue(0); - useEffect(() => { - animValue.value = withDelay( - index * 80, - withSpring(1, { damping: 18, stiffness: 120 }) - ); - }, []); + useEffect(() => { + animValue.value = withDelay( + index * 80, + withSpring(1, { damping: 18, stiffness: 120 }), + ); + }, []); - const animatedStyle = useAnimatedStyle(() => ({ - opacity: animValue.value, - transform: [ - { translateY: interpolate(animValue.value, [0, 1], [12, 0], Extrapolate.CLAMP) }, - { scale: interpolate(animValue.value, [0, 1], [0.95, 1], Extrapolate.CLAMP) }, - ], - })); + const animatedStyle = useAnimatedStyle(() => ({ + opacity: animValue.value, + transform: [ + { + translateY: interpolate( + animValue.value, + [0, 1], + [12, 0], + Extrapolate.CLAMP, + ), + }, + { + scale: interpolate(animValue.value, [0, 1], [0.95, 1], Extrapolate.CLAMP), + }, + ], + })); - return ( - - - - - {text} - - - - - ); -}, (prev, next) => prev.text === next.text && prev.onPress === next.onPress && prev.index === next.index); + return ( + + + + + {text} + + + + + ); + }, + (prev, next) => + prev.text === next.text && + prev.onPress === next.onPress && + prev.index === next.index, +); const AIChatScreen: React.FC = () => { - // CustomAlert state - const [alertVisible, setAlertVisible] = useState(false); - const [alertTitle, setAlertTitle] = useState(''); - const [alertMessage, setAlertMessage] = useState(''); - const [alertActions, setAlertActions] = useState void; style?: object }>>([ - { label: 'OK', onPress: () => setAlertVisible(false) }, - ]); + // CustomAlert state + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const {t } = useTranslation(); + const [alertActions, setAlertActions] = useState< + Array<{ label: string; onPress: () => void; style?: object }> + >([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - const openAlert = ( - title: string, - message: string, - actions?: Array<{ label: string; onPress?: () => void; style?: object }> - ) => { - setAlertTitle(title); - setAlertMessage(message); - if (actions && actions.length > 0) { - setAlertActions( - actions.map(a => ({ - label: a.label, - style: a.style, - onPress: () => { a.onPress?.(); setAlertVisible(false); }, - })) - ); - } else { - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - } - setAlertVisible(true); - }; - const route = useRoute(); - const navigation = useNavigation(); - const { currentTheme } = useTheme(); - const insets = useSafeAreaInsets(); + const openAlert = ( + title: string, + message: string, + actions?: Array<{ label: string; onPress?: () => void; style?: object }>, + ) => { + setAlertTitle(title); + setAlertMessage(message); + if (actions && actions.length > 0) { + setAlertActions( + actions.map((a) => ({ + label: a.label, + style: a.style, + onPress: () => { + a.onPress?.(); + setAlertVisible(false); + }, + })), + ); + } else { + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + } + setAlertVisible(true); + }; + const route = useRoute(); + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); - const { contentId, contentType, episodeId, seasonNumber, episodeNumber, title } = route.params; + const { + contentId, + contentType, + episodeId, + seasonNumber, + episodeNumber, + title, + } = route.params; - const [messages, setMessages] = useState([]); - const [inputText, setInputText] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [context, setContext] = useState(null); - const [isLoadingContext, setIsLoadingContext] = useState(true); - const [suggestions, setSuggestions] = useState([]); - const [backdropUrl, setBackdropUrl] = useState(null); - const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [messages, setMessages] = useState([]); + const [inputText, setInputText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [context, setContext] = useState(null); + const [isLoadingContext, setIsLoadingContext] = useState(true); + const [suggestions, setSuggestions] = useState([]); + const [backdropUrl, setBackdropUrl] = useState(null); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); - // Ensure Android cleans up heavy image resources when leaving the screen to avoid flash on back - useFocusEffect( - React.useCallback(() => { - return () => { - if (Platform.OS === 'android') { - setBackdropUrl(null); - } - }; - }, []) - ); + // Ensure Android cleans up heavy image resources when leaving the screen to avoid flash on back + useFocusEffect( + React.useCallback(() => { + return () => { + if (Platform.OS === 'android') { + setBackdropUrl(null); + } + }; + }, []), + ); - const scrollViewRef = useRef(null); - const inputRef = useRef(null); + const scrollViewRef = useRef(null); + const inputRef = useRef(null); - // Animation values - const headerOpacity = useSharedValue(1); - const inputContainerY = useSharedValue(0); - // Android full-screen modal fade - const modalOpacity = useSharedValue(Platform.OS === 'android' ? 0 : 1); + // Animation values + const headerOpacity = useSharedValue(1); + const inputContainerY = useSharedValue(0); + // Android full-screen modal fade + const modalOpacity = useSharedValue(Platform.OS === 'android' ? 0 : 1); - useEffect(() => { - loadContext(); - }, []); + useEffect(() => { + loadContext(); + }, []); - // Track keyboard and animate input to avoid gaps on iOS - useEffect(() => { - const onShow = (e: any) => { - setIsKeyboardVisible(true); - if (Platform.OS === 'ios') { - const kbHeight = e?.endCoordinates?.height ?? 0; - const lift = Math.max(0, kbHeight - insets.bottom); - inputContainerY.value = withTiming(-lift, { duration: 220 }); - } - }; - const onHide = () => { - setIsKeyboardVisible(false); - if (Platform.OS === 'ios') { - inputContainerY.value = withTiming(0, { duration: 220 }); - } - }; + // Track keyboard and animate input to avoid gaps on iOS + useEffect(() => { + const onShow = (e: any) => { + setIsKeyboardVisible(true); + if (Platform.OS === 'ios') { + const kbHeight = e?.endCoordinates?.height ?? 0; + const lift = Math.max(0, kbHeight - insets.bottom); + inputContainerY.value = withTiming(-lift, { duration: 220 }); + } + }; + const onHide = () => { + setIsKeyboardVisible(false); + if (Platform.OS === 'ios') { + inputContainerY.value = withTiming(0, { duration: 220 }); + } + }; - const showSub = Platform.OS === 'ios' - ? Keyboard.addListener('keyboardWillShow', onShow) - : Keyboard.addListener('keyboardDidShow', onShow); - const hideSub = Platform.OS === 'ios' - ? Keyboard.addListener('keyboardWillHide', onHide) - : Keyboard.addListener('keyboardDidHide', onHide); + const showSub = + Platform.OS === 'ios' + ? Keyboard.addListener('keyboardWillShow', onShow) + : Keyboard.addListener('keyboardDidShow', onShow); + const hideSub = + Platform.OS === 'ios' + ? Keyboard.addListener('keyboardWillHide', onHide) + : Keyboard.addListener('keyboardDidHide', onHide); - return () => { - showSub.remove(); - hideSub.remove(); - }; - }, [insets.bottom, inputContainerY]); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, [insets.bottom, inputContainerY]); - // Animate in on Android for full-screen modal feel - useEffect(() => { - if (Platform.OS === 'android') { - // Use spring to avoid jank on some devices - modalOpacity.value = withSpring(1, { damping: 20, stiffness: 140 }); - } - }, [modalOpacity]); + // Animate in on Android for full-screen modal feel + useEffect(() => { + if (Platform.OS === 'android') { + // Use spring to avoid jank on some devices + modalOpacity.value = withSpring(1, { damping: 20, stiffness: 140 }); + } + }, [modalOpacity]); - useEffect(() => { - if (context && messages.length === 0) { - // Generate conversation starters - const starters = generateConversationStarters(context); - setSuggestions(starters); - } - }, [context, messages.length]); + useEffect(() => { + if (context && messages.length === 0) { + // Generate conversation starters + const starters = generateConversationStarters(context); + setSuggestions(starters); + } + }, [context, messages.length]); - const loadContext = async () => { - try { - setIsLoadingContext(true); + const loadContext = async () => { + try { + setIsLoadingContext(true); - if (contentType === 'movie') { - // Movies: contentId may be TMDB id string or IMDb id (tt...) - let movieData = await tmdbService.getMovieDetails(contentId); - if (!movieData) { - // Try resolve TMDB id from IMDb id - const tmdbId = await tmdbService.findTMDBIdByIMDB(contentId); - if (tmdbId) { - movieData = await tmdbService.getMovieDetails(String(tmdbId)); - } - } + if (contentType === 'movie') { + // Movies: contentId may be TMDB id string or IMDb id (tt...) + let movieData = await tmdbService.getMovieDetails(contentId); + if (!movieData) { + // Try resolve TMDB id from IMDb id + const tmdbId = await tmdbService.findTMDBIdByIMDB(contentId); + if (tmdbId) { + movieData = await tmdbService.getMovieDetails(String(tmdbId)); + } + } - if (!movieData) throw new Error('Unable to load movie details'); + if (!movieData) throw new Error('Unable to load movie details'); - const movieContext = createMovieContext(movieData); - setContext(movieContext); - try { - const path = movieData.backdrop_path || movieData.poster_path || null; - if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); - } catch { } - } else { - // Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id) - let tmdbNumericId: number | null = null; - if (/^\d+$/.test(contentId)) { - tmdbNumericId = parseInt(contentId, 10); - } else { - // Try to resolve from IMDb id or stremio-like id - tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); - if (!tmdbNumericId && episodeId) { - tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId); - } - } + const movieContext = createMovieContext(movieData); + setContext(movieContext); + try { + const path = movieData.backdrop_path || movieData.poster_path || null; + if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); + } catch {} + } else { + // Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id) + let tmdbNumericId: number | null = null; + if (/^\d+$/.test(contentId)) { + tmdbNumericId = parseInt(contentId, 10); + } else { + // Try to resolve from IMDb id or stremio-like id + tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); + if (!tmdbNumericId && episodeId) { + tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId); + } + } - if (!tmdbNumericId) throw new Error('Unable to resolve TMDB ID for series'); + if (!tmdbNumericId) throw new Error('Unable to resolve TMDB ID for series'); - const [showData, allEpisodes] = await Promise.all([ - tmdbService.getTVShowDetails(tmdbNumericId), - tmdbService.getAllEpisodes(tmdbNumericId) - ]); + const [showData, allEpisodes] = await Promise.all([ + tmdbService.getTVShowDetails(tmdbNumericId), + tmdbService.getAllEpisodes(tmdbNumericId), + ]); - if (!showData) throw new Error('Unable to load TV show details'); - try { - const path = showData.backdrop_path || showData.poster_path || null; - if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); - } catch { } + if (!showData) throw new Error('Unable to load TV show details'); + try { + const path = showData.backdrop_path || showData.poster_path || null; + if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); + } catch {} - if (!showData) throw new Error('Unable to load TV show details'); - const seriesContext = createSeriesContext(showData, allEpisodes || {}); - setContext(seriesContext); - } - } catch (error) { - if (__DEV__) console.error('Error loading context:', error); - openAlert('Error', 'Failed to load content details for AI chat'); - } finally { - setIsLoadingContext(false); - {/* CustomAlert at root */ } - setAlertVisible(false)} - actions={alertActions} - /> - } - }; + if (!showData) throw new Error('Unable to load TV show details'); + const seriesContext = createSeriesContext(showData, allEpisodes || {}); + setContext(seriesContext); + } + } catch (error) { + if (__DEV__) console.error('Error loading context:', error); + openAlert('Error', 'Failed to load content details for AI chat'); + } finally { + setIsLoadingContext(false); + { + /* CustomAlert at root */ + } + setAlertVisible(false)} + actions={alertActions} + />; + } + }; - const sendMessage = useCallback(async (messageText: string) => { - if (!messageText.trim() || !context || isLoading) return; + const sendMessage = useCallback( + async (messageText: string) => { + if (!messageText.trim() || !context || isLoading) return; - const userMessage: ChatMessage = { - id: Date.now().toString(), - role: 'user', - content: messageText.trim(), - timestamp: Date.now(), - }; + const userMessage: ChatMessage = { + id: Date.now().toString(), + role: 'user', + content: messageText.trim(), + timestamp: Date.now(), + }; - setMessages(prev => [...prev, userMessage]); - setInputText(''); - setIsLoading(true); - setSuggestions([]); // Hide suggestions after first message + setMessages((prev) => [...prev, userMessage]); + setInputText(''); + setIsLoading(true); + setSuggestions([]); // Hide suggestions after first message - try { - // If series overview is loaded, parse user query for specific episode and fetch on-demand - let requestContext = context; - if ('episodesBySeason' in (context as any)) { - // Series-wide context; optionally detect SxE patterns to focus answer, but keep series context - const sxe = messageText.match(/s(\d+)e(\d+)/i) || messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i); - if (sxe) { - // We will append a brief hint to the user question to scope, but still pass series context - messageText = `${messageText} (about Season ${sxe[1]}, Episode ${sxe[2]})`; - } - } else if ('showTitle' in (context as any)) { - const sxe = messageText.match(/s(\d+)e(\d+)/i); - const words = messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i); - const seasonOnly = messageText.match(/s(\d+)(?!e)/i) || messageText.match(/season\s+(\d+)/i); + try { + // If series overview is loaded, parse user query for specific episode and fetch on-demand + let requestContext = context; + if ('episodesBySeason' in (context as any)) { + // Series-wide context; optionally detect SxE patterns to focus answer, but keep series context + const sxe = + messageText.match(/s(\d+)e(\d+)/i) || + messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i); + if (sxe) { + // We will append a brief hint to the user question to scope, but still pass series context + messageText = `${messageText} (about Season ${sxe[1]}, Episode ${sxe[2]})`; + } + } else if ('showTitle' in (context as any)) { + const sxe = messageText.match(/s(\d+)e(\d+)/i); + const words = messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i); + const seasonOnly = + messageText.match(/s(\d+)(?!e)/i) || messageText.match(/season\s+(\d+)/i); - let season = sxe ? parseInt(sxe[1], 10) : (words ? parseInt(words[1], 10) : undefined); - let episode = sxe ? parseInt(sxe[2], 10) : (words ? parseInt(words[2], 10) : undefined); + let season = sxe + ? parseInt(sxe[1], 10) + : words + ? parseInt(words[1], 10) + : undefined; + let episode = sxe + ? parseInt(sxe[2], 10) + : words + ? parseInt(words[2], 10) + : undefined; - // If only season mentioned (like "s2" or "season 2"), default to episode 1 - if (!season && seasonOnly) { - season = parseInt(seasonOnly[1], 10); - episode = 1; - } + // If only season mentioned (like "s2" or "season 2"), default to episode 1 + if (!season && seasonOnly) { + season = parseInt(seasonOnly[1], 10); + episode = 1; + } - if (season && episode) { - try { - // Resolve TMDB id for the show - let tmdbNumericId: number | null = null; - if (/^\d+$/.test(contentId)) { - tmdbNumericId = parseInt(contentId, 10); - } else { - tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); - if (!tmdbNumericId && episodeId) { - tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId); - } - } - if (tmdbNumericId) { - const [showData, episodeData] = await Promise.all([ - tmdbService.getTVShowDetails(tmdbNumericId), - tmdbService.getEpisodeDetails(tmdbNumericId, season, episode) - ]); - if (showData && episodeData) { - requestContext = createEpisodeContext(episodeData, showData, season, episode); - } - } - } catch { } - } - } + if (season && episode) { + try { + // Resolve TMDB id for the show + let tmdbNumericId: number | null = null; + if (/^\d+$/.test(contentId)) { + tmdbNumericId = parseInt(contentId, 10); + } else { + tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); + if (!tmdbNumericId && episodeId) { + tmdbNumericId = + await tmdbService.extractTMDBIdFromStremioId(episodeId); + } + } + if (tmdbNumericId) { + const [showData, episodeData] = await Promise.all([ + tmdbService.getTVShowDetails(tmdbNumericId), + tmdbService.getEpisodeDetails(tmdbNumericId, season, episode), + ]); + if (showData && episodeData) { + requestContext = createEpisodeContext( + episodeData, + showData, + season, + episode, + ); + } + } + } catch {} + } + } - const response = await aiService.sendMessage( - messageText.trim(), - requestContext, - messages - ); + const response = await aiService.sendMessage( + messageText.trim(), + requestContext, + messages, + ); - const assistantMessage: ChatMessage = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: response, - timestamp: Date.now(), - }; + const assistantMessage: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: response, + timestamp: Date.now(), + }; - setMessages(prev => [...prev, assistantMessage]); - } catch (error) { - if (__DEV__) console.error('Error sending message:', error); + setMessages((prev) => [...prev, assistantMessage]); + } catch (error) { + if (__DEV__) console.error('Error sending message:', error); - let errorMessage = 'Sorry, I encountered an error. Please try again.'; - if (error instanceof Error) { - if (error.message.includes('not configured')) { - errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.'; - } else if (/401|unauthorized|invalid api key|authentication/i.test(error.message)) { - errorMessage = 'OpenRouter rejected your API key. Please verify the key in Settings > AI Assistant.'; - } else if (/insufficient|credit|quota|429/i.test(error.message)) { - errorMessage = 'OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.'; - } else if (/model|provider|endpoint|unsupported|unavailable|not found/i.test(error.message)) { - errorMessage = 'The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.'; - } else if (error.message.includes('API request failed')) { - errorMessage = 'Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability.'; - } - } + let errorMessage = 'Sorry, I encountered an error. Please try again.'; + if (error instanceof Error) { + if (error.message.includes('not configured')) { + errorMessage = + 'Please configure your OpenRouter API key in Settings > AI Assistant.'; + } else if ( + /401|unauthorized|invalid api key|authentication/i.test(error.message) + ) { + errorMessage = + 'OpenRouter rejected your API key. Please verify the key in Settings > AI Assistant.'; + } else if (/insufficient|credit|quota|429/i.test(error.message)) { + errorMessage = + 'OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.'; + } else if ( + /model|provider|endpoint|unsupported|unavailable|not found/i.test( + error.message, + ) + ) { + errorMessage = + 'The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.'; + } else if (error.message.includes('API request failed')) { + errorMessage = + 'Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability.'; + } + } - const errorResponse: ChatMessage = { - id: (Date.now() + 1).toString(), - role: 'assistant', - content: errorMessage, - timestamp: Date.now(), - }; + const errorResponse: ChatMessage = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: errorMessage, + timestamp: Date.now(), + }; - setMessages(prev => [...prev, errorResponse]); - } finally { - setIsLoading(false); - } - }, [context, messages, isLoading]); + setMessages((prev) => [...prev, errorResponse]); + } finally { + setIsLoading(false); + } + }, + [context, messages, isLoading], + ); - const handleSendPress = useCallback(() => { - sendMessage(inputText); - }, [inputText, sendMessage]); + const handleSendPress = useCallback(() => { + sendMessage(inputText); + }, [inputText, sendMessage]); - const handleSuggestionPress = useCallback((suggestion: string) => { - sendMessage(suggestion); - }, [sendMessage]); + const handleSuggestionPress = useCallback( + (suggestion: string) => { + sendMessage(suggestion); + }, + [sendMessage], + ); - const scrollToBottom = useCallback(() => { - setTimeout(() => { - scrollViewRef.current?.scrollToEnd({ animated: true }); - }, 100); - }, []); + const scrollToBottom = useCallback(() => { + setTimeout(() => { + scrollViewRef.current?.scrollToEnd({ animated: true }); + }, 100); + }, []); - useEffect(() => { - if (messages.length > 0) { - scrollToBottom(); - } - }, [messages, scrollToBottom]); + useEffect(() => { + if (messages.length > 0) { + scrollToBottom(); + } + }, [messages, scrollToBottom]); - const getDisplayTitle = () => { - if (!context) return title; + const getDisplayTitle = () => { + if (!context) return title; - if ('episodesBySeason' in (context as any)) { - // Always show just the series title - return (context as any).title; - } else if ('showTitle' in (context as any)) { - // For episode contexts, now also only show show title to avoid episode in title per requirement - return (context as any).showTitle; - } - return ('title' in (context as any) && (context as any).title) ? (context as any).title : title; - }; + if ('episodesBySeason' in (context as any)) { + // Always show just the series title + return (context as any).title; + } else if ('showTitle' in (context as any)) { + // For episode contexts, now also only show show title to avoid episode in title per requirement + return (context as any).showTitle; + } + return 'title' in (context as any) && (context as any).title + ? (context as any).title + : title; + }; - const headerAnimatedStyle = useAnimatedStyle(() => ({ - opacity: headerOpacity.value, - })); + const headerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: headerOpacity.value, + })); - const inputAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: inputContainerY.value }], - })); + const inputAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: inputContainerY.value }], + })); - if (isLoadingContext) { - return ( - - - - - Loading AI context... - - - ); - } + if (isLoadingContext) { + return ( + + + + + {t('ai_chat_screen.loading')} + + + ); + } - return ( - - - {backdropUrl && ( - - - {Platform.OS === 'android' && AndroidBlurView - ? - : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable - ? - : } - - - )} - + return ( + + + {backdropUrl && ( + + + {Platform.OS === 'android' && AndroidBlurView ? ( + + ) : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + )} + + + )} + - {/* Header */} - - - { - if (Platform.OS === 'android') { - modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => { - if (finished) runOnJS(navigation.goBack)(); - }); - } else { - navigation.goBack(); - } - }} - style={styles.backButton} - > - - + {/* Header */} + + + { + if (Platform.OS === 'android') { + modalOpacity.value = withSpring( + 0, + { damping: 18, stiffness: 160 }, + (finished) => { + if (finished) runOnJS(navigation.goBack)(); + }, + ); + } else { + navigation.goBack(); + } + }} + style={styles.backButton} + > + + - - - AI Chat - - - {getDisplayTitle()} - - + + + AI Chat + + + {getDisplayTitle()} + + - - - - - + + + + + - {/* Chat Messages */} - - - {messages.length === 0 && suggestions.length > 0 && ( - - - - - - Ask me anything about - - - {getDisplayTitle()} - - - I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more. - + {/* Chat Messages */} + + + {messages.length === 0 && suggestions.length > 0 && ( + + + + + + Ask me anything about + + + {getDisplayTitle()} + + + I have detailed knowledge about this content and can answer questions + about plot, characters, themes, and more. + - - - Try asking: - - - {suggestions.map((suggestion, index) => ( - handleSuggestionPress(suggestion)} - index={index} - /> - ))} - - - - )} + + + Try asking: + + + {suggestions.map((suggestion, index) => ( + handleSuggestionPress(suggestion)} + index={index} + /> + ))} + + + + )} - {messages.map((message, index) => ( - - ))} + {messages.map((message, index) => ( + + ))} - {isLoading && ( - - - - - - - - - - )} - + {isLoading && ( + + + + + + + + + + )} + - {/* Input Container */} - - - - - {Platform.OS === 'android' && AndroidBlurView - ? - : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable - ? - : } - - - + {/* Input Container */} + + + + + {Platform.OS === 'android' && AndroidBlurView ? ( + + ) : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + )} + + + - - {inputText.trim() ? ( - - - - ) : ( - - - - )} - - - - - - - setAlertVisible(false)} - actions={alertActions} - /> - - ); + + {inputText.trim() ? ( + + + + ) : ( + + + + )} + + + + + + + setAlertVisible(false)} + actions={alertActions} + /> + + ); }; const styles = StyleSheet.create({ - container: { - flex: 1, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - gap: 20, - }, - loadingText: { - fontSize: 15, - fontWeight: '500', - letterSpacing: 0.3, - textAlign: 'center', - }, - header: { - paddingHorizontal: 20, - paddingBottom: 16, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: 'rgba(255,255,255,0.08)', - }, - headerContent: { - flexDirection: 'row', - alignItems: 'center', - gap: 14, - }, - backButton: { - padding: 10, - marginLeft: -6, - borderRadius: 12, - }, - headerInfo: { - flex: 1, - }, - headerTitle: { - fontSize: 22, - fontWeight: '700', - letterSpacing: -0.3, - }, - headerSubtitle: { - fontSize: 14, - fontWeight: '500', - marginTop: 3, - opacity: 0.7, - letterSpacing: 0.1, - }, - aiIndicator: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - }, - chatContainer: { - flex: 1, - }, - messagesContainer: { - flex: 1, - }, - messagesContent: { - padding: 20, - paddingBottom: 12, - }, - welcomeContainer: { - alignItems: 'center', - paddingVertical: 40, - paddingHorizontal: 28, - }, - welcomeIcon: { - width: 72, - height: 72, - borderRadius: 36, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 20, - }, - welcomeTitle: { - fontSize: 18, - fontWeight: '500', - textAlign: 'center', - letterSpacing: 0.2, - opacity: 0.85, - }, - welcomeSubtitle: { - fontSize: 24, - fontWeight: '700', - textAlign: 'center', - marginTop: 6, - marginBottom: 16, - letterSpacing: -0.4, - }, - welcomeDescription: { - fontSize: 15, - lineHeight: 23, - textAlign: 'center', - marginBottom: 36, - opacity: 0.7, - letterSpacing: 0.15, - maxWidth: 320, - }, - suggestionsContainer: { - width: '100%', - }, - suggestionsTitle: { - fontSize: 13, - fontWeight: '600', - marginBottom: 14, - textAlign: 'center', - textTransform: 'uppercase', - letterSpacing: 1.2, - opacity: 0.6, - }, - suggestionsGrid: { - gap: 10, - }, - suggestionChip: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 14, - borderRadius: 14, - marginBottom: 0, - }, - suggestionIcon: { - marginRight: 10, - }, - suggestionText: { - flex: 1, - fontSize: 14.5, - fontWeight: '500', - letterSpacing: 0.1, - }, - messageContainer: { - marginBottom: 20, - flexDirection: 'row', - alignItems: 'flex-end', - }, - userMessageContainer: { - justifyContent: 'flex-end', - }, - assistantMessageContainer: { - justifyContent: 'flex-start', - }, - lastMessageContainer: { - marginBottom: 12, - }, - avatarWrapper: { - marginRight: 10, - }, - avatarContainer: { - width: 30, - height: 30, - borderRadius: 15, - justifyContent: 'center', - alignItems: 'center', - }, - userAvatarContainer: { - width: 30, - height: 30, - borderRadius: 15, - justifyContent: 'center', - alignItems: 'center', - marginLeft: 10, - }, - messageBubble: { - maxWidth: width * 0.78, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 22, - overflow: 'hidden', - }, - userBubble: { - borderBottomRightRadius: 6, - }, - assistantBubble: { - borderBottomLeftRadius: 6, - }, - assistantBlurBackdrop: { - ...StyleSheet.absoluteFillObject, - borderRadius: 22, - }, - messageText: { - fontSize: 15.5, - lineHeight: 23, - letterSpacing: 0.15, - }, - userMessageText: { - color: 'white', - fontWeight: '400', - }, - messageTime: { - fontSize: 11, - marginTop: 8, - fontWeight: '500', - letterSpacing: 0.3, - }, - typingIndicator: { - flexDirection: 'row', - alignItems: 'flex-end', - marginBottom: 20, - }, - typingBubble: { - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 22, - borderBottomLeftRadius: 6, - marginLeft: 40, - }, - typingDots: { - flexDirection: 'row', - gap: 6, - alignItems: 'center', - }, - typingDot: { - width: 7, - height: 7, - borderRadius: 3.5, - }, - inputContainer: { - paddingHorizontal: 20, - paddingVertical: 14, - paddingBottom: 18, - }, - inputWrapper: { - flexDirection: 'row', - alignItems: 'flex-end', - borderRadius: 26, - paddingHorizontal: 18, - paddingVertical: 10, - gap: 14, - overflow: 'hidden', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.06)', - }, - inputBlurBackdrop: { - ...StyleSheet.absoluteFillObject, - borderRadius: 26, - }, - textInput: { - flex: 1, - fontSize: 16, - lineHeight: 23, - maxHeight: 120, - paddingVertical: 10, - letterSpacing: 0.15, - }, - sendButtonWrapper: { - marginLeft: 2, - }, - sendButton: { - width: 44, - height: 44, - borderRadius: 22, - justifyContent: 'center', - alignItems: 'center', - }, + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: 20, + }, + loadingText: { + fontSize: 15, + fontWeight: '500', + letterSpacing: 0.3, + textAlign: 'center', + }, + header: { + paddingHorizontal: 20, + paddingBottom: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(255,255,255,0.08)', + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + }, + backButton: { + padding: 10, + marginLeft: -6, + borderRadius: 12, + }, + headerInfo: { + flex: 1, + }, + headerTitle: { + fontSize: 22, + fontWeight: '700', + letterSpacing: -0.3, + }, + headerSubtitle: { + fontSize: 14, + fontWeight: '500', + marginTop: 3, + opacity: 0.7, + letterSpacing: 0.1, + }, + aiIndicator: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + }, + chatContainer: { + flex: 1, + }, + messagesContainer: { + flex: 1, + }, + messagesContent: { + padding: 20, + paddingBottom: 12, + }, + welcomeContainer: { + alignItems: 'center', + paddingVertical: 40, + paddingHorizontal: 28, + }, + welcomeIcon: { + width: 72, + height: 72, + borderRadius: 36, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + }, + welcomeTitle: { + fontSize: 18, + fontWeight: '500', + textAlign: 'center', + letterSpacing: 0.2, + opacity: 0.85, + }, + welcomeSubtitle: { + fontSize: 24, + fontWeight: '700', + textAlign: 'center', + marginTop: 6, + marginBottom: 16, + letterSpacing: -0.4, + }, + welcomeDescription: { + fontSize: 15, + lineHeight: 23, + textAlign: 'center', + marginBottom: 36, + opacity: 0.7, + letterSpacing: 0.15, + maxWidth: 320, + }, + suggestionsContainer: { + width: '100%', + }, + suggestionsTitle: { + fontSize: 13, + fontWeight: '600', + marginBottom: 14, + textAlign: 'center', + textTransform: 'uppercase', + letterSpacing: 1.2, + opacity: 0.6, + }, + suggestionsGrid: { + gap: 10, + }, + suggestionChip: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + borderRadius: 14, + marginBottom: 0, + }, + suggestionIcon: { + marginRight: 10, + }, + suggestionText: { + flex: 1, + fontSize: 14.5, + fontWeight: '500', + letterSpacing: 0.1, + }, + messageContainer: { + marginBottom: 20, + flexDirection: 'row', + alignItems: 'flex-end', + }, + userMessageContainer: { + justifyContent: 'flex-end', + }, + assistantMessageContainer: { + justifyContent: 'flex-start', + }, + lastMessageContainer: { + marginBottom: 12, + }, + avatarWrapper: { + marginRight: 10, + }, + avatarContainer: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + }, + userAvatarContainer: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + marginLeft: 10, + }, + messageBubble: { + maxWidth: width * 0.78, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 22, + overflow: 'hidden', + }, + userBubble: { + borderBottomRightRadius: 6, + }, + assistantBubble: { + borderBottomLeftRadius: 6, + }, + assistantBlurBackdrop: { + ...StyleSheet.absoluteFillObject, + borderRadius: 22, + }, + messageText: { + fontSize: 15.5, + lineHeight: 23, + letterSpacing: 0.15, + }, + userMessageText: { + color: 'white', + fontWeight: '400', + }, + messageTime: { + fontSize: 11, + marginTop: 8, + fontWeight: '500', + letterSpacing: 0.3, + }, + typingIndicator: { + flexDirection: 'row', + alignItems: 'flex-end', + marginBottom: 20, + }, + typingBubble: { + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 22, + borderBottomLeftRadius: 6, + marginLeft: 40, + }, + typingDots: { + flexDirection: 'row', + gap: 6, + alignItems: 'center', + }, + typingDot: { + width: 7, + height: 7, + borderRadius: 3.5, + }, + inputContainer: { + paddingHorizontal: 20, + paddingVertical: 14, + paddingBottom: 18, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'flex-end', + borderRadius: 26, + paddingHorizontal: 18, + paddingVertical: 10, + gap: 14, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.06)', + }, + inputBlurBackdrop: { + ...StyleSheet.absoluteFillObject, + borderRadius: 26, + }, + textInput: { + flex: 1, + fontSize: 16, + lineHeight: 23, + maxHeight: 120, + paddingVertical: 10, + letterSpacing: 0.15, + }, + sendButtonWrapper: { + marginLeft: 2, + }, + sendButton: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + }, }); export default AIChatScreen; diff --git a/src/screens/AISettingsScreen.tsx b/src/screens/AISettingsScreen.tsx index 3d88b2a4..b12c3b7d 100644 --- a/src/screens/AISettingsScreen.tsx +++ b/src/screens/AISettingsScreen.tsx @@ -1,17 +1,17 @@ import React, { useState, useEffect } from 'react'; import { - View, - Text, - StyleSheet, - ScrollView, - SafeAreaView, - StatusBar, - TextInput, - TouchableOpacity, - Linking, - Platform, - Dimensions, - Switch, + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + StatusBar, + TextInput, + TouchableOpacity, + Linking, + Platform, + Dimensions, + Switch, } from 'react-native'; import CustomAlert from '../components/CustomAlert'; import { mmkvStorage } from '../services/mmkvStorage'; @@ -28,40 +28,42 @@ const isTablet = width >= 768; const DEFAULT_OPENROUTER_MODEL = 'openrouter/free'; const AISettingsScreen: React.FC = () => { - const { t } = useTranslation(); - // CustomAlert state (must be inside the component) - const [alertVisible, setAlertVisible] = useState(false); - const [alertTitle, setAlertTitle] = useState(''); - const [alertMessage, setAlertMessage] = useState(''); - const [alertActions, setAlertActions] = useState void; style?: object }>>([ - { label: 'OK', onPress: () => setAlertVisible(false) }, - ]); + const { t } = useTranslation(); + // CustomAlert state (must be inside the component) + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState< + Array<{ label: string; onPress: () => void; style?: object }> + >([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - const openAlert = ( - title: string, - message: string, - actions?: Array<{ label: string; onPress?: () => void; style?: object }> - ) => { - setAlertTitle(title); - setAlertMessage(message); - if (actions && actions.length > 0) { - setAlertActions( - actions.map(a => ({ - label: a.label, - style: a.style, - onPress: () => { a.onPress?.(); }, - })) - ); - } else { - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - } - setAlertVisible(true); - }; - const navigation = useNavigation(); - const { currentTheme } = useTheme(); - const insets = useSafeAreaInsets(); - const { settings, updateSetting } = useSettings(); - const OPENROUTER_SVG = ` + const openAlert = ( + title: string, + message: string, + actions?: Array<{ label: string; onPress?: () => void; style?: object }>, + ) => { + setAlertTitle(title); + setAlertMessage(message); + if (actions && actions.length > 0) { + setAlertActions( + actions.map((a) => ({ + label: a.label, + style: a.style, + onPress: () => { + a.onPress?.(); + }, + })), + ); + } else { + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + } + setAlertVisible(true); + }; + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + const { settings, updateSetting } = useSettings(); + const OPENROUTER_SVG = ` OpenRouter @@ -73,574 +75,728 @@ const AISettingsScreen: React.FC = () => { `; - const [apiKey, setApiKey] = useState(''); - const [loading, setLoading] = useState(false); - const [isKeySet, setIsKeySet] = useState(false); - const [useDefaultModel, setUseDefaultModel] = useState(true); - const [customModel, setCustomModel] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [loading, setLoading] = useState(false); + const [isKeySet, setIsKeySet] = useState(false); + const [useDefaultModel, setUseDefaultModel] = useState(true); + const [customModel, setCustomModel] = useState(''); - useEffect(() => { - loadApiKey(); - }, []); + useEffect(() => { + loadApiKey(); + }, []); - const loadApiKey = async () => { - try { - const [savedKey, savedModel] = await Promise.all([ - mmkvStorage.getItem('openrouter_api_key'), - mmkvStorage.getItem('openrouter_model'), - ]); - if (savedKey) { - setApiKey(savedKey); - setIsKeySet(true); - } - if (savedModel && savedModel.trim()) { - setUseDefaultModel(false); - setCustomModel(savedModel.trim()); - } else { - setUseDefaultModel(true); - setCustomModel(''); - } - } catch (error) { - if (__DEV__) console.error('Error loading OpenRouter API key:', error); - } - }; + const loadApiKey = async () => { + try { + const [savedKey, savedModel] = await Promise.all([ + mmkvStorage.getItem('openrouter_api_key'), + mmkvStorage.getItem('openrouter_model'), + ]); + if (savedKey) { + setApiKey(savedKey); + setIsKeySet(true); + } + if (savedModel && savedModel.trim()) { + setUseDefaultModel(false); + setCustomModel(savedModel.trim()); + } else { + setUseDefaultModel(true); + setCustomModel(''); + } + } catch (error) { + if (__DEV__) console.error('Error loading OpenRouter API key:', error); + } + }; - const handleSaveApiKey = async () => { - if (!apiKey.trim()) { - openAlert(t('common.error'), t('ai_settings.error_invalid_key')); - return; - } + const handleSaveApiKey = async () => { + if (!apiKey.trim()) { + openAlert(t('common.error'), t('ai_settings.error_invalid_key')); + return; + } - if (!apiKey.startsWith('sk-or-')) { - openAlert(t('common.error'), t('ai_settings.error_key_format')); - return; - } + if (!apiKey.startsWith('sk-or-')) { + openAlert(t('common.error'), t('ai_settings.error_key_format')); + return; + } - setLoading(true); - try { - await mmkvStorage.setItem('openrouter_api_key', apiKey.trim()); - if (useDefaultModel || !customModel.trim()) { - await mmkvStorage.removeItem('openrouter_model'); - } else { - await mmkvStorage.setItem('openrouter_model', customModel.trim()); - } - setIsKeySet(true); - openAlert(t('common.success'), t('ai_settings.success_saved')); - } catch (error) { - openAlert(t('common.error'), t('ai_settings.error_save')); - if (__DEV__) console.error('Error saving OpenRouter API key:', error); - } finally { - setLoading(false); - } - }; + setLoading(true); + try { + await mmkvStorage.setItem('openrouter_api_key', apiKey.trim()); + if (useDefaultModel || !customModel.trim()) { + await mmkvStorage.removeItem('openrouter_model'); + } else { + await mmkvStorage.setItem('openrouter_model', customModel.trim()); + } + setIsKeySet(true); + openAlert(t('common.success'), t('ai_settings.success_saved')); + } catch (error) { + openAlert(t('common.error'), t('ai_settings.error_save')); + if (__DEV__) console.error('Error saving OpenRouter API key:', error); + } finally { + setLoading(false); + } + }; - const handleRemoveApiKey = () => { - openAlert( - t('ai_settings.confirm_remove_title'), - t('ai_settings.confirm_remove_msg'), - [ - { label: t('common.cancel'), onPress: () => { } }, - { - label: 'Remove', - onPress: async () => { - try { - await mmkvStorage.removeItem('openrouter_api_key'); - setApiKey(''); - setIsKeySet(false); - openAlert(t('common.success'), t('ai_settings.success_removed')); - } catch (error) { - openAlert(t('common.error'), t('ai_settings.error_remove')); - } - } - } - ] - ); - }; + const handleRemoveApiKey = () => { + openAlert( + t('ai_settings.confirm_remove_title'), + t('ai_settings.confirm_remove_msg'), + [ + { label: t('common.cancel'), onPress: () => {} }, + { + label: t('common.remove'), + onPress: async () => { + try { + await mmkvStorage.removeItem('openrouter_api_key'); + setApiKey(''); + setIsKeySet(false); + openAlert(t('common.success'), t('ai_settings.success_removed')); + } catch (error) { + openAlert(t('common.error'), t('ai_settings.error_remove')); + } + }, + }, + ], + ); + }; - const handleGetApiKey = () => { - Linking.openURL('https://openrouter.ai/keys'); - }; + const handleGetApiKey = () => { + Linking.openURL('https://openrouter.ai/keys'); + }; - return ( - - + return ( + + - {/* Header */} - - navigation.goBack()} - style={styles.backButton} - > - - - {t('settings.settings_title')} - - + {/* Header */} + + navigation.goBack()} + style={styles.backButton} + > + + + {t('settings.settings_title')} + + - - {/* Empty for now, but ready for future actions */} - - + + {/* Empty for now, but ready for future actions */} + + - - {t('ai_settings.title')} - + + {t('ai_settings.title')} + - - {/* Info Card */} - - - - - {t('ai_settings.info_title')} - - - - {t('ai_settings.info_desc')} - + + {/* Info Card */} + + + + + {t('ai_settings.info_title')} + + + + {t('ai_settings.info_desc')} + - - - - - {t('ai_settings.feature_1')} - - - - - - {t('ai_settings.feature_2')} - - - - - - {t('ai_settings.feature_3')} - - - - - - {t('ai_settings.feature_4')} - - - - + + + + + {t('ai_settings.feature_1')} + + + + + + {t('ai_settings.feature_2')} + + + + + + {t('ai_settings.feature_3')} + + + + + + {t('ai_settings.feature_4')} + + + + - {/* API Key Configuration */} - - - {t('ai_settings.api_key_section')} - + {/* API Key Configuration */} + + + {t('ai_settings.api_key_section')} + - - - {t('ai_settings.api_key_label')} - - - {t('ai_settings.api_key_desc')} - + + + {t('ai_settings.api_key_label')} + + + {t('ai_settings.api_key_desc')} + - + - - - - Model - - - - - {useDefaultModel - ? `Using ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).` - : 'Use a custom OpenRouter model ID (useful for paid plans).'} - - {!useDefaultModel && ( - - )} - + + + + {t('ai_settings.model')} + + + + + {useDefaultModel + ? `${t('ai_settings.using')} ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).` + : t('ai_settings.paid_routing')} + + {!useDefaultModel && ( + + )} + - - {!isKeySet ? ( - - - - {loading ? t('ai_settings.saving') : t('ai_settings.save_api_key')} - - - ) : ( - - - - {t('ai_settings.update')} - + + {!isKeySet ? ( + + + + {loading ? t('ai_settings.saving') : t('ai_settings.save_api_key')} + + + ) : ( + + + + {t('ai_settings.update')} + - - - - {t('ai_settings.remove')} - - - - )} - + + + + {t('ai_settings.remove')} + + + + )} + - - - - {t('ai_settings.get_free_key')} - - - - + + + + {t('ai_settings.get_free_key')} + + + + - {/* Enable Toggle (top) */} - - - {t('ai_settings.enable_chat')} - updateSetting('aiChatEnabled', v)} - trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }} - thumbColor={settings.aiChatEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis} - ios_backgroundColor={currentTheme.colors.elevation2} - /> - - {t('ai_settings.enable_chat_desc')} - + {/* Enable Toggle (top) */} + + + + {t('ai_settings.enable_chat')} + + updateSetting('aiChatEnabled', v)} + trackColor={{ + false: currentTheme.colors.elevation2, + true: currentTheme.colors.primary, + }} + thumbColor={ + settings.aiChatEnabled + ? currentTheme.colors.white + : currentTheme.colors.mediumEmphasis + } + ios_backgroundColor={currentTheme.colors.elevation2} + /> + + + {t('ai_settings.enable_chat_desc')} + + - {/* Status Card */} - {isKeySet && ( - - - - - {t('ai_settings.chat_enabled')} - - - - {t('ai_settings.chat_enabled_desc')} - - - )} + {/* Status Card */} + {isKeySet && ( + + + + + {t('ai_settings.chat_enabled')} + + + + {t('ai_settings.chat_enabled_desc')} + + + )} - {/* Usage Info */} - - - {t('ai_settings.how_it_works')} - - - {t('ai_settings.how_it_works_desc')} - - - {/* OpenRouter branding */} - - - - - setAlertVisible(false)} - actions={alertActions} - /> - - ); + {/* Usage Info */} + + + {t('ai_settings.how_it_works')} + + + {t('ai_settings.how_it_works_desc')} + + + {/* OpenRouter branding */} + + + + + setAlertVisible(false)} + actions={alertActions} + /> + + ); }; const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8, - }, - backButton: { - flexDirection: 'row', - alignItems: 'center', - padding: 8, - }, - backText: { - fontSize: 17, - marginLeft: 8, - }, - headerActions: { - flexDirection: 'row', - alignItems: 'center', - }, - headerButton: { - padding: 8, - marginLeft: 8, - }, - headerTitle: { - fontSize: 34, - fontWeight: 'bold', - paddingHorizontal: 20, - marginBottom: 24, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - paddingBottom: 40, - }, - infoCard: { - borderRadius: 16, - padding: 20, - marginHorizontal: 16, - marginBottom: 20, - }, - infoHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 12, - }, - infoTitle: { - fontSize: 20, - fontWeight: '700', - marginLeft: 12, - }, - infoDescription: { - fontSize: 16, - lineHeight: 24, - marginBottom: 16, - }, - featureList: { - gap: 8, - }, - featureItem: { - flexDirection: 'row', - alignItems: 'center', - }, - featureText: { - fontSize: 15, - marginLeft: 8, - flex: 1, - }, - card: { - borderRadius: 16, - padding: 20, - marginHorizontal: 16, - marginBottom: 20, - }, - cardTitle: { - fontSize: 13, - fontWeight: '600', - letterSpacing: 0.8, - marginBottom: 16, - }, - apiKeySection: { - gap: 12, - }, - modelSection: { - gap: 8, - marginTop: 4, - }, - modelHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - label: { - fontSize: 16, - fontWeight: '600', - }, - description: { - fontSize: 14, - lineHeight: 20, - }, - input: { - borderRadius: 12, - padding: 16, - fontSize: 16, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - borderWidth: 1, - }, - buttonContainer: { - marginTop: 8, - }, - buttonRow: { - flexDirection: 'row', - gap: 12, - }, - saveButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 14, - paddingHorizontal: 20, - borderRadius: 12, - }, - saveButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - updateButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 14, - paddingHorizontal: 20, - borderRadius: 12, - flex: 1, - }, - updateButtonText: { - color: 'white', - fontSize: 16, - fontWeight: '600', - }, - removeButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 14, - paddingHorizontal: 20, - borderRadius: 12, - borderWidth: 2, - flex: 1, - }, - removeButtonText: { - fontSize: 16, - fontWeight: '600', - }, - getKeyButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - borderRadius: 12, - marginTop: 8, - }, - getKeyButtonText: { - fontSize: 15, - fontWeight: '500', - }, - statusCard: { - borderRadius: 16, - padding: 20, - marginBottom: 20, - }, - statusHeader: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }, - statusTitle: { - fontSize: 18, - fontWeight: '600', - marginLeft: 12, - }, - statusDescription: { - fontSize: 15, - lineHeight: 22, - }, - usageCard: { - borderRadius: 16, - padding: 20, - }, - usageTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 12, - }, - usageText: { - fontSize: 15, - lineHeight: 24, - }, + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: + Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + marginLeft: 8, + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + }, + headerButton: { + padding: 8, + marginLeft: 8, + }, + headerTitle: { + fontSize: 34, + fontWeight: 'bold', + paddingHorizontal: 20, + marginBottom: 24, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 40, + }, + infoCard: { + borderRadius: 16, + padding: 20, + marginHorizontal: 16, + marginBottom: 20, + }, + infoHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + infoTitle: { + fontSize: 20, + fontWeight: '700', + marginLeft: 12, + }, + infoDescription: { + fontSize: 16, + lineHeight: 24, + marginBottom: 16, + }, + featureList: { + gap: 8, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'center', + }, + featureText: { + fontSize: 15, + marginLeft: 8, + flex: 1, + }, + card: { + borderRadius: 16, + padding: 20, + marginHorizontal: 16, + marginBottom: 20, + }, + cardTitle: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.8, + marginBottom: 16, + }, + apiKeySection: { + gap: 12, + }, + modelSection: { + gap: 8, + marginTop: 4, + }, + modelHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + label: { + fontSize: 16, + fontWeight: '600', + }, + description: { + fontSize: 14, + lineHeight: 20, + }, + input: { + borderRadius: 12, + padding: 16, + fontSize: 16, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + borderWidth: 1, + }, + buttonContainer: { + marginTop: 8, + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + }, + saveButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + }, + saveButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + updateButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + flex: 1, + }, + updateButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, + removeButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 14, + paddingHorizontal: 20, + borderRadius: 12, + borderWidth: 2, + flex: 1, + }, + removeButtonText: { + fontSize: 16, + fontWeight: '600', + }, + getKeyButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 12, + marginTop: 8, + }, + getKeyButtonText: { + fontSize: 15, + fontWeight: '500', + }, + statusCard: { + borderRadius: 16, + padding: 20, + marginBottom: 20, + }, + statusHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + statusTitle: { + fontSize: 18, + fontWeight: '600', + marginLeft: 12, + }, + statusDescription: { + fontSize: 15, + lineHeight: 22, + }, + usageCard: { + borderRadius: 16, + padding: 20, + }, + usageTitle: { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + }, + usageText: { + fontSize: 15, + lineHeight: 24, + }, }); export default AISettingsScreen;