multi-lang init

This commit is contained in:
tapframe 2026-01-06 11:34:05 +05:30
parent 9877f513e2
commit 9c37ad8b94
10 changed files with 374 additions and 27 deletions

View file

@ -13,6 +13,7 @@ import {
Platform,
LogBox
} from 'react-native';
import './src/i18n'; // Initialize i18n
import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar';

85
package-lock.json generated
View file

@ -64,10 +64,13 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21",
"lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0",
"react": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4",
"react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2",
@ -7505,6 +7508,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2-without-node-native": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz",
@ -7616,6 +7628,37 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/i18next": {
"version": "25.7.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -7743,6 +7786,12 @@
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/intl-pluralrules": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz",
"integrity": "sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==",
"license": "ISC"
},
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -10537,6 +10586,33 @@
"react": ">=17.0.0"
}
},
"node_modules/react-i18next": {
"version": "16.5.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
@ -13250,6 +13326,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT"
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View file

@ -64,10 +64,13 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21",
"lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0",
"react": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4",
"react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2",

21
src/i18n/index.ts Normal file
View file

@ -0,0 +1,21 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import 'intl-pluralrules';
import languageDetector from './languageDetector';
import { resources } from './resources';
i18n
.use(languageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
export default i18n;

View file

@ -0,0 +1,29 @@
import { getLocales } from 'expo-localization';
import { LanguageDetectorModule } from 'i18next';
import { mmkvStorage } from '../services/mmkvStorage';
const languageDetector = {
type: 'languageDetector',
async: true,
detect: async (callback: any) => {
try {
const savedLanguage = await mmkvStorage.getItem('user_language');
if (savedLanguage) {
callback(savedLanguage);
return;
}
} catch (error) {
console.log('Error reading language from storage', error);
}
const locales = getLocales();
const languageCode = locales[0]?.languageCode ?? 'en';
callback(languageCode);
},
init: () => { },
cacheUserLanguage: (language: string) => {
mmkvStorage.setItem('user_language', language);
},
};
export default languageDetector;

42
src/i18n/locales/en.json Normal file
View file

@ -0,0 +1,42 @@
{
"common": {
"loading": "Loading...",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"error": "Error",
"success": "Success",
"ok": "OK"
},
"addons": {
"title": "Addons",
"reorder_mode": "Reorder Mode",
"reorder_info": "Addons at the top have higher priority when loading content",
"add_addon_placeholder": "Addon URL",
"add_button": "Add Addon",
"my_addons": "My Addons",
"community_addons": "Community Addons",
"no_addons": "No addons installed",
"uninstall_title": "Uninstall Addon",
"uninstall_message": "Are you sure you want to uninstall {{name}}?",
"uninstall_button": "Uninstall",
"install_success": "Addon installed successfully",
"install_error": "Failed to install addon",
"load_error": "Failed to load addons",
"fetch_error": "Failed to fetch addon details",
"invalid_url": "Please enter an addon URL",
"configure": "Configure",
"version": "Version: {{version}}",
"installed_addons": "INSTALLED ADDONS",
"reorder_drag_title": "DRAG ADDONS TO REORDER",
"install": "Install"
},
"settings": {
"language": "Language",
"select_language": "Select Language",
"english": "English",
"portuguese": "Portuguese"
}
}

42
src/i18n/locales/pt.json Normal file
View file

@ -0,0 +1,42 @@
{
"common": {
"loading": "Carregando...",
"cancel": "Cancelar",
"save": "Salvar",
"delete": "Excluir",
"edit": "Editar",
"search": "Buscar",
"error": "Erro",
"success": "Sucesso",
"ok": "OK"
},
"addons": {
"title": "Addons",
"reorder_mode": "Modo de Reordenação",
"reorder_info": "Arraste e solte para reordenar seus addons.",
"add_addon_placeholder": "Digite a URL do addon (comece com https://)",
"add_button": "Adicionar",
"my_addons": "Meus Addons",
"community_addons": "Addons da Comunidade",
"no_addons": "Nenhum addon instalado",
"uninstall_title": "Desinstalar Addon",
"uninstall_message": "Tem certeza que deseja desinstalar {{name}}?",
"uninstall_button": "Desinstalar",
"installed_addons": "ADDONS INSTALADOS",
"reorder_drag_title": "ARRASTE PARA REORDENAR",
"install": "Instalar",
"install_success": "Addon instalado com sucesso",
"install_error": "Falha ao instalar addon",
"load_error": "Falha ao carregar addons",
"fetch_error": "Falha ao buscar detalhes do addon",
"invalid_url": "Por favor, digite uma URL de addon",
"configure": "Configurar",
"version": "Versão: {{version}}"
},
"settings": {
"language": "Idioma",
"select_language": "Selecionar Idioma",
"english": "Inglês",
"portuguese": "Português"
}
}

7
src/i18n/resources.ts Normal file
View file

@ -0,0 +1,7 @@
import en from './locales/en.json';
import pt from './locales/pt.json';
export const resources = {
en: { translation: en },
pt: { translation: pt },
};

View file

@ -30,6 +30,7 @@ import { logger } from '../utils/logger';
import { mmkvStorage } from '../services/mmkvStorage';
import { BlurView as ExpoBlurView } from 'expo-blur';
import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback for AddonsScreen
let GlassViewComp: any = null;
@ -536,6 +537,7 @@ const createStyles = (colors: any) => StyleSheet.create({
const AddonsScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [addons, setAddons] = useState<ExtendedManifest[]>([]);
const [loading, setLoading] = useState(true);
@ -603,9 +605,9 @@ const AddonsScreen = () => {
}
} catch (error) {
logger.error('Failed to load addons:', error);
setAlertTitle('Error');
setAlertMessage('Failed to load addons');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertTitle(t('common.error'));
setAlertMessage(t('addons.load_error'));
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
} finally {
setLoading(false);
@ -617,9 +619,9 @@ const AddonsScreen = () => {
const handleAddAddon = async (url?: string) => {
let urlToInstall = url || addonUrl;
if (!urlToInstall) {
setAlertTitle('Error');
setAlertMessage('Please enter an addon URL');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertTitle(t('common.error'));
setAlertMessage(t('addons.invalid_url'));
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
return;
}
@ -637,9 +639,9 @@ const AddonsScreen = () => {
setShowConfirmModal(true);
} catch (error) {
logger.error('Failed to fetch addon details:', error);
setAlertTitle('Error');
setAlertMessage(`Failed to fetch addon details from ${urlToInstall}`);
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertTitle(t('common.error'));
setAlertMessage(`${t('addons.fetch_error')} ${urlToInstall}`);
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
} finally {
setInstalling(false);
@ -656,9 +658,9 @@ const AddonsScreen = () => {
setShowConfirmModal(false);
setAddonDetails(null);
loadAddons();
setAlertTitle('Success');
setAlertMessage('Addon installed successfully');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertTitle(t('common.success'));
setAlertMessage(t('addons.install_success'));
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
} catch (error) {
logger.error('Failed to install addon:', error);
@ -691,12 +693,12 @@ const AddonsScreen = () => {
};
const handleRemoveAddon = (addon: ExtendedManifest) => {
setAlertTitle('Uninstall Addon');
setAlertMessage(`Are you sure you want to uninstall ${addon.name}?`);
setAlertTitle(t('addons.uninstall_title'));
setAlertMessage(t('addons.uninstall_message', { name: addon.name }));
setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
{ label: t('common.cancel'), onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
{
label: 'Uninstall',
label: t('addons.uninstall_button'),
onPress: async () => {
await stremioService.removeAddon(addon.id);
setAddons(prev => prev.filter(a => a.id !== addon.id));
@ -997,15 +999,15 @@ const AddonsScreen = () => {
</View>
<Text style={styles.headerTitle}>
Addons
{reorderMode && <Text style={styles.reorderModeText}> (Reorder Mode)</Text>}
{t('addons.title')}
{reorderMode && <Text style={styles.reorderModeText}>{t('addons.reorder_mode')}</Text>}
</Text>
{reorderMode && (
<View style={styles.reorderInfoBanner}>
<MaterialIcons name="info-outline" size={18} color={colors.primary} />
<Text style={styles.reorderInfoText}>
Addons at the top have higher priority when loading content
{t('addons.reorder_info')}
</Text>
</View>
)}
@ -1040,7 +1042,7 @@ const AddonsScreen = () => {
<View style={styles.addAddonContainer}>
<TextInput
style={styles.addonInput}
placeholder="Addon URL"
placeholder={t('addons.add_addon_placeholder')}
placeholderTextColor={colors.mediumGray}
value={addonUrl}
onChangeText={setAddonUrl}
@ -1053,7 +1055,7 @@ const AddonsScreen = () => {
disabled={installing || !addonUrl}
>
<Text style={styles.addButtonText}>
{installing ? 'Loading...' : 'Add Addon'}
{installing ? 'Loading...' : t('addons.add_button')}
</Text>
</TouchableOpacity>
</View>
@ -1063,13 +1065,13 @@ const AddonsScreen = () => {
{/* Installed Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"}
{reorderMode ? t('addons.reorder_drag_title') : t('addons.installed_addons')}
</Text>
<View style={styles.addonList}>
{addons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No addons installed</Text>
<Text style={styles.emptyText}>{t('addons.no_addons')}</Text>
</View>
) : (
addons.map((addon, index) => (
@ -1083,7 +1085,8 @@ const AddonsScreen = () => {
)}
</View>
</View>
</ScrollView>
</ScrollView >
)}
{/* Addon Details Confirmation Modal */}
@ -1189,7 +1192,7 @@ const AddonsScreen = () => {
setAddonDetails(null);
}}
>
<Text style={styles.modalButtonText}>Cancel</Text>
<Text style={styles.modalButtonText}>{t('common.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, styles.installButton]}
@ -1199,7 +1202,7 @@ const AddonsScreen = () => {
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.modalButtonText}>Install</Text>
<Text style={styles.modalButtonText}>{t('addons.install')}</Text>
)}
</TouchableOpacity>
</View>
@ -1216,7 +1219,7 @@ const AddonsScreen = () => {
onClose={() => setAlertVisible(false)}
actions={alertActions}
/>
</SafeAreaView>
</SafeAreaView >
);
};

View file

@ -12,7 +12,10 @@ import {
Platform,
Dimensions,
Linking,
Modal,
FlatList,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { mmkvStorage } from '../services/mmkvStorage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -142,8 +145,10 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
const SettingsScreen: React.FC = () => {
const { t, i18n } = useTranslation();
const { settings, updateSetting } = useSettings();
const [hasUpdateBadge, setHasUpdateBadge] = useState(false);
const [languageModalVisible, setLanguageModalVisible] = useState(false);
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@ -577,6 +582,13 @@ const SettingsScreen: React.FC = () => {
(settingsConfig?.categories?.['playback']?.visible !== false)
) && (
<SettingsCard title="GENERAL">
<SettingItem
title={t('settings.language')}
description={i18n.language === 'pt' ? t('settings.portuguese') : t('settings.english')}
icon="globe"
renderControl={() => <ChevronRight />}
onPress={() => setLanguageModalVisible(true)}
/>
{(settingsConfig?.categories?.['content']?.visible !== false) && (
<SettingItem
title="Content & Discovery"
@ -791,6 +803,69 @@ const SettingsScreen: React.FC = () => {
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
<Modal
visible={languageModalVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setLanguageModalVisible(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
activeOpacity={1}
onPress={() => setLanguageModalVisible(false)}
>
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.modalTitle, { color: currentTheme.colors.highEmphasis }]}>
{t('settings.select_language')}
</Text>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'en' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('en');
setLanguageModalVisible(false);
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'en' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.english')}
</Text>
{i18n.language === 'en' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'pt' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('pt');
setLanguageModalVisible(false);
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'pt' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.portuguese')}
</Text>
{i18n.language === 'pt' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
</View>
);
};
@ -799,6 +874,45 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
width: '100%',
maxWidth: 340,
borderRadius: 16,
padding: 20,
elevation: 5,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
modalTitle: {
fontSize: 18,
fontWeight: 'bold',
marginBottom: 16,
textAlign: 'center',
},
languageOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 8,
marginBottom: 8,
},
languageText: {
fontSize: 16,
},
// Mobile styles
contentContainer: {
flex: 1,