diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index cde9ab4d..1b6c8299 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -18,6 +18,7 @@ import Animated, { interpolate, Extrapolate, } from 'react-native-reanimated'; +import { useTranslation } from 'react-i18next'; import { useTheme } from '../../contexts/ThemeContext'; import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen'; import { getAgeRatingColor } from '../../utils/ageRatingColors'; @@ -53,6 +54,7 @@ const MetadataDetails: React.FC = ({ contentId, loadingMetadata = false, }) => { + const { t } = useTranslation(); const { currentTheme } = useTheme(); const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false); const [isMDBEnabled, setIsMDBEnabled] = useState(false); @@ -407,7 +409,7 @@ const MetadataDetails: React.FC = ({ fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14 } ]}> - {isFullDescriptionOpen ? 'Show Less' : 'Show More'} + {isFullDescriptionOpen ? t('common.show_less') : t('common.show_more')} =90% completion).", + "auto_add_label": "Auto Add Anime", + "auto_add_desc": "If an anime is not in your MyAnimeList list, it will be added automatically when you start watching.", + "auto_library_sync_label": "Auto-Sync to Library", + "auto_library_sync_desc": "Automatically add items from your MyAnimeList \"Watching\" list to your Nuvio Library.", + "include_nsfw_label": "Include NSFW Content", + "include_nsfw_desc": "Allow NSFW entries to be returned when fetching your MyAnimeList list." + }, "tmdb_settings": { "title": "TMDb Settings", "metadata_enrichment": "Metadata Enrichment", @@ -917,6 +954,46 @@ "success_removed": "API key removed successfully", "error_remove": "Failed to remove API key" }, + "ai_chat": { + "title": "AI Chat", + "loading_context": "Loading AI context...", + "welcome_title": "Ask me anything about", + "welcome_description": "I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more.", + "try_asking": "Try asking:", + "placeholder": "Ask about this content...", + "errors": { + "load_content_details": "Failed to load content details for AI chat", + "generic": "Sorry, I encountered an error. Please try again.", + "not_configured": "Please configure your OpenRouter API key in Settings > AI Assistant.", + "invalid_api_key": "OpenRouter rejected your API key. Please verify the key in Settings > AI Assistant.", + "quota": "OpenRouter quota/credits were rejected for this request. Please check your OpenRouter usage and limits.", + "model_unavailable": "The selected OpenRouter model is unavailable. Retry with `openrouter/free` or choose another custom model in Settings > AI Assistant.", + "connection": "Failed to connect to AI service. Please check your internet connection, API key, and OpenRouter model availability." + }, + "starters": { + "series": { + "overall": "What is {{title}} about overall?", + "arcs": "Summarize key arcs across all seasons", + "rated": "Which episodes are the highest rated and why?", + "pivotal": "List pivotal episodes for character development", + "themes": "How did themes evolve from Season 1 onward?" + }, + "episode": { + "overview": "What happened in this episode of {{showTitle}}?", + "plot_points": "Explain the main plot points of \"{{episodeTitle}}\"", + "character_development": "What character development occurred in this episode?", + "hidden_details": "Are there any hidden details or easter eggs I might have missed?", + "story_arc": "How does this episode connect to the overall story arc?" + }, + "movie": { + "overview": "What is {{title}} about?", + "themes": "Explain the themes in this movie", + "ending": "What's the significance of the ending?", + "characters": "Tell me about the main characters and their development", + "production_facts": "Are there any interesting production facts about this film?" + } + } + }, "catalog_settings": { "title": "Catalogs", "layout_phone": "LAYOUT CATALOGSCREEN (PHONE)", @@ -1505,4 +1582,4 @@ "no_logs_captured": "No logs captured." } } -} +} \ No newline at end of file diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 3115c2e4..5abd368e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -550,6 +550,43 @@ "conflict_msg": "Non puoi connettere Simkl mentre Trakt è connesso. Disconnetti prima Trakt.", "disclaimer": "Nuvio non è affiliato con Simkl." }, + "mal_settings": { + "title": "MyAnimeList", + "connect_title": "Connetti MyAnimeList", + "connect_desc": "Sincronizza la tua cronologia di visione e gestisci la tua lista anime.", + "sign_in": "Accedi con MyAnimeList", + "sign_out": "Disconnetti", + "sign_out_confirm": "Sei sicuro di voler disconnettere il tuo account MyAnimeList?", + "auth_success_title": "Connessione riuscita", + "auth_success_msg": "Il tuo account MyAnimeList e stato collegato correttamente.", + "auth_error_title": "Errore di autenticazione", + "auth_error_msg": "Impossibile completare l'autenticazione con MyAnimeList.", + "auth_error_generic": "Si \u00e8 verificato un errore durante l'autenticazione.", + "sync_complete_title": "Sincronizzazione completata", + "sync_error_title": "Sincronizzazione fallita", + "sync_success_msg": "I dati MyAnimeList sono stati aggiornati correttamente.", + "sync_error_msg": "Impossibile aggiornare i dati MyAnimeList. Riprova.", + "sync_now_button": "Sincronizza ora", + "sync_settings_title": "Impostazioni sincronizzazione", + "profile_id": "ID: {{id}}", + "stats_total": "Totale", + "stats_days": "Giorni", + "stats_mean": "Media", + "status_watching": "In visione", + "status_completed": "Completati", + "status_on_hold": "In pausa", + "status_dropped": "Interrotti", + "sync_enabled_label": "Abilita sincronizzazione MyAnimeList", + "sync_enabled_desc": "Interruttore globale per abilitare o disabilitare tutte le funzioni MyAnimeList.", + "auto_update_label": "Aggiornamento automatico episodi", + "auto_update_desc": "Aggiorna automaticamente i tuoi progressi su MyAnimeList quando completi un episodio (>=90% completamento).", + "auto_add_label": "Aggiunta automatica anime", + "auto_add_desc": "Se un anime non e presente nella tua lista MyAnimeList, verra aggiunto automaticamente quando inizi a guardarlo.", + "auto_library_sync_label": "Sincronizzazione automatica con la Libreria", + "auto_library_sync_desc": "Aggiunge automaticamente alla Libreria Nuvio gli elementi presenti nella tua lista MyAnimeList \"Watching\".", + "include_nsfw_label": "Includi contenuti NSFW", + "include_nsfw_desc": "Consenti il recupero di contenuti NSFW quando viene caricata la tua lista MyAnimeList." + }, "tmdb_settings": { "title": "Impostazioni TMDb", "metadata_enrichment": "Arricchimento metadati", @@ -660,6 +697,7 @@ "integrations": "Integrazioni", "playback": "Riproduzione", "backup_restore": "Backup e Ripristino", + "backup_restore_desc": "Crea e ripristina backup dell'app", "updates": "Aggiornamenti", "about": "Informazioni", "developer": "Sviluppatore", @@ -690,7 +728,8 @@ "media": "MEDIA", "notifications": "NOTIFICHE", "testing": "TEST", - "danger_zone": "ZONA PERICOLOSA" + "danger_zone": "ZONA PERICOLOSA", + "introdb_contribution": "Contributi IntroDB" }, "items": { "legal": "Note Legali & Disclaimer", @@ -751,7 +790,9 @@ "reset_campaigns": "Ripristina Campagne", "reset_campaigns_desc": "Cancella le impressioni delle campagne", "clear_all_data": "Cancella tutti i dati", - "clear_all_data_desc": "Ripristina tutte le impostazioni e i dati memorizzati" + "clear_all_data_desc": "Ripristina tutte le impostazioni e i dati memorizzati", + "enable_intro_submission": "Abilita l'aggiunta di dati sulle intro", + "enable_intro_submission_desc": "Contribuisci alla community IntroDB" }, "options": { "horizontal": "Orizzontale", @@ -794,7 +835,7 @@ "description": "Aggiorna dal cloud (pull) o usa le impostazioni di questo dispositivo come sorgente principale (push).", "pull_btn": "Pull dal Cloud", "push_btn": "Push dal Dispositivo", - "manage_account": "Gestiscci Account", + "manage_account": "Gestisci Account", "sign_out": "Disconnetti", "sign_in_up": "Accedi / Registrati" }, @@ -900,6 +941,46 @@ "success_removed": "Chiave API rimossa con successo", "error_remove": "Impossibile rimuovere la chiave API" }, + "ai_chat": { + "title": "Chat IA", + "loading_context": "Caricamento contesto IA...", + "welcome_title": "Chiedimi qualsiasi cosa su", + "welcome_description": "Ho una conoscenza dettagliata di questo contenuto e posso rispondere a domande su trama, personaggi, temi e altro.", + "try_asking": "Prova a chiedere:", + "placeholder": "Chiedi qualcosa su questo contenuto...", + "errors": { + "load_content_details": "Impossibile caricare i dettagli del contenuto per la chat IA", + "generic": "Mi dispiace, si \u00e8 verificato un errore. Riprova.", + "not_configured": "Configura la tua chiave API OpenRouter in Impostazioni > Assistente IA.", + "invalid_api_key": "OpenRouter ha rifiutato la tua chiave API. Verificala in Impostazioni > Assistente IA.", + "quota": "OpenRouter ha rifiutato la richiesta per limiti o crediti. Controlla utilizzo e limiti del tuo account.", + "model_unavailable": "Il modello OpenRouter selezionato non \u00e8 disponibile. Riprova con `openrouter/free` oppure scegli un altro modello personalizzato in Impostazioni > Assistente IA.", + "connection": "Impossibile connettersi al servizio IA. Controlla connessione internet, chiave API e disponibilit\u00e0 del modello OpenRouter." + }, + "starters": { + "series": { + "overall": "Di cosa parla {{title}} nel complesso?", + "arcs": "Riassumi gli archi narrativi principali di tutte le stagioni", + "rated": "Quali episodi hanno la valutazione pi\u00f9 alta e perch\u00e9?", + "pivotal": "Elenca gli episodi chiave per lo sviluppo dei personaggi", + "themes": "Come si evolvono i temi dalla Stagione 1 in poi?" + }, + "episode": { + "overview": "Cosa succede in questo episodio di {{showTitle}}?", + "plot_points": "Spiegami i punti principali della trama di \"{{episodeTitle}}\"", + "character_development": "Quale sviluppo dei personaggi avviene in questo episodio?", + "hidden_details": "Ci sono dettagli nascosti o easter egg che potrei essermi perso?", + "story_arc": "Come si collega questo episodio all'arco narrativo generale?" + }, + "movie": { + "overview": "Di cosa parla {{title}}?", + "themes": "Spiegami i temi di questo film", + "ending": "Qual \u00e8 il significato del finale?", + "characters": "Parlami dei personaggi principali e della loro evoluzione", + "production_facts": "Ci sono curiosit\u00e0 interessanti sulla produzione di questo film?" + } + } + }, "catalog_settings": { "title": "Cataloghi", "layout_phone": "LAYOUT SCHERMATA CATALOGO (TELEFONO)", @@ -1144,7 +1225,7 @@ "sync_desc": "Sincronizza automaticamente le notifiche per tutte le serie nella tua libreria e nella watchlist/collezione di Trakt.", "section_advanced": "Avanzate", "reset_button": "Ripristina tutte le notifiche", - "test_button": "Test Notifica (5 sec)", + "test_button": "Test Invio Notifica (5 sec)", "test_notification_in": "Notifica tra {{seconds}}s...", "test_notification_text": "La notifica apparirà tra {{seconds}} secondi", "alert_reset_title": "Ripristina Notifiche", @@ -1488,4 +1569,4 @@ "no_logs_captured": "Nessun log catturato." } } -} +} \ No newline at end of file diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx index a669bb5c..e25d1e85 100644 --- a/src/screens/AIChatScreen.tsx +++ b/src/screens/AIChatScreen.tsx @@ -20,6 +20,7 @@ import { useTheme } from '../contexts/ThemeContext'; import FastImage from '@d11/react-native-fast-image'; import { BlurView as ExpoBlurView } from 'expo-blur'; import { LinearGradient } from 'expo-linear-gradient'; +import { useTranslation } from 'react-i18next'; // Lazy-safe community blur import (avoid bundling issues on web) let AndroidBlurView: any = null; if (Platform.OS === 'android') { @@ -46,7 +47,7 @@ if (Platform.OS === 'ios') { } } import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'; -import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, createSeriesContext, generateConversationStarters } from '../services/aiService'; +import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, createSeriesContext } from '../services/aiService'; import { tmdbService } from '../services/tmdbService'; import Markdown from 'react-native-markdown-display'; import Animated, { @@ -428,13 +429,17 @@ const SuggestionChip: React.FC = React.memo(({ text, onPres }, (prev, next) => prev.text === next.text && prev.onPress === next.onPress && prev.index === next.index); const AIChatScreen: React.FC = () => { + const { t } = useTranslation(); + // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); const [alertMessage, setAlertMessage] = useState(''); - const [alertActions, setAlertActions] = useState void; style?: object }>>([ - { label: 'OK', onPress: () => setAlertVisible(false) }, - ]); + const [alertActions, setAlertActions] = useState void; style?: object }>>([]); + + const defaultAlertActions = useMemo(() => ([ + { label: t('common.ok'), onPress: () => setAlertVisible(false) }, + ]), [t]); const openAlert = ( title: string, @@ -452,7 +457,7 @@ const AIChatScreen: React.FC = () => { })) ); } else { - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertActions(defaultAlertActions); } setAlertVisible(true); }; @@ -536,11 +541,42 @@ const AIChatScreen: React.FC = () => { useEffect(() => { if (context && messages.length === 0) { - // Generate conversation starters - const starters = generateConversationStarters(context); + const starters = (() => { + if ('episodesBySeason' in (context as any)) { + const series = context as any; + return [ + t('ai_chat.starters.series.overall', { title: series.title }), + t('ai_chat.starters.series.arcs'), + t('ai_chat.starters.series.rated'), + t('ai_chat.starters.series.pivotal'), + t('ai_chat.starters.series.themes'), + ]; + } + + if ('showTitle' in (context as any)) { + const episode = context as any; + return [ + t('ai_chat.starters.episode.overview', { showTitle: episode.showTitle }), + t('ai_chat.starters.episode.plot_points', { episodeTitle: episode.episodeTitle }), + t('ai_chat.starters.episode.character_development'), + t('ai_chat.starters.episode.hidden_details'), + t('ai_chat.starters.episode.story_arc'), + ]; + } + + const movie = context as any; + return [ + t('ai_chat.starters.movie.overview', { title: movie.title }), + t('ai_chat.starters.movie.themes'), + t('ai_chat.starters.movie.ending'), + t('ai_chat.starters.movie.characters'), + t('ai_chat.starters.movie.production_facts'), + ]; + })(); + setSuggestions(starters); } - }, [context, messages.length]); + }, [context, messages.length, t]); const loadContext = async () => { try { @@ -597,7 +633,7 @@ const AIChatScreen: React.FC = () => { } } catch (error) { if (__DEV__) console.error('Error loading context:', error); - openAlert('Error', 'Failed to load content details for AI chat'); + openAlert(t('common.error'), t('ai_chat.errors.load_content_details')); } finally { setIsLoadingContext(false); {/* CustomAlert at root */ } @@ -692,18 +728,18 @@ const AIChatScreen: React.FC = () => { } catch (error) { if (__DEV__) console.error('Error sending message:', error); - let errorMessage = 'Sorry, I encountered an error. Please try again.'; + let errorMessage = t('ai_chat.errors.generic'); if (error instanceof Error) { if (error.message.includes('not configured')) { - errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.'; + errorMessage = t('ai_chat.errors.not_configured'); } 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 = t('ai_chat.errors.invalid_api_key'); } 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 = t('ai_chat.errors.quota'); } 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 = t('ai_chat.errors.model_unavailable'); } 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 = t('ai_chat.errors.connection'); } } @@ -718,7 +754,7 @@ const AIChatScreen: React.FC = () => { } finally { setIsLoading(false); } - }, [context, messages, isLoading]); + }, [context, messages, isLoading, t]); const handleSendPress = useCallback(() => { sendMessage(inputText); @@ -767,7 +803,7 @@ const AIChatScreen: React.FC = () => { - Loading AI context... + {t('ai_chat.loading_context')} ); @@ -820,7 +856,7 @@ const AIChatScreen: React.FC = () => { - AI Chat + {t('ai_chat.title')} {getDisplayTitle()} @@ -867,18 +903,18 @@ const AIChatScreen: React.FC = () => { - Ask me anything about + {t('ai_chat.welcome_title')} {getDisplayTitle()} - I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more. + {t('ai_chat.welcome_description')} - Try asking: + {t('ai_chat.try_asking')} {suggestions.map((suggestion, index) => ( @@ -942,7 +978,7 @@ const AIChatScreen: React.FC = () => { ]} value={inputText} onChangeText={setInputText} - placeholder="Ask about this content..." + placeholder={t('ai_chat.placeholder')} placeholderTextColor={currentTheme.colors.mediumEmphasis} multiline maxLength={500} diff --git a/src/screens/MalSettingsScreen.tsx b/src/screens/MalSettingsScreen.tsx index 15f1fa1c..326bcf72 100644 --- a/src/screens/MalSettingsScreen.tsx +++ b/src/screens/MalSettingsScreen.tsx @@ -1,16 +1,16 @@ import React, { useCallback, useEffect, useState } from 'react'; import { - View, - Text, - StyleSheet, - TouchableOpacity, - ActivityIndicator, - SafeAreaView, - ScrollView, - StatusBar, - Platform, - Switch, - Image, + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + SafeAreaView, + ScrollView, + StatusBar, + Platform, + Switch, + Image, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; @@ -21,535 +21,535 @@ import { MalSync } from '../services/mal/MalSync'; import { mmkvStorage } from '../services/mmkvStorage'; import { MalUser } from '../types/mal'; import { useTheme } from '../contexts/ThemeContext'; -import { colors } from '../styles'; import CustomAlert from '../components/CustomAlert'; import { useTranslation } from 'react-i18next'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const MalSettingsScreen: React.FC = () => { - const { t } = useTranslation(); - const navigation = useNavigation(); - const { currentTheme } = useTheme(); - - const [isLoading, setIsLoading] = useState(true); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [userProfile, setUserProfile] = useState(null); - - const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true); - const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true); - const [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true); - const [autoLibrarySyncEnabled, setAutoLibrarySyncEnabled] = useState(mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false); - const [includeNsfwEnabled, setIncludeNsfwEnabled] = useState(mmkvStorage.getBoolean('mal_include_nsfw') ?? true); + const { t } = useTranslation(); + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + const malName = t('mal_settings.title'); - const [alertVisible, setAlertVisible] = useState(false); - const [alertTitle, setAlertTitle] = useState(''); - const [alertMessage, setAlertMessage] = useState(''); - const [alertActions, setAlertActions] = useState void }>>([]); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userProfile, setUserProfile] = useState(null); - const openAlert = (title: string, message: string, actions?: any[]) => { - setAlertTitle(title); - setAlertMessage(message); - setAlertActions(actions || [{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); - setAlertVisible(true); - }; + const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true); + const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true); + const [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true); + const [autoLibrarySyncEnabled, setAutoLibrarySyncEnabled] = useState(mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false); + const [includeNsfwEnabled, setIncludeNsfwEnabled] = useState(mmkvStorage.getBoolean('mal_include_nsfw') ?? true); - const checkAuthStatus = useCallback(async () => { - setIsLoading(true); - try { - // Initialize Auth (loads from storage) - const token = MalAuth.getToken(); - - if (token && !MalAuth.isTokenExpired(token)) { - setIsAuthenticated(true); - // Fetch Profile - const profile = await MalApiService.getUserInfo(); - setUserProfile(profile); - } else if (token && MalAuth.isTokenExpired(token)) { - // Try refresh - const refreshed = await MalAuth.refreshToken(); - if (refreshed) { - setIsAuthenticated(true); - const profile = await MalApiService.getUserInfo(); - setUserProfile(profile); - } else { - setIsAuthenticated(false); - setUserProfile(null); - } - } else { - setIsAuthenticated(false); - setUserProfile(null); - } - } catch (error) { - console.error('[MalSettings] Auth check failed', error); - setIsAuthenticated(false); - } finally { - setIsLoading(false); - } - }, []); + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void }>>([]); - useEffect(() => { - checkAuthStatus(); - }, [checkAuthStatus]); + const openAlert = (title: string, message: string, actions?: any[]) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions || [{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + }; - const handleSignIn = async () => { - setIsLoading(true); - try { - const result = await MalAuth.login(); - if (result === true) { - await checkAuthStatus(); - openAlert('Success', 'Connected to MyAnimeList'); - } else { - const errorMessage = typeof result === 'string' ? result : 'Failed to connect to MyAnimeList'; - openAlert('Error', errorMessage); + const checkAuthStatus = useCallback(async () => { + setIsLoading(true); + try { + // Initialize Auth (loads from storage) + const token = MalAuth.getToken(); + + if (token && !MalAuth.isTokenExpired(token)) { + setIsAuthenticated(true); + // Fetch Profile + const profile = await MalApiService.getUserInfo(); + setUserProfile(profile); + } else if (token && MalAuth.isTokenExpired(token)) { + // Try refresh + const refreshed = await MalAuth.refreshToken(); + if (refreshed) { + setIsAuthenticated(true); + const profile = await MalApiService.getUserInfo(); + setUserProfile(profile); + } else { + setIsAuthenticated(false); + setUserProfile(null); + } + } else { + setIsAuthenticated(false); + setUserProfile(null); + } + } catch (error) { + console.error('[MalSettings] Auth check failed', error); + setIsAuthenticated(false); + } finally { + setIsLoading(false); } - } catch (e: any) { - console.error(e); - openAlert('Error', `An error occurred during sign in: ${e.message || 'Unknown error'}`); - } finally { - setIsLoading(false); - } - }; + }, []); - const handleSignOut = () => { - openAlert('Sign Out', 'Are you sure you want to disconnect?', [ - { label: 'Cancel', onPress: () => setAlertVisible(false) }, - { - label: 'Sign Out', - onPress: () => { - MalAuth.clearToken(); - setIsAuthenticated(false); - setUserProfile(null); - setAlertVisible(false); - } - } - ]); - }; + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); - const toggleSync = (val: boolean) => { - setSyncEnabled(val); - mmkvStorage.setBoolean('mal_enabled', val); - }; + const handleSignIn = async () => { + setIsLoading(true); + try { + const result = await MalAuth.login(); + if (result === true) { + await checkAuthStatus(); + openAlert(t('mal_settings.auth_success_title'), t('mal_settings.auth_success_msg')); + } else { + const errorMessage = typeof result === 'string' ? result : t('mal_settings.auth_error_msg'); + openAlert(t('mal_settings.auth_error_title'), errorMessage); + } + } catch (e: any) { + console.error(e); + openAlert(t('mal_settings.auth_error_title'), `${t('mal_settings.auth_error_generic')} ${e.message || t('common.unknown')}`); + } finally { + setIsLoading(false); + } + }; - const toggleAutoUpdate = (val: boolean) => { - setAutoUpdateEnabled(val); - mmkvStorage.setBoolean('mal_auto_update', val); - }; + const handleSignOut = () => { + openAlert(t('mal_settings.sign_out'), t('mal_settings.sign_out_confirm'), [ + { label: t('common.cancel'), onPress: () => setAlertVisible(false) }, + { + label: t('mal_settings.sign_out'), + onPress: () => { + MalAuth.clearToken(); + setIsAuthenticated(false); + setUserProfile(null); + setAlertVisible(false); + } + } + ]); + }; - const toggleAutoAdd = (val: boolean) => { - setAutoAddEnabled(val); - mmkvStorage.setBoolean('mal_auto_add', val); - }; + const toggleSync = (val: boolean) => { + setSyncEnabled(val); + mmkvStorage.setBoolean('mal_enabled', val); + }; - const toggleAutoLibrarySync = (val: boolean) => { - setAutoLibrarySyncEnabled(val); - mmkvStorage.setBoolean('mal_auto_sync_to_library', val); - }; + const toggleAutoUpdate = (val: boolean) => { + setAutoUpdateEnabled(val); + mmkvStorage.setBoolean('mal_auto_update', val); + }; - const toggleIncludeNsfw = (val: boolean) => { - setIncludeNsfwEnabled(val); - mmkvStorage.setBoolean('mal_include_nsfw', val); - }; + const toggleAutoAdd = (val: boolean) => { + setAutoAddEnabled(val); + mmkvStorage.setBoolean('mal_auto_add', val); + }; - return ( - - - - navigation.goBack()} - style={styles.backButton} - > - - - Settings - - - + const toggleAutoLibrarySync = (val: boolean) => { + setAutoLibrarySyncEnabled(val); + mmkvStorage.setBoolean('mal_auto_sync_to_library', val); + }; - - MyAnimeList - + const toggleIncludeNsfw = (val: boolean) => { + setIncludeNsfwEnabled(val); + mmkvStorage.setBoolean('mal_include_nsfw', val); + }; - - - {isLoading ? ( - - - - ) : isAuthenticated && userProfile ? ( - - - {userProfile.picture ? ( - - ) : ( - - {userProfile.name.charAt(0)} - - )} - - - {userProfile.name} - - - - - ID: {userProfile.id} - - - {userProfile.location && ( - - - - {userProfile.location} - - - )} - {userProfile.birthday && ( - - - - {userProfile.birthday} - - - )} + return ( + + + + navigation.goBack()} + style={styles.backButton} + > + + + {t('settings.settings_title')} + + + + + + {malName} + + + + + {isLoading ? ( + + - + ) : isAuthenticated && userProfile ? ( + + + {userProfile.picture ? ( + + ) : ( + + {userProfile.name.charAt(0)} + + )} + + + {userProfile.name} + + + + + {t('mal_settings.profile_id', { id: userProfile.id })} + + + {userProfile.location && ( + + + + {userProfile.location} + + + )} + {userProfile.birthday && ( + + + + {userProfile.birthday} + + + )} + + - {userProfile.anime_statistics && ( - - - - - {userProfile.anime_statistics.num_items} - - Total - - - - {userProfile.anime_statistics.num_days_watched.toFixed(1)} - - Days - - - - {userProfile.anime_statistics.mean_score.toFixed(1)} - - Mean - - - - - - - Watching - - {userProfile.anime_statistics.num_items_watching} - - - - - Completed - - {userProfile.anime_statistics.num_items_completed} - - - - - On Hold - - {userProfile.anime_statistics.num_items_on_hold} - - - - - Dropped - - {userProfile.anime_statistics.num_items_dropped} - + {userProfile.anime_statistics && ( + + + + + {userProfile.anime_statistics.num_items} + + {t('mal_settings.stats_total')} + + + + {userProfile.anime_statistics.num_days_watched.toFixed(1)} + + {t('mal_settings.stats_days')} + + + + {userProfile.anime_statistics.mean_score.toFixed(1)} + + {t('mal_settings.stats_mean')} + + + + + + + {t('mal_settings.status_watching')} + + {userProfile.anime_statistics.num_items_watching} + + + + + {t('mal_settings.status_completed')} + + {userProfile.anime_statistics.num_items_completed} + + + + + {t('mal_settings.status_on_hold')} + + {userProfile.anime_statistics.num_items_on_hold} + + + + + {t('mal_settings.status_dropped')} + + {userProfile.anime_statistics.num_items_dropped} + + + + )} + + + { + setIsLoading(true); + try { + const synced = await MalSync.syncMalToLibrary(); + if (synced) { + openAlert(t('mal_settings.sync_complete_title'), t('mal_settings.sync_success_msg')); + } else { + openAlert(t('mal_settings.sync_error_title'), t('mal_settings.sync_error_msg')); + } + } catch { + openAlert(t('mal_settings.sync_error_title'), t('mal_settings.sync_error_msg')); + } finally { + setIsLoading(false); + } + }} + > + + {t('mal_settings.sync_now_button')} + + + + {t('mal_settings.sign_out')} + + ) : ( + + + + {t('mal_settings.connect_title')} + + + {t('mal_settings.connect_desc')} + + + {t('mal_settings.sign_in')} + + )} - - - { - setIsLoading(true); - try { - const synced = await MalSync.syncMalToLibrary(); - if (synced) { - openAlert('Sync Complete', 'MAL data has been refreshed.'); - } else { - openAlert('Sync Failed', 'Could not refresh MAL data.'); - } - } catch { - openAlert('Sync Failed', 'Could not refresh MAL data.'); - } finally { - setIsLoading(false); - } - }} - > - - Sync - - - - Sign Out - - - ) : ( - - - - Connect MyAnimeList - - - Sync your watch history and manage your anime list. - - - Sign In with MAL - - - )} - - {isAuthenticated && ( - - - - Sync Settings - - - - - - - Enable MAL Sync - - - Global switch to enable or disable all MyAnimeList features. - + {isAuthenticated && ( + + + + {t('mal_settings.sync_settings_title')} + + + + + + + {t('mal_settings.sync_enabled_label')} + + + {t('mal_settings.sync_enabled_desc')} + + + + + + + + + + + {t('mal_settings.auto_update_label')} + + + {t('mal_settings.auto_update_desc')} + + + + + + + + + + + {t('mal_settings.auto_add_label')} + + + {t('mal_settings.auto_add_desc')} + + + + + + + + + + + {t('mal_settings.auto_library_sync_label')} + + + {t('mal_settings.auto_library_sync_desc')} + + + + + + + + + + + {t('mal_settings.include_nsfw_label')} + + + {t('mal_settings.include_nsfw_desc')} + + + + - + )} + - - - - - Auto Episode Update - - - Automatically update your progress on MAL when you finish watching an episode (>=90% completion). - - - - - - - - - - - Auto Add Anime - - - If an anime is not in your MAL list, it will be added automatically when you start watching. - - - - - - - - - - - Auto-Sync to Library - - - Automatically add items from your MAL 'Watching' list to your Nuvio Library. - - - - - - - - - - - Include NSFW Content - - - Allow NSFW entries to be returned when fetching your MAL list. - - - - - - - - )} - - - setAlertVisible(false)} - actions={alertActions} - /> - - ); + setAlertVisible(false)} + actions={alertActions} + /> + + ); }; const styles = StyleSheet.create({ - container: { flex: 1 }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, - }, - backButton: { flexDirection: 'row', alignItems: 'center', padding: 8 }, - backText: { fontSize: 17, marginLeft: 8 }, - headerTitle: { - fontSize: 34, - fontWeight: 'bold', - paddingHorizontal: 16, - marginBottom: 24, - }, - scrollView: { flex: 1 }, - scrollContent: { paddingHorizontal: 16, paddingBottom: 32 }, - card: { - borderRadius: 12, - overflow: 'hidden', - marginBottom: 16, - elevation: 2, - }, - loadingContainer: { padding: 40, alignItems: 'center' }, - signInContainer: { padding: 24, alignItems: 'center' }, - signInTitle: { fontSize: 20, fontWeight: '600', marginBottom: 8 }, - signInDescription: { fontSize: 15, textAlign: 'center', marginBottom: 24 }, - button: { - width: '100%', - height: 44, - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - marginTop: 8, - }, - buttonText: { fontSize: 16, fontWeight: '500', color: 'white' }, - profileContainer: { padding: 20 }, - profileHeader: { flexDirection: 'row', alignItems: 'center' }, - avatar: { width: 64, height: 64, borderRadius: 32 }, - avatarPlaceholder: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, - avatarText: { fontSize: 24, color: 'white', fontWeight: 'bold' }, - profileInfo: { marginLeft: 16, flex: 1 }, - profileName: { fontSize: 18, fontWeight: '600' }, - profileDetailRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, - profileDetailText: { fontSize: 12, marginLeft: 4 }, - statsContainer: { marginTop: 20 }, - statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16 }, - statBox: { alignItems: 'center', flex: 1 }, - statValue: { fontSize: 18, fontWeight: 'bold' }, - statLabel: { fontSize: 12, marginTop: 2 }, - statGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - borderTopWidth: 1, - paddingTop: 16, - gap: 12 - }, - statGridItem: { - flexDirection: 'row', - alignItems: 'center', - width: '45%', - marginBottom: 8 - }, - statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 }, - statGridLabel: { fontSize: 13, flex: 1 }, - statGridValue: { fontSize: 13, fontWeight: '600' }, - actionButtonsRow: { flexDirection: 'row', marginTop: 20 }, - smallButton: { - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - }, - signOutButton: { marginTop: 20 }, - settingsSection: { padding: 20 }, - sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 }, - settingItem: { marginBottom: 16 }, - settingContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, - settingTextContainer: { flex: 1, marginRight: 16 }, - settingLabel: { fontSize: 15, fontWeight: '500', marginBottom: 4 }, - settingDescription: { fontSize: 14 }, - noteContainer: { - flexDirection: 'row', - alignItems: 'center', - padding: 12, - borderRadius: 8, - borderWidth: 1, - marginBottom: 20, - marginTop: -8, - }, - noteText: { - fontSize: 13, - marginLeft: 8, - flex: 1, - lineHeight: 18, - }, + container: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { flexDirection: 'row', alignItems: 'center', padding: 8 }, + backText: { fontSize: 17, marginLeft: 8 }, + headerTitle: { + fontSize: 34, + fontWeight: 'bold', + paddingHorizontal: 16, + marginBottom: 24, + }, + scrollView: { flex: 1 }, + scrollContent: { paddingHorizontal: 16, paddingBottom: 32 }, + card: { + borderRadius: 12, + overflow: 'hidden', + marginBottom: 16, + elevation: 2, + }, + loadingContainer: { padding: 40, alignItems: 'center' }, + signInContainer: { padding: 24, alignItems: 'center' }, + signInTitle: { fontSize: 20, fontWeight: '600', marginBottom: 8 }, + signInDescription: { fontSize: 15, textAlign: 'center', marginBottom: 24 }, + button: { + width: '100%', + height: 44, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + marginTop: 8, + }, + buttonText: { fontSize: 16, fontWeight: '500', color: 'white' }, + profileContainer: { padding: 20 }, + profileHeader: { flexDirection: 'row', alignItems: 'center' }, + avatar: { width: 64, height: 64, borderRadius: 32 }, + avatarPlaceholder: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' }, + avatarText: { fontSize: 24, color: 'white', fontWeight: 'bold' }, + profileInfo: { marginLeft: 16, flex: 1 }, + profileName: { fontSize: 18, fontWeight: '600' }, + profileDetailRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, + profileDetailText: { fontSize: 12, marginLeft: 4 }, + statsContainer: { marginTop: 20 }, + statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16 }, + statBox: { alignItems: 'center', flex: 1 }, + statValue: { fontSize: 18, fontWeight: 'bold' }, + statLabel: { fontSize: 12, marginTop: 2 }, + statGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + borderTopWidth: 1, + paddingTop: 16, + gap: 12 + }, + statGridItem: { + flexDirection: 'row', + alignItems: 'center', + width: '45%', + marginBottom: 8 + }, + statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 }, + statGridLabel: { fontSize: 13, flex: 1 }, + statGridValue: { fontSize: 13, fontWeight: '600' }, + actionButtonsRow: { flexDirection: 'row', marginTop: 20 }, + smallButton: { + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + signOutButton: { marginTop: 20 }, + settingsSection: { padding: 20 }, + sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 }, + settingItem: { marginBottom: 16 }, + settingContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + settingTextContainer: { flex: 1, marginRight: 16 }, + settingLabel: { fontSize: 15, fontWeight: '500', marginBottom: 4 }, + settingDescription: { fontSize: 14 }, + noteContainer: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 8, + borderWidth: 1, + marginBottom: 20, + marginTop: -8, + }, + noteText: { + fontSize: 13, + marginLeft: 8, + flex: 1, + lineHeight: 18, + }, }); export default MalSettingsScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index eee4857a..d9fdf632 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -420,8 +420,8 @@ const SettingsScreen: React.FC = () => { )} {isItemVisible('mal') && ( } renderControl={() => } onPress={() => navigation.navigate('MalSettings')} @@ -717,8 +717,8 @@ const SettingsScreen: React.FC = () => { {showCloudSyncItem && ( { {(settingsConfig?.categories?.['backup']?.visible !== false) && ( } onPress={() => navigation.navigate('Backup')}