mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 08:41:57 +00:00
Improved Localization in AI Screens
This commit is contained in:
parent
07542d2838
commit
60ca025ece
4 changed files with 2172 additions and 1739 deletions
|
|
@ -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..."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"go_back": "Torna indietro",
|
||||
"settings": "Impostazioni",
|
||||
"close": "Chiudi",
|
||||
"remove":"Rimuovi",
|
||||
"enable": "Abilita",
|
||||
"disable": "Disabilita",
|
||||
"show_more": "Mostra altro",
|
||||
|
|
@ -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",
|
||||
|
|
@ -1543,5 +1548,8 @@
|
|||
"user_id": "ID Utente",
|
||||
"display_name": "Nickname",
|
||||
"display_name_placeholder": "Aggiungi un nickname"
|
||||
},
|
||||
"ai_chat_screen": {
|
||||
"loading": "Caricamento contesto AI in corso..."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -14,7 +20,12 @@ import {
|
|||
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';
|
||||
|
|
@ -39,14 +50,28 @@ if (Platform.OS === 'ios') {
|
|||
// 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;
|
||||
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, {
|
||||
|
|
@ -59,8 +84,9 @@ import Animated, {
|
|||
withDelay,
|
||||
interpolate,
|
||||
Extrapolate,
|
||||
runOnJS
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
|
@ -84,7 +110,10 @@ interface ChatBubbleProps {
|
|||
}
|
||||
|
||||
// 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 scale = useSharedValue(1);
|
||||
|
||||
|
|
@ -94,22 +123,22 @@ const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color })
|
|||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 400 }),
|
||||
withTiming(0.3, { duration: 400 })
|
||||
withTiming(0.3, { duration: 400 }),
|
||||
),
|
||||
-1,
|
||||
false
|
||||
)
|
||||
false,
|
||||
),
|
||||
);
|
||||
scale.value = withDelay(
|
||||
delay,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.2, { duration: 400 }),
|
||||
withTiming(1, { duration: 400 })
|
||||
withTiming(1, { duration: 400 }),
|
||||
),
|
||||
-1,
|
||||
false
|
||||
)
|
||||
false,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
|
@ -119,11 +148,14 @@ const TypingDot: React.FC<{ delay: number; color: string }> = ({ delay, color })
|
|||
}));
|
||||
|
||||
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 isUser = message.role === 'user';
|
||||
|
||||
|
|
@ -141,31 +173,36 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
bubbleAnimation.value,
|
||||
[0, 1],
|
||||
[16, 0],
|
||||
Extrapolate.CLAMP
|
||||
)
|
||||
Extrapolate.CLAMP,
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
bubbleAnimation.value,
|
||||
[0, 1],
|
||||
[0.95, 1],
|
||||
Extrapolate.CLAMP
|
||||
)
|
||||
}
|
||||
]
|
||||
Extrapolate.CLAMP,
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.messageContainer,
|
||||
isUser ? styles.userMessageContainer : styles.assistantMessageContainer,
|
||||
isLast && styles.lastMessageContainer,
|
||||
animatedStyle
|
||||
]}>
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{!isUser && (
|
||||
<View style={styles.avatarWrapper}>
|
||||
<LinearGradient
|
||||
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}99`]}
|
||||
colors={[
|
||||
currentTheme.colors.primary,
|
||||
`${currentTheme.colors.primary}99`,
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.avatarContainer}
|
||||
|
|
@ -175,9 +212,11 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
</View>
|
||||
)}
|
||||
|
||||
<View style={[
|
||||
<View
|
||||
style={[
|
||||
styles.messageBubble,
|
||||
isUser ? [
|
||||
isUser
|
||||
? [
|
||||
styles.userBubble,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
|
|
@ -186,20 +225,37 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
}
|
||||
] : [
|
||||
styles.assistantBubble,
|
||||
{ backgroundColor: 'transparent' }
|
||||
},
|
||||
]
|
||||
]}>
|
||||
: [styles.assistantBubble, { backgroundColor: 'transparent' }],
|
||||
]}
|
||||
>
|
||||
{!isUser && (
|
||||
<View style={styles.assistantBlurBackdrop} pointerEvents="none">
|
||||
{Platform.OS === 'android' && AndroidBlurView
|
||||
? <AndroidBlurView blurAmount={18} blurRadius={10} style={StyleSheet.absoluteFill} />
|
||||
: 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)' }]} />
|
||||
{Platform.OS === 'android' && AndroidBlurView ? (
|
||||
<AndroidBlurView
|
||||
blurAmount={18}
|
||||
blurRadius={10}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
) : 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>
|
||||
)}
|
||||
{isUser ? (
|
||||
|
|
@ -220,7 +276,7 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
paragraph: {
|
||||
marginBottom: 12,
|
||||
marginTop: 0,
|
||||
color: currentTheme.colors.highEmphasis
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 22,
|
||||
|
|
@ -247,7 +303,7 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
},
|
||||
link: {
|
||||
color: currentTheme.colors.primary,
|
||||
textDecorationLine: 'underline'
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
|
|
@ -282,23 +338,23 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
},
|
||||
bullet_list: {
|
||||
marginBottom: 10,
|
||||
marginTop: 0
|
||||
marginTop: 0,
|
||||
},
|
||||
ordered_list: {
|
||||
marginBottom: 10,
|
||||
marginTop: 0
|
||||
marginTop: 0,
|
||||
},
|
||||
list_item: {
|
||||
marginBottom: 6,
|
||||
color: currentTheme.colors.highEmphasis
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
color: currentTheme.colors.highEmphasis
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic',
|
||||
color: currentTheme.colors.mediumEmphasis
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
},
|
||||
blockquote: {
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
|
|
@ -337,29 +393,43 @@ const ChatBubble: React.FC<ChatBubbleProps> = React.memo(({ message, isLast }) =
|
|||
{message.content}
|
||||
</Markdown>
|
||||
)}
|
||||
<Text style={[
|
||||
<Text
|
||||
style={[
|
||||
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([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isUser && (
|
||||
<View style={[styles.userAvatarContainer, {
|
||||
<View
|
||||
style={[
|
||||
styles.userAvatarContainer,
|
||||
{
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
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>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
},
|
||||
(prev, next) => {
|
||||
return (
|
||||
prev.isLast === next.isLast &&
|
||||
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.timestamp === next.message.timestamp
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
interface SuggestionChipProps {
|
||||
text: string;
|
||||
|
|
@ -375,22 +446,32 @@ interface SuggestionChipProps {
|
|||
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 animValue = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
animValue.value = withDelay(
|
||||
index * 80,
|
||||
withSpring(1, { damping: 18, stiffness: 120 })
|
||||
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) },
|
||||
{
|
||||
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)',
|
||||
borderWidth: 1,
|
||||
borderColor: `${currentTheme.colors.primary}40`,
|
||||
}
|
||||
},
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
|
|
@ -414,7 +495,12 @@ const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPres
|
|||
color={currentTheme.colors.primary}
|
||||
style={styles.suggestionIcon}
|
||||
/>
|
||||
<Text style={[styles.suggestionText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.suggestionText,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
]}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<MaterialIcons
|
||||
|
|
@ -425,31 +511,40 @@ const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPres
|
|||
</TouchableOpacity>
|
||||
</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 = () => {
|
||||
// CustomAlert state
|
||||
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 {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 }>
|
||||
actions?: Array<{ label: string; onPress?: () => void; style?: object }>,
|
||||
) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
if (actions && actions.length > 0) {
|
||||
setAlertActions(
|
||||
actions.map(a => ({
|
||||
actions.map((a) => ({
|
||||
label: a.label,
|
||||
style: a.style,
|
||||
onPress: () => { a.onPress?.(); setAlertVisible(false); },
|
||||
}))
|
||||
onPress: () => {
|
||||
a.onPress?.();
|
||||
setAlertVisible(false);
|
||||
},
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
|
|
@ -461,7 +556,14 @@ const AIChatScreen: React.FC = () => {
|
|||
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<ChatMessage[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
|
|
@ -480,7 +582,7 @@ const AIChatScreen: React.FC = () => {
|
|||
setBackdropUrl(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('keyboardDidShow', onShow);
|
||||
const hideSub = Platform.OS === 'ios'
|
||||
const hideSub =
|
||||
Platform.OS === 'ios'
|
||||
? Keyboard.addListener('keyboardWillHide', onHide)
|
||||
: Keyboard.addListener('keyboardDidHide', onHide);
|
||||
|
||||
|
|
@ -582,7 +686,7 @@ const AIChatScreen: React.FC = () => {
|
|||
|
||||
const [showData, allEpisodes] = await Promise.all([
|
||||
tmdbService.getTVShowDetails(tmdbNumericId),
|
||||
tmdbService.getAllEpisodes(tmdbNumericId)
|
||||
tmdbService.getAllEpisodes(tmdbNumericId),
|
||||
]);
|
||||
|
||||
if (!showData) throw new Error('Unable to load TV show details');
|
||||
|
|
@ -600,18 +704,21 @@ const AIChatScreen: React.FC = () => {
|
|||
openAlert('Error', 'Failed to load content details for AI chat');
|
||||
} finally {
|
||||
setIsLoadingContext(false);
|
||||
{/* CustomAlert at root */ }
|
||||
{
|
||||
/* CustomAlert at root */
|
||||
}
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
actions={alertActions}
|
||||
/>
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = useCallback(async (messageText: string) => {
|
||||
const sendMessage = useCallback(
|
||||
async (messageText: string) => {
|
||||
if (!messageText.trim() || !context || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
|
|
@ -621,7 +728,7 @@ const AIChatScreen: React.FC = () => {
|
|||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
setSuggestions([]); // Hide suggestions after first message
|
||||
|
|
@ -631,7 +738,9 @@ const AIChatScreen: React.FC = () => {
|
|||
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);
|
||||
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]})`;
|
||||
|
|
@ -639,10 +748,19 @@ const AIChatScreen: React.FC = () => {
|
|||
} 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);
|
||||
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) {
|
||||
|
|
@ -659,16 +777,22 @@ const AIChatScreen: React.FC = () => {
|
|||
} else {
|
||||
tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId);
|
||||
if (!tmdbNumericId && episodeId) {
|
||||
tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId);
|
||||
tmdbNumericId =
|
||||
await tmdbService.extractTMDBIdFromStremioId(episodeId);
|
||||
}
|
||||
}
|
||||
if (tmdbNumericId) {
|
||||
const [showData, episodeData] = await Promise.all([
|
||||
tmdbService.getTVShowDetails(tmdbNumericId),
|
||||
tmdbService.getEpisodeDetails(tmdbNumericId, season, episode)
|
||||
tmdbService.getEpisodeDetails(tmdbNumericId, season, episode),
|
||||
]);
|
||||
if (showData && episodeData) {
|
||||
requestContext = createEpisodeContext(episodeData, showData, season, episode);
|
||||
requestContext = createEpisodeContext(
|
||||
episodeData,
|
||||
showData,
|
||||
season,
|
||||
episode,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -678,7 +802,7 @@ const AIChatScreen: React.FC = () => {
|
|||
const response = await aiService.sendMessage(
|
||||
messageText.trim(),
|
||||
requestContext,
|
||||
messages
|
||||
messages,
|
||||
);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
|
|
@ -688,22 +812,33 @@ const AIChatScreen: React.FC = () => {
|
|||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
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.';
|
||||
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.';
|
||||
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.';
|
||||
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(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, errorResponse]);
|
||||
setMessages((prev) => [...prev, errorResponse]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [context, messages, isLoading]);
|
||||
},
|
||||
[context, messages, isLoading],
|
||||
);
|
||||
|
||||
const handleSendPress = useCallback(() => {
|
||||
sendMessage(inputText);
|
||||
}, [inputText, sendMessage]);
|
||||
|
||||
const handleSuggestionPress = useCallback((suggestion: string) => {
|
||||
const handleSuggestionPress = useCallback(
|
||||
(suggestion: string) => {
|
||||
sendMessage(suggestion);
|
||||
}, [sendMessage]);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
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
|
||||
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(() => ({
|
||||
|
|
@ -763,11 +905,18 @@ const AIChatScreen: React.FC = () => {
|
|||
|
||||
if (isLoadingContext) {
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.loadingContainer,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground },
|
||||
]}
|
||||
>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Loading AI context...
|
||||
<Text
|
||||
style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}
|
||||
>
|
||||
{t('ai_chat_screen.loading')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -775,7 +924,13 @@ const AIChatScreen: React.FC = () => {
|
|||
|
||||
return (
|
||||
<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 && (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
<FastImage
|
||||
|
|
@ -783,52 +938,96 @@ const AIChatScreen: React.FC = () => {
|
|||
style={StyleSheet.absoluteFill}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{Platform.OS === 'android' && AndroidBlurView
|
||||
? <AndroidBlurView blurAmount={12} blurRadius={6} style={StyleSheet.absoluteFill} />
|
||||
: 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)' }]} />
|
||||
{Platform.OS === 'android' && AndroidBlurView ? (
|
||||
<AndroidBlurView
|
||||
blurAmount={12}
|
||||
blurRadius={6}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
) : 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>
|
||||
)}
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
<Animated.View style={[
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
paddingTop: Platform.OS === 'ios' ? insets.top : insets.top
|
||||
paddingTop: Platform.OS === 'ios' ? insets.top : insets.top,
|
||||
},
|
||||
headerAnimatedStyle
|
||||
]}>
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<View style={styles.headerContent}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
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)();
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
navigation.goBack();
|
||||
}
|
||||
}}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.headerTitle,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
]}
|
||||
>
|
||||
AI Chat
|
||||
</Text>
|
||||
<Text style={[styles.headerSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.headerSubtitle,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{getDisplayTitle()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<LinearGradient
|
||||
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}CC`]}
|
||||
colors={[
|
||||
currentTheme.colors.primary,
|
||||
`${currentTheme.colors.primary}CC`,
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.aiIndicator}
|
||||
|
|
@ -849,7 +1048,7 @@ const AIChatScreen: React.FC = () => {
|
|||
style={styles.messagesContainer}
|
||||
contentContainerStyle={[
|
||||
styles.messagesContent,
|
||||
{ paddingBottom: isKeyboardVisible ? 20 : (56 + (isLoading ? 20 : 0)) }
|
||||
{ paddingBottom: isKeyboardVisible ? 20 : 56 + (isLoading ? 20 : 0) },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews
|
||||
|
|
@ -859,25 +1058,49 @@ const AIChatScreen: React.FC = () => {
|
|||
{messages.length === 0 && suggestions.length > 0 && (
|
||||
<View style={styles.welcomeContainer}>
|
||||
<LinearGradient
|
||||
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}99`]}
|
||||
colors={[
|
||||
currentTheme.colors.primary,
|
||||
`${currentTheme.colors.primary}99`,
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.welcomeIcon}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={34} color="white" />
|
||||
</LinearGradient>
|
||||
<Text style={[styles.welcomeTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.welcomeTitle,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
]}
|
||||
>
|
||||
Ask me anything about
|
||||
</Text>
|
||||
<Text style={[styles.welcomeSubtitle, { color: currentTheme.colors.primary }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.welcomeSubtitle,
|
||||
{ color: currentTheme.colors.primary },
|
||||
]}
|
||||
>
|
||||
{getDisplayTitle()}
|
||||
</Text>
|
||||
<Text 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
|
||||
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>
|
||||
|
||||
<View style={styles.suggestionsContainer}>
|
||||
<Text style={[styles.suggestionsTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.suggestionsTitle,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
Try asking:
|
||||
</Text>
|
||||
<View style={styles.suggestionsGrid}>
|
||||
|
|
@ -904,7 +1127,12 @@ const AIChatScreen: React.FC = () => {
|
|||
|
||||
{isLoading && (
|
||||
<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}>
|
||||
<TypingDot delay={0} color={currentTheme.colors.primary} />
|
||||
<TypingDot delay={150} color={currentTheme.colors.primary} />
|
||||
|
|
@ -916,30 +1144,55 @@ const AIChatScreen: React.FC = () => {
|
|||
</ScrollView>
|
||||
|
||||
{/* Input Container */}
|
||||
<SafeAreaView edges={['bottom']} style={{ backgroundColor: 'transparent' }}>
|
||||
<Animated.View style={[
|
||||
<SafeAreaView
|
||||
edges={['bottom']}
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
{
|
||||
backgroundColor: 'transparent',
|
||||
paddingBottom: 12
|
||||
paddingBottom: 12,
|
||||
},
|
||||
inputAnimatedStyle
|
||||
]}>
|
||||
inputAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.inputWrapper, { backgroundColor: 'transparent' }]}>
|
||||
<View style={styles.inputBlurBackdrop} pointerEvents="none">
|
||||
{Platform.OS === 'android' && AndroidBlurView
|
||||
? <AndroidBlurView blurAmount={10} blurRadius={4} style={StyleSheet.absoluteFill} />
|
||||
: 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)' }]} />
|
||||
{Platform.OS === 'android' && AndroidBlurView ? (
|
||||
<AndroidBlurView
|
||||
blurAmount={10}
|
||||
blurRadius={4}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
) : 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>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[
|
||||
styles.textInput,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
]}
|
||||
style={[styles.textInput, { color: currentTheme.colors.highEmphasis }]}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder="Ask about this content..."
|
||||
|
|
@ -959,7 +1212,10 @@ const AIChatScreen: React.FC = () => {
|
|||
>
|
||||
{inputText.trim() ? (
|
||||
<LinearGradient
|
||||
colors={[currentTheme.colors.primary, `${currentTheme.colors.primary}DD`]}
|
||||
colors={[
|
||||
currentTheme.colors.primary,
|
||||
`${currentTheme.colors.primary}DD`,
|
||||
]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.sendButton}
|
||||
|
|
@ -967,7 +1223,12 @@ const AIChatScreen: React.FC = () => {
|
|||
<MaterialIcons name="arrow-upward" size={22} color="white" />
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={[styles.sendButton, { backgroundColor: 'rgba(255,255,255,0.08)' }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.sendButton,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.08)' },
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-upward"
|
||||
size={22}
|
||||
|
|
|
|||
|
|
@ -33,24 +33,26 @@ const AISettingsScreen: React.FC = () => {
|
|||
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 [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 }>
|
||||
actions?: Array<{ label: string; onPress?: () => void; style?: object }>,
|
||||
) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
if (actions && actions.length > 0) {
|
||||
setAlertActions(
|
||||
actions.map(a => ({
|
||||
actions.map((a) => ({
|
||||
label: a.label,
|
||||
style: a.style,
|
||||
onPress: () => { a.onPress?.(); },
|
||||
}))
|
||||
onPress: () => {
|
||||
a.onPress?.();
|
||||
},
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||
|
|
@ -141,7 +143,7 @@ const AISettingsScreen: React.FC = () => {
|
|||
[
|
||||
{ label: t('common.cancel'), onPress: () => {} },
|
||||
{
|
||||
label: 'Remove',
|
||||
label: t('common.remove'),
|
||||
onPress: async () => {
|
||||
try {
|
||||
await mmkvStorage.removeItem('openrouter_api_key');
|
||||
|
|
@ -151,9 +153,9 @@ const AISettingsScreen: React.FC = () => {
|
|||
} catch (error) {
|
||||
openAlert(t('common.error'), t('ai_settings.error_remove'));
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -162,7 +164,12 @@ const AISettingsScreen: React.FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<SafeAreaView
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground },
|
||||
]}
|
||||
>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
|
|
@ -196,43 +203,91 @@ const AISettingsScreen: React.FC = () => {
|
|||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Info Card */}
|
||||
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.infoCard,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.infoHeader}>
|
||||
<MaterialIcons
|
||||
name="smart-toy"
|
||||
size={24}
|
||||
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')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.infoDescription,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.info_desc')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.featureList}>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.feature_1')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.feature_2')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.feature_3')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.feature_4')}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -240,8 +295,12 @@ const AISettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
{/* API Key Configuration */}
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<View
|
||||
style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}
|
||||
>
|
||||
{t('ai_settings.api_key_section')}
|
||||
</Text>
|
||||
|
||||
|
|
@ -249,7 +308,12 @@ const AISettingsScreen: React.FC = () => {
|
|||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{t('ai_settings.api_key_label')}
|
||||
</Text>
|
||||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.api_key_desc')}
|
||||
</Text>
|
||||
|
||||
|
|
@ -259,8 +323,8 @@ const AISettingsScreen: React.FC = () => {
|
|||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.elevation2
|
||||
}
|
||||
borderColor: currentTheme.colors.elevation2,
|
||||
},
|
||||
]}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
|
|
@ -273,21 +337,35 @@ const AISettingsScreen: React.FC = () => {
|
|||
|
||||
<View style={styles.modelSection}>
|
||||
<View style={styles.modelHeader}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Model
|
||||
<Text
|
||||
style={[styles.label, { color: currentTheme.colors.highEmphasis }]}
|
||||
>
|
||||
{t('ai_settings.model')}
|
||||
</Text>
|
||||
<Switch
|
||||
value={useDefaultModel}
|
||||
onValueChange={setUseDefaultModel}
|
||||
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }}
|
||||
thumbColor={useDefaultModel ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
trackColor={{
|
||||
false: currentTheme.colors.elevation2,
|
||||
true: currentTheme.colors.primary,
|
||||
}}
|
||||
thumbColor={
|
||||
useDefaultModel
|
||||
? currentTheme.colors.white
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
ios_backgroundColor={currentTheme.colors.elevation2}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.description,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{useDefaultModel
|
||||
? `Using ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).`
|
||||
: 'Use a custom OpenRouter model ID (useful for paid plans).'}
|
||||
? `${t('ai_settings.using')} ${DEFAULT_OPENROUTER_MODEL} (free automatic routing).`
|
||||
: t('ai_settings.paid_routing')}
|
||||
</Text>
|
||||
{!useDefaultModel && (
|
||||
<TextInput
|
||||
|
|
@ -296,8 +374,8 @@ const AISettingsScreen: React.FC = () => {
|
|||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.elevation2
|
||||
}
|
||||
borderColor: currentTheme.colors.elevation2,
|
||||
},
|
||||
]}
|
||||
value={customModel}
|
||||
onChangeText={setCustomModel}
|
||||
|
|
@ -312,7 +390,10 @@ const AISettingsScreen: React.FC = () => {
|
|||
<View style={styles.buttonContainer}>
|
||||
{!isKeySet ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
]}
|
||||
onPress={handleSaveApiKey}
|
||||
disabled={loading}
|
||||
>
|
||||
|
|
@ -329,7 +410,10 @@ const AISettingsScreen: React.FC = () => {
|
|||
) : (
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
style={[
|
||||
styles.updateButton,
|
||||
{ backgroundColor: currentTheme.colors.primary },
|
||||
]}
|
||||
onPress={handleSaveApiKey}
|
||||
disabled={loading}
|
||||
>
|
||||
|
|
@ -343,7 +427,10 @@ const AISettingsScreen: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
|
||||
style={[
|
||||
styles.removeButton,
|
||||
{ borderColor: currentTheme.colors.error },
|
||||
]}
|
||||
onPress={handleRemoveApiKey}
|
||||
>
|
||||
<MaterialIcons
|
||||
|
|
@ -352,7 +439,12 @@ const AISettingsScreen: React.FC = () => {
|
|||
color={currentTheme.colors.error}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.removeButtonText,
|
||||
{ color: currentTheme.colors.error },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.remove')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
|
@ -361,7 +453,10 @@ const AISettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
style={[
|
||||
styles.getKeyButton,
|
||||
{ backgroundColor: currentTheme.colors.elevation2 },
|
||||
]}
|
||||
onPress={handleGetApiKey}
|
||||
>
|
||||
<MaterialIcons
|
||||
|
|
@ -370,7 +465,12 @@ const AISettingsScreen: React.FC = () => {
|
|||
color={currentTheme.colors.primary}
|
||||
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')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
|
@ -378,51 +478,106 @@ const AISettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
|
||||
{/* Enable Toggle (top) */}
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>{t('ai_settings.enable_chat')}</Text>
|
||||
<View
|
||||
style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{t('ai_settings.enable_chat')}
|
||||
</Text>
|
||||
<Switch
|
||||
value={!!settings.aiChatEnabled}
|
||||
onValueChange={(v) => updateSetting('aiChatEnabled', v)}
|
||||
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }}
|
||||
thumbColor={settings.aiChatEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
trackColor={{
|
||||
false: currentTheme.colors.elevation2,
|
||||
true: currentTheme.colors.primary,
|
||||
}}
|
||||
thumbColor={
|
||||
settings.aiChatEnabled
|
||||
? currentTheme.colors.white
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
ios_backgroundColor={currentTheme.colors.elevation2}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Status Card */}
|
||||
{isKeySet && (
|
||||
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusCard,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.statusHeader}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
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')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusDescription,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
]}
|
||||
>
|
||||
{t('ai_settings.chat_enabled_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Usage Info */}
|
||||
<View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.usageCard,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}
|
||||
>
|
||||
{t('ai_settings.how_it_works')}
|
||||
</Text>
|
||||
<Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<Text
|
||||
style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}
|
||||
>
|
||||
{t('ai_settings.how_it_works_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
{/* OpenRouter branding */}
|
||||
<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>
|
||||
</ScrollView>
|
||||
<CustomAlert
|
||||
|
|
@ -445,7 +600,8 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
|
||||
paddingTop:
|
||||
Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
|||
Loading…
Reference in a new issue