Improved Localization in AI Screens

This commit is contained in:
albyalex96 2026-03-05 19:29:09 +01:00
parent 07542d2838
commit 60ca025ece
4 changed files with 2172 additions and 1739 deletions

View file

@ -14,6 +14,7 @@
"try_again": "Try Again", "try_again": "Try Again",
"go_back": "Go Back", "go_back": "Go Back",
"settings": "Settings", "settings": "Settings",
"remove":"Remove",
"close": "Close", "close": "Close",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
@ -926,7 +927,11 @@
"confirm_remove_title": "Remove API Key", "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.", "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", "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": { "catalog_settings": {
"title": "Catalogs", "title": "Catalogs",
@ -1523,5 +1528,8 @@
"user_id": "User ID", "user_id": "User ID",
"display_name": "Display Name", "display_name": "Display Name",
"display_name_placeholder": "Add a display name" "display_name_placeholder": "Add a display name"
},
"ai_chat_screen":{
"loading":"Loading AI context..."
} }
} }

View file

@ -15,6 +15,7 @@
"go_back": "Torna indietro", "go_back": "Torna indietro",
"settings": "Impostazioni", "settings": "Impostazioni",
"close": "Chiudi", "close": "Chiudi",
"remove":"Rimuovi",
"enable": "Abilita", "enable": "Abilita",
"disable": "Disabilita", "disable": "Disabilita",
"show_more": "Mostra altro", "show_more": "Mostra altro",
@ -510,7 +511,7 @@
"overview": "PANORAMICA", "overview": "PANORAMICA",
"no_categories": "Nessuna categoria", "no_categories": "Nessuna categoria",
"pre_installed": "PRE-INSTALLATO", "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": { "trakt": {
"title": "Impostazioni Trakt", "title": "Impostazioni Trakt",
@ -946,7 +947,11 @@
"confirm_remove_title": "Rimuovi Chiave API", "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.", "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", "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": { "catalog_settings": {
"title": "Cataloghi", "title": "Cataloghi",
@ -1537,11 +1542,14 @@
"no_logs_captured": "Nessun log catturato." "no_logs_captured": "Nessun log catturato."
} }
}, },
"account_manager":{ "account_manager": {
"sign_out":"Esci", "sign_out": "Esci",
"sign_out_desc":"", "sign_out_desc": "",
"user_id":"ID Utente", "user_id": "ID Utente",
"display_name":"Nickname", "display_name": "Nickname",
"display_name_placeholder":"Aggiungi un nickname" "display_name_placeholder": "Aggiungi un nickname"
},
"ai_chat_screen": {
"loading": "Caricamento contesto AI in corso..."
} }
} }

View file

