diff --git a/app/build.gradle b/app/build.gradle
index ad5ab2196..b753a4e31 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -62,6 +62,11 @@ android {
buildFeatures {
viewBinding true
buildConfig true
+ compose true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.13"
}
buildTypes {
@@ -194,6 +199,14 @@ dependencies {
implementation 'androidx.fragment:fragment-ktx:1.6.2'
+ // Compose
+ def composeBom = platform('androidx.compose:compose-bom:2025.01.00')
+ implementation composeBom
+ implementation 'androidx.compose.material:material'
+ // Preview support
+ implementation 'androidx.compose.ui:ui-tooling-preview'
+ debugImplementation 'androidx.compose.ui:ui-tooling'
+
// 23.0.0 to 23.2.0 breaks the apk for some reason (too small, fails to read android-manifest)
implementation "com.google.android.gms:play-services-ads:22.6.0"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fbc85c93a..26352a2cf 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,6 +39,7 @@
android:roundIcon="@mipmap/ic_roundapp"
android:supportsRtl="false"
android:theme="@style/AppTheme.Orange.NoActionBar"
+ android:localeConfig="@xml/locales_config"
tools:replace="android:allowBackup,android:supportsRtl">
+
+
+
+
+
+
{
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val intent = Intent(
+ android.provider.Settings.ACTION_APP_LOCALE_SETTINGS,
+ Uri.parse("package:${requireContext().packageName}"),
+ )
+ startActivity(intent)
+ } else {
+ LanguagePickerDialog().show(parentFragmentManager, null)
+ }
+ return true
+ }
+
else -> return super.onPreferenceTreeClick(preference)
}
}
diff --git a/app/src/main/java/com/pr0gramm/app/ui/dialogs/LanguagePickerDialog.kt b/app/src/main/java/com/pr0gramm/app/ui/dialogs/LanguagePickerDialog.kt
new file mode 100644
index 000000000..7ee72e1c2
--- /dev/null
+++ b/app/src/main/java/com/pr0gramm/app/ui/dialogs/LanguagePickerDialog.kt
@@ -0,0 +1,217 @@
+package com.pr0gramm.app.ui.dialogs
+
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.ButtonDefaults.textButtonColors
+import androidx.compose.material.Card
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.ListItem
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.ProvideTextStyle
+import androidx.compose.material.RadioButton
+import androidx.compose.material.RadioButtonDefaults
+import androidx.compose.material.Text
+import androidx.compose.material.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.core.os.LocaleListCompat
+import com.pr0gramm.app.R
+import com.pr0gramm.app.ui.base.BaseDialogFragment
+import org.xmlpull.v1.XmlPullParser
+import java.util.Locale
+
+class LanguagePickerDialog : BaseDialogFragment("LanguagePickerDialog") {
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ val supportedLocales = getSupportedLocales()
+ val currentLocale = getCurrentLocale(supportedLocales)
+
+ return ComposeView(requireContext()).apply {
+ setContent {
+ DialogContent(supportedLocales, currentLocale)
+ }
+ }
+ }
+
+ @Composable
+ fun DialogContent(
+ supportedLocales: List,
+ initialLocale: Locale
+ ) {
+ val selectedLocale = remember { mutableStateOf(initialLocale) }
+
+ Dialog(
+ onDismissRequest = { dismiss() }
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .padding(16.dp),
+ shape = RoundedCornerShape(16.dp),
+ ) {
+ val titleStyle = MaterialTheme.typography.subtitle1
+ val subtitleStyle = MaterialTheme.typography.body2
+ Column {
+ ProvideTextStyle(titleStyle) {
+ Text(
+ stringResource(R.string.language_picker_title),
+ Modifier
+ .padding(start = 16.dp, end = 16.dp, top = 16.dp)
+ .align(Alignment.Start),
+ )
+ }
+ ProvideTextStyle(subtitleStyle) {
+ Text(
+ stringResource(R.string.language_picker_subtitle),
+ Modifier
+ .padding(vertical = 4.dp, horizontal = 16.dp)
+ .align(Alignment.Start),
+ )
+ }
+ supportedLocales.forEach {
+ LanguageItem(
+ it,
+ selected = it == selectedLocale.value,
+ onClick = {
+ selectedLocale.value = it
+ },
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(
+ onClick = { dismiss() },
+ modifier = Modifier.padding(8.dp),
+ colors = textButtonColors(
+ contentColor = colorResource(R.color.orange_primary)
+ ),
+ ) {
+ Text(stringResource(R.string.language_picker_dismiss))
+ }
+ TextButton(
+ onClick = {
+ AppCompatDelegate.setApplicationLocales(
+ LocaleListCompat.create(
+ selectedLocale.value
+ )
+ )
+ dismiss()
+ },
+ modifier = Modifier.padding(8.dp),
+ colors = textButtonColors(
+ contentColor = colorResource(R.color.orange_primary)
+ ),
+ ) {
+ Text(stringResource(R.string.language_picker_confirm))
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @OptIn(ExperimentalMaterialApi::class)
+ @Composable
+ fun LanguageItem(
+ locale: Locale,
+ selected: Boolean,
+ onClick: (() -> Unit),
+ ) {
+ ListItem(
+ modifier = Modifier.clickable {
+ onClick()
+ },
+ text = { Text(locale.getDisplayLanguage(locale)) },
+ trailing = {
+ RadioButton(
+ selected = selected,
+ onClick = onClick,
+ colors = RadioButtonDefaults.colors(
+ selectedColor = colorResource(R.color.orange_primary)
+ )
+ )
+ }
+ )
+ }
+
+ private fun getSupportedLocales(): List {
+ val locales = mutableListOf()
+
+ try {
+ val parser = resources.getXml(R.xml.locales_config)
+ var eventType = parser.eventType
+ val namespace = "http://schemas.android.com/apk/res/android"
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if (eventType == XmlPullParser.START_TAG && parser.name == "locale") {
+ val languageTag = parser.getAttributeValue(namespace, "name")
+ if (languageTag != null) {
+ val locale = Locale.forLanguageTag(languageTag)
+ locales.add(locale)
+ }
+ }
+ eventType = parser.next()
+ }
+
+ parser.close()
+ } catch (e: Exception) {
+ logger.error("Error parsing locales config!", e)
+ }
+
+ return locales
+ }
+
+ private fun getCurrentLocale(supportedLocales: List): Locale {
+ val applicationLocales = AppCompatDelegate.getApplicationLocales()
+ val appLocale = if (!applicationLocales.isEmpty) {
+ applicationLocales.get(0)!!
+ } else {
+ getCurrentAppLocale()
+ }
+
+ val candidate = supportedLocales.firstOrNull { it.isO3Language == appLocale.isO3Language }
+
+ if (candidate != null) {
+ return candidate
+ }
+
+ return Locale.forLanguageTag("en-US")
+ }
+
+ private fun getCurrentAppLocale(): Locale {
+ val config = requireContext().resources.configuration
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ config.locales[0]
+ } else {
+ @Suppress("DEPRECATION")
+ config.locale
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/pr0gramm/app/util/Extensions.kt b/app/src/main/java/com/pr0gramm/app/util/Extensions.kt
index 4d9bc8294..f99fc5cf0 100644
--- a/app/src/main/java/com/pr0gramm/app/util/Extensions.kt
+++ b/app/src/main/java/com/pr0gramm/app/util/Extensions.kt
@@ -294,7 +294,7 @@ inline fun LruCache.getOrPut(key: K, creator: (K) -> V)
}
}
-fun lruCache(maxSize: Int, creator: (K) -> V?): LruCache {
+fun lruCache(maxSize: Int, creator: (K) -> V?): LruCache {
return object : LruCache(maxSize) {
override fun create(key: K) = creator(key)
}
diff --git a/app/src/main/res/drawable/ic_white_translate.xml b/app/src/main/res/drawable/ic_white_translate.xml
new file mode 100644
index 000000000..a6814415d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_white_translate.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 1a2b1e705..b9dd3ba29 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -602,4 +602,11 @@
Aktiviere diese Option um die \'Müll\' Kategorie in der Navigation zu zeigen.
Mülltrennung
Deaktiviere diese Option um \'Müll\' auch in \'Neu\' anzuzeigen.
+
+ Sprache
+ Ändere die Sprache der App unabhängig deiner Systemsprache
+ Sprache wählen
+ Bitte wähle eine der verfügbaren Sprachen.
+ Abbrechen
+ Bestätigen
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6be026fbd..b5baa5b65 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -622,5 +622,12 @@
Video quality
Junk
Category \'Junk\'
+
+ Language
+ Change the app language independent of your system language
+ Choose language
+ Please choose one of the available languages.
+ Dismiss
+ Confirm
diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
new file mode 100644
index 000000000..11e3e3651
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 570f6b796..80634f991 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -363,6 +363,12 @@
+
+