@ -1,4 +1,10 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import { import {
View, View,
Text, Text,
@ -14,7 +20,12 @@ import {
Keyboard, Keyboard,
} from 'react-native'; } from 'react-native';
import CustomAlert from '../components/CustomAlert'; 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 { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
@ -39,14 +50,28 @@ if (Platform.OS === 'ios') {
// Dynamically require so app still runs if the package isn't installed yet // Dynamically require so app still runs if the package isn't installed yet
const glass = require('expo-glass-effect'); const glass = require('expo-glass-effect');
GlassViewComp = glass.GlassView; GlassViewComp = glass.GlassView;
liquidGlassAvailable = typeof glass.isLiquidGlassAvailable === 'function' ? glass.isLiquidGlassAvailable() : false; liquidGlassAvailable =
typeof glass.isLiquidGlassAvailable === 'function'
? glass.isLiquidGlassAvailable()
: false;
} catch { } catch {
GlassViewComp = null; GlassViewComp = null;
liquidGlassAvailable = false; liquidGlassAvailable = false;
} }
} }
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; import {
import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, createSeriesContext, generateConversationStarters } from '../services/aiService'; 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 { tmdbService } from '../services/tmdbService';
import Markdown from 'react-native-markdown-display'; import Markdown from 'react-native-markdown-display';
import Animated, { import Animated, {
@ -59,8 +84,9 @@ import Animated, {
withDelay, withDelay,
interpolate, interpolate,
Extrapolate, Extrapolate,
runOnJS runOnJS,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
@ -84,7 +110,10 @@ interface ChatBubbleProps {
} }
// Animated typing dot component // Animated typing dot component
const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color }) => { const TypingDot: React.FC<{ delay: number; color: string }> = ({
delay,
color,
}) => {
const opacity = useSharedValue(0.3); const opacity = useSharedValue(0.3);
const scale = useSharedValue(1); const scale = useSharedValue(1);
@ -94,22 +123,22 @@ const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color })
withRepeat( withRepeat(
withSequence( withSequence(
withTiming(1, { duration: 400 }), withTiming(1, { duration: 400 }),
withTiming(0.3, { duration: 400 }) withTiming(0.3, { duration: 400 }),
), ),
-1, -1,
false false,
) ),
); );
scale.value = withDelay( scale.value = withDelay(
delay, delay,
withRepeat( withRepeat(
withSequence( withSequence(
withTiming(1.2, { duration: 400 }), withTiming(1.2, { duration: 400 }),
withTiming(1, { duration: 400 }) withTiming(1, { duration: 400 }),
), ),
-1, -1,
false false,
) ),
); );
}, []); }, []);
@ -119,11 +148,14 @@ const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color })
})); }));
return ( return (
<Animated.View style={[styles.typingDot, { backgroundColor: color }, animatedStyle]} /> <Animated.View
style={[styles.typingDot, { backgroundColor: color }, animatedStyle]}
/>
); );
}; };
const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) => { const ChatBubble: React.FC<ChatBubbleProps> = React.memo(
({ message, isLast }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const isUser = message.role === 'user'; const isUser = message.role === 'user';
@ -141,31 +173,36 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
bubbleAnimation.value, bubbleAnimation.value,
[0, 1], [0, 1],
[16, 0], [16, 0],
Extrapolate.CLAMP Extrapolate.CLAMP,
) ),
}, },
{ {
scale: interpolate( scale: interpolate(
bubbleAnimation.value, bubbleAnimation.value,
[0, 1], [0, 1],
[0.95, 1], [0.95, 1],
Extrapolate.CLAMP Extrapolate.CLAMP,
) ),
} },
] ],
})); }));
return ( return (
<Animated.View style={[ <Animated.View
style={[
styles.messageContainer, styles.messageContainer,
isUser ? styles.userMessageContainer : styles.assistantMessageContainer, isUser ? styles.userMessageContainer : styles.assistantMessageContainer,
isLast && styles.lastMessageContainer, isLast && styles.lastMessageContainer,
animatedStyle animatedStyle,
]}> ]}
>
{!isUser && ( {!isUser && (
<View style={styles.avatarWrapper}> <View style={styles.avatarWrapper}>
<LinearGradient <LinearGradient
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}99`]} colors={[
currentTheme.colors.primary,
`${currentTheme.colors.primary}99`,
]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.avatarContainer} style={styles.avatarContainer}
@ -175,9 +212,11 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
</View> </View>
)} )}
<View style={[ <View
style={[
styles.messageBubble, styles.messageBubble,
isUser ? [ isUser
? [
styles.userBubble, styles.userBubble,
{ {
backgroundColor: currentTheme.colors.primary, backgroundColor: currentTheme.colors.primary,
@ -186,20 +225,37 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
shadowOpacity: 0.25, shadowOpacity: 0.25,
shadowRadius: 8, shadowRadius: 8,
elevation: 6, elevation: 6,
} },
] : [
styles.assistantBubble,
{ backgroundColor: 'transparent' }
] ]
]}> : [styles.assistantBubble, { backgroundColor: 'transparent' }],
]}
>
{!isUser && ( {!isUser && (
<View style={styles.assistantBlurBackdrop} pointerEvents="none"> <View style={styles.assistantBlurBackdrop} pointerEvents="none">
{Platform.OS === 'android' && AndroidBlurView {Platform.OS === 'android' && AndroidBlurView ? (
? <AndroidBlurView blurAmount={18} blurRadius={10} style={StyleSheet.absoluteFill} /> <AndroidBlurView
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable blurAmount={18}
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" /> blurRadius={10}
: <ExpoBlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />} style={StyleSheet.absoluteFill}
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.45)' }]} /> />
) : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={StyleSheet.absoluteFill}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView
intensity={80}
tint="dark"
style={StyleSheet.absoluteFill}
/>
)}
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: 'rgba(0,0,0,0.45)' },
]}
/>
</View> </View>
)} )}
{isUser ? ( {isUser ? (
@ -220,7 +276,7 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
paragraph: { paragraph: {
marginBottom: 12, marginBottom: 12,
marginTop: 0, marginTop: 0,
color: currentTheme.colors.highEmphasis color: currentTheme.colors.highEmphasis,
}, },
heading1: { heading1: {
fontSize: 22, fontSize: 22,
@ -247,7 +303,7 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
}, },
link: { link: {
color: currentTheme.colors.primary, color: currentTheme.colors.primary,
textDecorationLine: 'underline' textDecorationLine: 'underline',
}, },
code_inline: { code_inline: {
backgroundColor: 'rgba(255,255,255,0.08)', backgroundColor: 'rgba(255,255,255,0.08)',
@ -282,23 +338,23 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
}, },
bullet_list: { bullet_list: {
marginBottom: 10, marginBottom: 10,
marginTop: 0 marginTop: 0,
}, },
ordered_list: { ordered_list: {
marginBottom: 10, marginBottom: 10,
marginTop: 0 marginTop: 0,
}, },
list_item: { list_item: {
marginBottom: 6, marginBottom: 6,
color: currentTheme.colors.highEmphasis color: currentTheme.colors.highEmphasis,
}, },
strong: { strong: {
fontWeight: '700', fontWeight: '700',
color: currentTheme.colors.highEmphasis color: currentTheme.colors.highEmphasis,
}, },
em: { em: {
fontStyle: 'italic', fontStyle: 'italic',
color: currentTheme.colors.mediumEmphasis color: currentTheme.colors.mediumEmphasis,
}, },
blockquote: { blockquote: {
backgroundColor: 'rgba(255,255,255,0.04)', backgroundColor: 'rgba(255,255,255,0.04)',
@ -337,29 +393,43 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
{message.content} {message.content}
</Markdown> </Markdown>
)} )}
<Text style={[ <Text
style={[
styles.messageTime, styles.messageTime,
{ color: isUser ? 'rgba(255,255,255,0.65)' : currentTheme.colors.disabled } {
]}> color: isUser ? 'rgba(255,255,255,0.65)' : currentTheme.colors.disabled,
},
]}
>
{new Date(message.timestamp).toLocaleTimeString([], { {new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
})} })}
</Text> </Text>
</View> </View>
{isUser && ( {isUser && (
<View style={[styles.userAvatarContainer, { <View
style={[
styles.userAvatarContainer,
{
backgroundColor: 'rgba(255,255,255,0.08)', backgroundColor: 'rgba(255,255,255,0.08)',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)', borderColor: 'rgba(255,255,255,0.12)',
}]}> },
<MaterialIcons name="person" size={14} color={currentTheme.colors.primary} /> ]}
>
<MaterialIcons
name="person"
size={14}
color={currentTheme.colors.primary}
/>
</View> </View>
)} )}
</Animated.View> </Animated.View>
); );
}, (prev, next) => { },
(prev, next) => {
return ( return (
prev.isLast === next.isLast && prev.isLast === next.isLast &&
prev.message.id === next.message.id && prev.message.id === next.message.id &&
@ -367,7 +437,8 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
prev.message.content === next.message.content && prev.message.content === next.message.content &&
prev.message.timestamp === next.message.timestamp prev.message.timestamp === next.message.timestamp
); );
}); },
);
interface SuggestionChipProps { interface SuggestionChipProps {
text: string; text: string;
@ -375,22 +446,32 @@ interface SuggestionChipProps {
index: number; index: number;
} }
const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPress, index }) => { const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(
({ text, onPress, index }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const animValue = useSharedValue(0); const animValue = useSharedValue(0);
useEffect(() => { useEffect(() => {
animValue.value = withDelay( animValue.value = withDelay(
index * 80, index * 80,
withSpring(1, { damping: 18, stiffness: 120 }) withSpring(1, { damping: 18, stiffness: 120 }),
); );
}, []); }, []);
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
opacity: animValue.value, opacity: animValue.value,
transform: [ transform: [
{ translateY: interpolate(animValue.value, [0, 1], [12, 0], Extrapolate.CLAMP) }, {
{ scale: interpolate(animValue.value, [0, 1], [0.95, 1], Extrapolate.CLAMP) }, translateY: interpolate(
animValue.value,
[0, 1],
[12, 0],
Extrapolate.CLAMP,
),
},
{
scale: interpolate(animValue.value, [0, 1], [0.95, 1], Extrapolate.CLAMP),
},
], ],
})); }));
@ -403,7 +484,7 @@ const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPres
backgroundColor: 'rgba(255,255,255,0.04)', backgroundColor: 'rgba(255,255,255,0.04)',
borderWidth: 1, borderWidth: 1,
borderColor: `${currentTheme.colors.primary}40`, borderColor: `${currentTheme.colors.primary}40`,
} },
]} ]}
onPress={onPress} onPress={onPress}
activeOpacity={0.7} activeOpacity={0.7}
@ -414,7 +495,12 @@ const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPres
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
style={styles.suggestionIcon} style={styles.suggestionIcon}
/> />
<Text style={[styles.suggestionText, { color: currentTheme.colors.highEmphasis }]}> <Text
style={[
styles.suggestionText,
{ color: currentTheme.colors.highEmphasis },
]}
>
{text} {text}
</Text> </Text>
<MaterialIcons <MaterialIcons
@ -425,31 +511,40 @@ const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPres
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
); );
}, (prev, next) => prev.text === next.text && prev.onPress === next.onPress && prev.index === next.index); },
(prev, next) =>
prev.text === next.text &&
prev.onPress === next.onPress &&
prev.index === next.index,
);
const AIChatScreen: React.FC = () => { const AIChatScreen: React.FC = () => {
// CustomAlert state // CustomAlert state
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState(''); const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([ const {t } = useTranslation();
{ label: 'OK', onPress: () => setAlertVisible(false) }, const [alertActions, setAlertActions] = useState<
]); Array<{ label: string; onPress: () => void; style?: object }>
>([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
const openAlert = ( const openAlert = (
title: string, title: string,
message: string, message: string,
actions?: Array<{ label: string; onPress?: () => void; style?: object }> actions?: Array<{ label: string; onPress?: () => void; style?: object }>,
) => { ) => {
setAlertTitle(title); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
if (actions && actions.length > 0) { if (actions && actions.length > 0) {
setAlertActions( setAlertActions(
actions.map(a => ({ actions.map((a) => ({
label: a.label, label: a.label,
style: a.style, style: a.style,
onPress: () => { a.onPress?.(); setAlertVisible(false); }, onPress: () => {
})) a.onPress?.();
setAlertVisible(false);
},
})),
); );
} else { } else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
@ -461,7 +556,14 @@ const AIChatScreen: React.FC = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets(); 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<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
@ -480,7 +582,7 @@ const AIChatScreen: React.FC = () => {
setBackdropUrl(null); setBackdropUrl(null);
} }
}; };
}, []) }, []),
); );
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
@ -513,10 +615,12 @@ const AIChatScreen: React.FC = () => {
} }
}; };
const showSub = Platform.OS === 'ios' const showSub =
Platform.OS === 'ios'
? Keyboard.addListener('keyboardWillShow', onShow) ? Keyboard.addListener('keyboardWillShow', onShow)
: Keyboard.addListener('keyboardDidShow', onShow); : Keyboard.addListener('keyboardDidShow', onShow);
const hideSub = Platform.OS === 'ios' const hideSub =
Platform.OS === 'ios'
? Keyboard.addListener('keyboardWillHide', onHide) ? Keyboard.addListener('keyboardWillHide', onHide)
: Keyboard.addListener('keyboardDidHide', onHide); : Keyboard.addListener('keyboardDidHide', onHide);
@ -564,7 +668,7 @@ const AIChatScreen: React.FC = () => {
try { try {
const path = movieData.backdrop_path || movieData.poster_path || null; const path = movieData.backdrop_path || movieData.poster_path || null;
if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`);
} catch { } } catch {}
} else { } else {
// Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id) // Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id)
let tmdbNumericId: number | null = null; let tmdbNumericId: number | null = null;
@ -582,14 +686,14 @@ const AIChatScreen: React.FC = () => {
const [showData, allEpisodes] = await Promise.all([ const [showData, allEpisodes] = await Promise.all([
tmdbService.getTVShowDetails(tmdbNumericId), tmdbService.getTVShowDetails(tmdbNumericId),
tmdbService.getAllEpisodes(tmdbNumericId) tmdbService.getAllEpisodes(tmdbNumericId),
]); ]);
if (!showData) throw new Error('Unable to load TV show details'); if (!showData) throw new Error('Unable to load TV show details');
try { try {
const path = showData.backdrop_path || showData.poster_path || null; const path = showData.backdrop_path || showData.poster_path || null;
if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`); if (path) setBackdropUrl(`https://image.tmdb.org/t/p/w780${path}`);
} catch { } } catch {}
if (!showData) throw new Error('Unable to load TV show details'); if (!showData) throw new Error('Unable to load TV show details');
const seriesContext = createSeriesContext(showData, allEpisodes || {}); const seriesContext = createSeriesContext(showData, allEpisodes || {});
@ -600,18 +704,21 @@ const AIChatScreen: React.FC = () => {
openAlert('Error', 'Failed to load content details for AI chat'); openAlert('Error', 'Failed to load content details for AI chat');
} finally { } finally {
setIsLoadingContext(false); setIsLoadingContext(false);
{/* CustomAlert at root */ } {
/* CustomAlert at root */
}
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}
title={alertTitle} title={alertTitle}
message={alertMessage} message={alertMessage}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
actions={alertActions} actions={alertActions}
/> />;
} }
}; };
const sendMessage = useCallback(async (messageText: string) => { const sendMessage = useCallback(
async (messageText: string) => {
if (!messageText.trim() || !context || isLoading) return; if (!messageText.trim() || !context || isLoading) return;
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
@ -621,7 +728,7 @@ const AIChatScreen: React.FC = () => {
timestamp: Date.now(), timestamp: Date.now(),
}; };
setMessages(prev => [...prev, userMessage]); setMessages((prev) => [...prev, userMessage]);
setInputText(''); setInputText('');
setIsLoading(true); setIsLoading(true);
setSuggestions([]); // Hide suggestions after first message setSuggestions([]); // Hide suggestions after first message
@ -631,7 +738,9 @@ const AIChatScreen: React.FC = () => {
let requestContext = context; let requestContext = context;
if ('episodesBySeason' in (context as any)) { if ('episodesBySeason' in (context as any)) {
// Series-wide context; optionally detect SxE patterns to focus answer, but keep series context // 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); const sxe =
messageText.match(/s(\d+)e(\d+)/i) ||
messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i);
if (sxe) { if (sxe) {
// We will append a brief hint to the user question to scope, but still pass series context // 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]})`; messageText = `${messageText} (about Season ${sxe[1]}, Episode ${sxe[2]})`;
@ -639,10 +748,19 @@ const AIChatScreen: React.FC = () => {
} else if ('showTitle' in (context as any)) { } else if ('showTitle' in (context as any)) {
const sxe = messageText.match(/s(\d+)e(\d+)/i); const sxe = messageText.match(/s(\d+)e(\d+)/i);
const words = messageText.match(/season\s+(\d+)[^\d]+episode\s+(\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); 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 season = sxe
let episode = sxe ? parseInt(sxe[2], 10) : (words ? parseInt(words[2], 10) : undefined); ? 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 only season mentioned (like "s2" or "season 2"), default to episode 1
if (!season && seasonOnly) { if (!season && seasonOnly) {
@ -659,26 +777,32 @@ const AIChatScreen: React.FC = () => {
} else { } else {
tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId); tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId);
if (!tmdbNumericId && episodeId) { if (!tmdbNumericId && episodeId) {
tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId); tmdbNumericId =
await tmdbService.extractTMDBIdFromStremioId(episodeId);
} }
} }
if (tmdbNumericId) { if (tmdbNumericId) {
const [showData, episodeData] = await Promise.all([ const [showData, episodeData] = await Promise.all([
tmdbService.getTVShowDetails(tmdbNumericId), tmdbService.getTVShowDetails(tmdbNumericId),
tmdbService.getEpisodeDetails(tmdbNumericId, season, episode) tmdbService.getEpisodeDetails(tmdbNumericId, season, episode),
]); ]);
if (showData && episodeData) { if (showData && episodeData) {
requestContext = createEpisodeContext(episodeData, showData, season, episode); requestContext = createEpisodeContext(
episodeData,
showData,
season,
episode,
);
} }
} }
} catch { } } catch {}
} }
} }
const response = await aiService.sendMessage( const response = await aiService.sendMessage(
messageText.trim(), messageText.trim(),
requestContext, requestContext,
messages messages,
); );
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@ -688,22 +812,33 @@ const AIChatScreen: React.FC = () => {
timestamp: Date.now(), timestamp: Date.now(),
}; };
setMessages(prev => [...prev, assistantMessage]); setMessages((prev) => [...prev, assistantMessage]);
} catch (error) { } catch (error) {
if (__DEV__) console.error('Error sending message:', error); if (__DEV__) console.error('Error sending message:', error);
let errorMessage = 'Sorry, I encountered an error. Please try again.'; let errorMessage = 'Sorry, I encountered an error. Please try again.';
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('not configured')) { if (error.message.includes('not configured')) {
errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.'; errorMessage =
} else if (/401|unauthorized|invalid api key|authentication/i.test(error.message)) { 'Please configure your OpenRouter API key in Settings > AI Assistant.';
errorMessage = 'OpenRouter rejected your API key. Please verify the 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)) { } 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.'; errorMessage =
} else if (/model|provider|endpoint|unsupported|unavailable|not found/i.test(error.message)) { 'OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.';
errorMessage = 'The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.'; } 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')) { } 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.'; errorMessage =
'Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability.';
} }
} }
@ -714,19 +849,24 @@ const AIChatScreen: React.FC = () => {
timestamp: Date.now(), timestamp: Date.now(),
}; };
setMessages(prev => [...prev, errorResponse]); setMessages((prev) => [...prev, errorResponse]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [context, messages, isLoading]); },
[context, messages, isLoading],
);
const handleSendPress = useCallback(() => { const handleSendPress = useCallback(() => {
sendMessage(inputText); sendMessage(inputText);
}, [inputText, sendMessage]); }, [inputText, sendMessage]);
const handleSuggestionPress = useCallback((suggestion: string) => { const handleSuggestionPress = useCallback(
(suggestion: string) => {
sendMessage(suggestion); sendMessage(suggestion);
}, [sendMessage]); },
[sendMessage],
);
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
setTimeout(() => { setTimeout(() => {
@ -750,7 +890,9 @@ const AIChatScreen: React.FC = () => {
// For episode contexts, now also only show show title to avoid episode in title per requirement // For episode contexts, now also only show show title to avoid episode in title per requirement
return (context as any).showTitle; return (context as any).showTitle;
} }
return ('title' in (context as any) && (context as any).title) ? (context as any).title : title; return 'title' in (context as any) && (context as any).title
? (context as any).title
: title;
}; };
const headerAnimatedStyle = useAnimatedStyle(() => ({ const headerAnimatedStyle = useAnimatedStyle(() => ({
@ -763,11 +905,18 @@ const AIChatScreen: React.FC = () => {
if (isLoadingContext) { if (isLoadingContext) {
return ( return (
<View style={[styles.loadingContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> <View
style={[
styles.loadingContainer,
{ backgroundColor: currentTheme.colors.darkBackground },
]}
>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}> <Text
Loading AI context... style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}
>
{t('ai_chat_screen.loading')}
</Text> </Text>
</View> </View>
); );
@ -775,7 +924,13 @@ const AIChatScreen: React.FC = () => {
return ( return (
<Animated.View style={{ flex: 1, opacity: modalOpacity }}> <Animated.View style={{ flex: 1, opacity: modalOpacity }}>
<SafeAreaView edges={['top', 'bottom']} style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <SafeAreaView
edges={['top', 'bottom']}
style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground },
]}
>
{backdropUrl && ( {backdropUrl && (
<View style={StyleSheet.absoluteFill} pointerEvents="none"> <View style={StyleSheet.absoluteFill} pointerEvents="none">
<FastImage <FastImage
@ -783,52 +938,96 @@ const AIChatScreen: React.FC = () => {
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
resizeMode={FastImage.resizeMode.cover} resizeMode={FastImage.resizeMode.cover}
/> />
{Platform.OS === 'android' && AndroidBlurView {Platform.OS === 'android' && AndroidBlurView ? (
? <AndroidBlurView blurAmount={12} blurRadius={6} style={StyleSheet.absoluteFill} /> <AndroidBlurView
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable blurAmount={12}
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" /> blurRadius={6}
: <ExpoBlurView intensity={60} tint="dark" style={StyleSheet.absoluteFill} />} style={StyleSheet.absoluteFill}
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.45)' }]} /> />
) : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={StyleSheet.absoluteFill}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView
intensity={60}
tint="dark"
style={StyleSheet.absoluteFill}
/>
)}
<View
style={[
StyleSheet.absoluteFill,
{
backgroundColor:
Platform.OS === 'android' ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.45)',
},
]}
/>
</View> </View>
)} )}
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
{/* Header */} {/* Header */}
<Animated.View style={[ <Animated.View
style={[
styles.header, styles.header,
{ {
backgroundColor: 'transparent', backgroundColor: 'transparent',
paddingTop: Platform.OS === 'ios' ? insets.top : insets.top paddingTop: Platform.OS === 'ios' ? insets.top : insets.top,
}, },
headerAnimatedStyle headerAnimatedStyle,
]}> ]}
>
<View style={styles.headerContent}> <View style={styles.headerContent}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => { modalOpacity.value = withSpring(
0,
{ damping: 18, stiffness: 160 },
(finished) => {
if (finished) runOnJS(navigation.goBack)(); if (finished) runOnJS(navigation.goBack)();
}); },
);
} else { } else {
navigation.goBack(); navigation.goBack();
} }
}} }}
style={styles.backButton} style={styles.backButton}
> >
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} /> <MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerInfo}> <View style={styles.headerInfo}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}> <Text
style={[
styles.headerTitle,
{ color: currentTheme.colors.highEmphasis },
]}
>
AI Chat AI Chat
</Text> </Text>
<Text style={[styles.headerSubtitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[
styles.headerSubtitle,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{getDisplayTitle()} {getDisplayTitle()}
</Text> </Text>
</View> </View>
<LinearGradient <LinearGradient
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}CC`]} colors={[
currentTheme.colors.primary,
`${currentTheme.colors.primary}CC`,
]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.aiIndicator} style={styles.aiIndicator}
@ -849,7 +1048,7 @@ const AIChatScreen: React.FC = () => {
style={styles.messagesContainer} style={styles.messagesContainer}
contentContainerStyle={[ contentContainerStyle={[
styles.messagesContent, styles.messagesContent,
{ paddingBottom: isKeyboardVisible ? 20 : (56 + (isLoading ? 20 : 0)) } { paddingBottom: isKeyboardVisible ? 20 : 56 + (isLoading ? 20 : 0) },
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
removeClippedSubviews removeClippedSubviews
@ -859,25 +1058,49 @@ const AIChatScreen: React.FC = () => {
{messages.length === 0 && suggestions.length > 0 && ( {messages.length === 0 && suggestions.length > 0 && (
<View style={styles.welcomeContainer}> <View style={styles.welcomeContainer}>
<LinearGradient <LinearGradient
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}99`]} colors={[
currentTheme.colors.primary,
`${currentTheme.colors.primary}99`,
]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.welcomeIcon} style={styles.welcomeIcon}
> >
<MaterialIcons name="auto-awesome" size={34} color="white" /> <MaterialIcons name="auto-awesome" size={34} color="white" />
</LinearGradient> </LinearGradient>
<Text style={[styles.welcomeTitle, { color: currentTheme.colors.highEmphasis }]}> <Text
style={[
styles.welcomeTitle,
{ color: currentTheme.colors.highEmphasis },
]}
>
Ask me anything about Ask me anything about
</Text> </Text>
<Text style={[styles.welcomeSubtitle, { color: currentTheme.colors.primary }]}> <Text
style={[
styles.welcomeSubtitle,
{ color: currentTheme.colors.primary },
]}
>
{getDisplayTitle()} {getDisplayTitle()}
</Text> </Text>
<Text style={[styles.welcomeDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text
I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more. style={[
styles.welcomeDescription,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
I have detailed knowledge about this content and can answer questions
about plot, characters, themes, and more.
</Text> </Text>
<View style={styles.suggestionsContainer}> <View style={styles.suggestionsContainer}>
<Text style={[styles.suggestionsTitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[
styles.suggestionsTitle,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
Try asking: Try asking:
</Text> </Text>
<View style={styles.suggestionsGrid}> <View style={styles.suggestionsGrid}>
@ -904,7 +1127,12 @@ const AIChatScreen: React.FC = () => {
{isLoading && ( {isLoading && (
<View style={styles.typingIndicator}> <View style={styles.typingIndicator}>
<View style={[styles.typingBubble, { backgroundColor: 'rgba(255,255,255,0.06)' }]}> <View
style={[
styles.typingBubble,
{ backgroundColor: 'rgba(255,255,255,0.06)' },
]}
>
<View style={styles.typingDots}> <View style={styles.typingDots}>
<TypingDot delay={0} color={currentTheme.colors.primary} /> <TypingDot delay={0} color={currentTheme.colors.primary} />
<TypingDot delay={150} color={currentTheme.colors.primary} /> <TypingDot delay={150} color={currentTheme.colors.primary} />
@ -916,30 +1144,55 @@ const AIChatScreen: React.FC = () => {
</ScrollView> </ScrollView>
{/* Input Container */} {/* Input Container */}
<SafeAreaView edges={['bottom']} style={{ backgroundColor: 'transparent' }}> <SafeAreaView
<Animated.View style={[ edges={['bottom']}
style={{ backgroundColor: 'transparent' }}
>
<Animated.View
style={[
styles.inputContainer, styles.inputContainer,
{ {
backgroundColor: 'transparent', backgroundColor: 'transparent',
paddingBottom: 12 paddingBottom: 12,
}, },
inputAnimatedStyle inputAnimatedStyle,
]}> ]}
>
<View style={[styles.inputWrapper, { backgroundColor: 'transparent' }]}> <View style={[styles.inputWrapper, { backgroundColor: 'transparent' }]}>
<View style={styles.inputBlurBackdrop} pointerEvents="none"> <View style={styles.inputBlurBackdrop} pointerEvents="none">
{Platform.OS === 'android' && AndroidBlurView {Platform.OS === 'android' && AndroidBlurView ? (
? <AndroidBlurView blurAmount={10} blurRadius={4} style={StyleSheet.absoluteFill} /> <AndroidBlurView
: Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable blurAmount={10}
? <GlassViewComp style={StyleSheet.absoluteFill} glassEffectStyle="regular" /> blurRadius={4}
: <ExpoBlurView intensity={50} tint="dark" style={StyleSheet.absoluteFill} />} style={StyleSheet.absoluteFill}
<View style={[StyleSheet.absoluteFill, { backgroundColor: Platform.OS === 'android' ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.25)' }]} /> />
) : Platform.OS === 'ios' && GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={StyleSheet.absoluteFill}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView
intensity={50}
tint="dark"
style={StyleSheet.absoluteFill}
/>
)}
<View
style={[
StyleSheet.absoluteFill,
{
backgroundColor:
Platform.OS === 'android'
? 'rgba(0,0,0,0.15)'
: 'rgba(0,0,0,0.25)',
},
]}
/>
</View> </View>
<TextInput <TextInput
ref={inputRef} ref={inputRef}
style={[ style={[styles.textInput, { color: currentTheme.colors.highEmphasis }]}
styles.textInput,
{ color: currentTheme.colors.highEmphasis }
]}
value={inputText} value={inputText}
onChangeText={setInputText} onChangeText={setInputText}
placeholder="Ask about this content..." placeholder="Ask about this content..."
@ -959,7 +1212,10 @@ const AIChatScreen: React.FC = () => {
> >
{inputText.trim() ? ( {inputText.trim() ? (
<LinearGradient <LinearGradient
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}DD`]} colors={[
currentTheme.colors.primary,
`${currentTheme.colors.primary}DD`,
]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.sendButton} style={styles.sendButton}
@ -967,7 +1223,12 @@ const AIChatScreen: React.FC = () => {
<MaterialIcons name="arrow-upward" size={22} color="white" /> <MaterialIcons name="arrow-upward" size={22} color="white" />
</LinearGradient> </LinearGradient>
) : ( ) : (
<View style={[styles.sendButton, { backgroundColor: 'rgba(255,255,255,0.08)' }]}> <View
style={[
styles.sendButton,
{ backgroundColor: 'rgba(255,255,255,0.08)' },
]}
>
<MaterialIcons <MaterialIcons
name="arrow-upward" name="arrow-upward"
size={22} size={22}

View file

@ -33,24 +33,26 @@ const AISettingsScreen: React.FC = () => {
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState(''); const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([ const [alertActions, setAlertActions] = useState<
{ label: 'OK', onPress: () => setAlertVisible(false) }, Array<{ label: string; onPress: () => void; style?: object }>
]); >([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
const openAlert = ( const openAlert = (
title: string, title: string,
message: string, message: string,
actions?: Array<{ label: string; onPress?: () => void; style?: object }> actions?: Array<{ label: string; onPress?: () => void; style?: object }>,
) => { ) => {
setAlertTitle(title); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
if (actions && actions.length > 0) { if (actions && actions.length > 0) {
setAlertActions( setAlertActions(
actions.map(a => ({ actions.map((a) => ({
label: a.label, label: a.label,
style: a.style, style: a.style,
onPress: () => { a.onPress?.(); }, onPress: () => {
})) a.onPress?.();
},
})),
); );
} else { } else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
@ -139,9 +141,9 @@ const AISettingsScreen: React.FC = () => {
t('ai_settings.confirm_remove_title'), t('ai_settings.confirm_remove_title'),
t('ai_settings.confirm_remove_msg'), t('ai_settings.confirm_remove_msg'),
[ [
{ label: t('common.cancel'), onPress: () => { } }, { label: t('common.cancel'), onPress: () => {} },
{ {
label: 'Remove', label: t('common.remove'),
onPress: async () => { onPress: async () => {
try { try {
await mmkvStorage.removeItem('openrouter_api_key'); await mmkvStorage.removeItem('openrouter_api_key');
@ -151,9 +153,9 @@ const AISettingsScreen: React.FC = () => {
} catch (error) { } catch (error) {
openAlert(t('common.error'), t('ai_settings.error_remove')); openAlert(t('common.error'), t('ai_settings.error_remove'));
} }
} },
} },
] ],
); );
}; };
@ -162,7 +164,12 @@ const AISettingsScreen: React.FC = () => {
}; };
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <SafeAreaView
style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground },
]}
>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
{/* Header */} {/* Header */}
@ -196,43 +203,91 @@ const AISettingsScreen: React.FC = () => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Info Card */} {/* Info Card */}
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <View
style={[
styles.infoCard,
{ backgroundColor: currentTheme.colors.elevation1 },
]}
>
<View style={styles.infoHeader}> <View style={styles.infoHeader}>
<MaterialIcons <MaterialIcons
name="smart-toy" name="smart-toy"
size={24} size={24}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
/> />
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}> <Text
style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}
>
{t('ai_settings.info_title')} {t('ai_settings.info_title')}
</Text> </Text>
</View> </View>
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[
styles.infoDescription,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.info_desc')} {t('ai_settings.info_desc')}
</Text> </Text>
<View style={styles.featureList}> <View style={styles.featureList}>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.feature_1')} {t('ai_settings.feature_1')}
</Text> </Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.feature_2')} {t('ai_settings.feature_2')}
</Text> </Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.feature_3')} {t('ai_settings.feature_3')}
</Text> </Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.feature_4')} {t('ai_settings.feature_4')}
</Text> </Text>
</View> </View>
@ -240,8 +295,12 @@ const AISettingsScreen: React.FC = () => {
</View> </View>
{/* API Key Configuration */} {/* API Key Configuration */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}> <View
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}> style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}
>
<Text
style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}
>
{t('ai_settings.api_key_section')} {t('ai_settings.api_key_section')}
</Text> </Text>
@ -249,7 +308,12 @@ const AISettingsScreen: React.FC = () => {
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
{t('ai_settings.api_key_label')} {t('ai_settings.api_key_label')}
</Text> </Text>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[
styles.description,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.api_key_desc')} {t('ai_settings.api_key_desc')}
</Text> </Text>
@ -259,8 +323,8 @@ const AISettingsScreen: React.FC = () => {
{ {
backgroundColor: currentTheme.colors.elevation2, backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis, color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2 borderColor: currentTheme.colors.elevation2,
} },
]} ]}
value={apiKey} value={apiKey}
onChangeText={setApiKey} onChangeText={setApiKey}
@ -273,21 +337,35 @@ const AISettingsScreen: React.FC = () => {
<View style={styles.modelSection}> <View style={styles.modelSection}>
<View style={styles.modelHeader}> <View style={styles.modelHeader}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}> <Text
Model style={[styles.label, { color: currentTheme.colors.highEmphasis }]}
>
{t('ai_settings.model')}
</Text> </Text>
<Switch <Switch
value={useDefaultModel} value={useDefaultModel}
onValueChange={setUseDefaultModel} onValueChange={setUseDefaultModel}
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }} trackColor={{
thumbColor={useDefaultModel ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis} false: currentTheme.colors.elevation2,
true: currentTheme.colors.primary,
}}
thumbColor={
useDefaultModel
? currentTheme.colors.white
: currentTheme.colors.mediumEmphasis
}
ios_backgroundColor={currentTheme.colors.elevation2} ios_backgroundColor={currentTheme.colors.elevation2}
/> />
</View> </View>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[
styles.description,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{useDefaultModel {useDefaultModel
? `Using ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).` ? `${t('ai_settings.using')} ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).`
: 'Use a custom OpenRouter model ID (useful for paid plans).'} : t('ai_settings.paid_routing')}
</Text> </Text>
{!useDefaultModel && ( {!useDefaultModel && (
<TextInput <TextInput
@ -296,8 +374,8 @@ const AISettingsScreen: React.FC = () => {
{ {
backgroundColor: currentTheme.colors.elevation2, backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis, color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2 borderColor: currentTheme.colors.elevation2,
} },
]} ]}
value={customModel} value={customModel}
onChangeText={setCustomModel} onChangeText={setCustomModel}
@ -312,7 +390,10 @@ const AISettingsScreen: React.FC = () => {
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
{!isKeySet ? ( {!isKeySet ? (
<TouchableOpacity <TouchableOpacity
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary }]} style={[
styles.saveButton,
{ backgroundColor: currentTheme.colors.primary },
]}
onPress={handleSaveApiKey} onPress={handleSaveApiKey}
disabled={loading} disabled={loading}
> >
@ -329,7 +410,10 @@ const AISettingsScreen: React.FC = () => {
) : ( ) : (
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
<TouchableOpacity <TouchableOpacity
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]} style={[
styles.updateButton,
{ backgroundColor: currentTheme.colors.primary },
]}
onPress={handleSaveApiKey} onPress={handleSaveApiKey}
disabled={loading} disabled={loading}
> >
@ -343,7 +427,10 @@ const AISettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]} style={[
styles.removeButton,
{ borderColor: currentTheme.colors.error },
]}
onPress={handleRemoveApiKey} onPress={handleRemoveApiKey}
> >
<MaterialIcons <MaterialIcons
@ -352,7 +439,12 @@ const AISettingsScreen: React.FC = () => {
color={currentTheme.colors.error} color={currentTheme.colors.error}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}> <Text
style={[
styles.removeButtonText,
{ color: currentTheme.colors.error },
]}
>
{t('ai_settings.remove')} {t('ai_settings.remove')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -361,7 +453,10 @@ const AISettingsScreen: React.FC = () => {
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]} style={[
styles.getKeyButton,
{ backgroundColor: currentTheme.colors.elevation2 },
]}
onPress={handleGetApiKey} onPress={handleGetApiKey}
> >
<MaterialIcons <MaterialIcons
@ -370,7 +465,12 @@ const AISettingsScreen: React.FC = () => {
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={[styles.getKeyButtonText, { color: currentTheme.colors.primary }]}> <Text
style={[
styles.getKeyButtonText,
{ color: currentTheme.colors.primary },
]}
>
{t('ai_settings.get_free_key')} {t('ai_settings.get_free_key')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -378,51 +478,106 @@ const AISettingsScreen: React.FC = () => {
</View> </View>
{/* Enable Toggle (top) */} {/* Enable Toggle (top) */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}> <View
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>{t('ai_settings.enable_chat')}</Text> >
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
{t('ai_settings.enable_chat')}
</Text>
<Switch <Switch
value={!!settings.aiChatEnabled} value={!!settings.aiChatEnabled}
onValueChange={(v) => updateSetting('aiChatEnabled', v)} onValueChange={(v) => updateSetting('aiChatEnabled', v)}
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }} trackColor={{
thumbColor={settings.aiChatEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis} false: currentTheme.colors.elevation2,
true: currentTheme.colors.primary,
}}
thumbColor={
settings.aiChatEnabled
? currentTheme.colors.white
: currentTheme.colors.mediumEmphasis
}
ios_backgroundColor={currentTheme.colors.elevation2} ios_backgroundColor={currentTheme.colors.elevation2}
/> />
</View> </View>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis, marginTop: 8 }]}>{t('ai_settings.enable_chat_desc')}</Text> <Text
style={[
styles.description,
{ color: currentTheme.colors.mediumEmphasis, marginTop: 8 },
]}
>
{t('ai_settings.enable_chat_desc')}
</Text>
</View> </View>
{/* Status Card */} {/* Status Card */}
{isKeySet && ( {isKeySet && (
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <View
style={[
styles.statusCard,
{ backgroundColor: currentTheme.colors.elevation1 },
]}
>
<View style={styles.statusHeader}> <View style={styles.statusHeader}>
<MaterialIcons <MaterialIcons
name="check-circle" name="check-circle"
size={24} size={24}
color={currentTheme.colors.success || '#4CAF50'} color={currentTheme.colors.success || '#4CAF50'}
/> />
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}> <Text
style={[
styles.statusTitle,
{ color: currentTheme.colors.success || '#4CAF50' },
]}
>
{t('ai_settings.chat_enabled')} {t('ai_settings.chat_enabled')}
</Text> </Text>
</View> </View>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[
styles.statusDescription,
{ color: currentTheme.colors.mediumEmphasis },
]}
>
{t('ai_settings.chat_enabled_desc')} {t('ai_settings.chat_enabled_desc')}
</Text> </Text>
</View> </View>
)} )}
{/* Usage Info */} {/* Usage Info */}
<View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <View
<Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}> style={[
styles.usageCard,
{ backgroundColor: currentTheme.colors.elevation1 },
]}
>
<Text
style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}
>
{t('ai_settings.how_it_works')} {t('ai_settings.how_it_works')}
</Text> </Text>
<Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}> <Text
style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}
>
{t('ai_settings.how_it_works_desc')} {t('ai_settings.how_it_works_desc')}
</Text> </Text>
</View> </View>
{/* OpenRouter branding */} {/* OpenRouter branding */}
<View style={{ alignItems: 'center', marginTop: 16, marginBottom: 32 }}> <View style={{ alignItems: 'center', marginTop: 16, marginBottom: 32 }}>
<SvgXml xml={OPENROUTER_SVG.replace(/CURRENTCOLOR/g, currentTheme.colors.mediumEmphasis)} width={180} height={60} /> <SvgXml
xml={OPENROUTER_SVG.replace(
/CURRENTCOLOR/g,
currentTheme.colors.mediumEmphasis,
)}
width={180}
height={60}
/>
</View> </View>
</ScrollView> </ScrollView>
<CustomAlert <CustomAlert
@ -445,7 +600,8 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8, paddingTop:
Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
}, },
backButton: { backButton: {
flexDirection: 'row', flexDirection: 'row',