diff --git a/app/build.gradle b/app/build.gradle index cb5e455ba..8442109a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,7 +37,14 @@ android { versionCode 6 versionName "1.0.5" - resourceConfigurations += ["en", "uk"] + // Only include supported languages to reduce APK size + // Must match languages available in LanguageFragment.kt + resourceConfigurations += [ + "en", // English (default) + "bo", // Tibetan + "vi", // Vietnamese + "zh-rHK" // Chinese - Hong Kong + ] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 19c096338..5a810b73d 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.view.View import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -27,6 +28,7 @@ import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.ApiConstants import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.ThemeMode import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.utils.Logger @@ -102,6 +104,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + applyThemeMode() installSplashScreen() binding = ActivityAppBinding.inflate(layoutInflater) lifecycle.addObserver(viewModel) @@ -276,6 +279,30 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { viewModel.makeExternalRoute(supportFragmentManager, deepLink) } + private fun applyThemeMode() { + val themeMode = corePreferencesManager.themeMode + + val nightMode = when (themeMode) { + ThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + ThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES + ThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + + AppCompatDelegate.setDefaultNightMode(nightMode) + + // Also apply language + applyLanguage() + } + + private fun applyLanguage() { + val languageCode = corePreferencesManager.appLanguage + + if (languageCode.isNotEmpty()) { + val localeList = androidx.core.os.LocaleListCompat.forLanguageTags(languageCode) + AppCompatDelegate.setApplicationLocales(localeList) + } + } + companion object { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 0130d6b31..90f6625e8 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -53,7 +53,9 @@ import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.manageaccount.ManageAccountFragment import org.openedx.profile.presentation.profile.ProfileFragment +import org.openedx.profile.presentation.settings.LanguageFragment import org.openedx.profile.presentation.settings.SettingsFragment +import org.openedx.profile.presentation.settings.ThemeFragment import org.openedx.profile.presentation.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -441,6 +443,14 @@ class AppRouter : override fun navigateToCoursesToSync(fm: FragmentManager) { replaceFragmentWithBackStack(fm, CoursesToSyncFragment()) } + + override fun navigateToThemeSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, ThemeFragment.newInstance()) + } + + override fun navigateToLanguageSettings(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, LanguageFragment.newInstance()) + } // endregion fun getVisibleFragment(fm: FragmentManager): Fragment? { diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 3ab735d27..e2909757b 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -42,6 +42,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { super.onViewCreated(view, savedInstanceState) initViewPager() + fixBottomNavigationTextRendering() binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { @@ -134,6 +135,49 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } + /** + * Fix Tibetan text rendering in bottom navigation. + * Moves Tibetan labels down to prevent overlapping with icons. + */ + private fun fixBottomNavigationTextRendering() { + binding.bottomNavView.post { + val bottomNavMenuView = binding.bottomNavView.getChildAt(0) as? android.view.ViewGroup + bottomNavMenuView?.let { menuView -> + for (i in 0 until menuView.childCount) { + val item = menuView.getChildAt(i) as? android.view.ViewGroup + item?.let { itemView -> + fixTextViewsRecursively(itemView) + } + } + } + } + } + + /** + * Recursively find TextViews and fix Tibetan text rendering. + */ + private fun fixTextViewsRecursively(view: View) { + if (view is android.widget.TextView) { + // Check if text contains Tibetan characters (Unicode range U+0F00-U+0FFF) + val text = view.text?.toString() ?: "" + val hasTibetan = text.any { it.code in 0x0F00..0x0FFF } + + if (hasTibetan) { + // Only apply fixes for Tibetan text + view.includeFontPadding = false + + // Move text down 8dp to prevent overlap with icon + val density = resources.displayMetrics.density + view.translationY = 8 * density + } + } else if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + fixTextViewsRecursively(view.getChildAt(i)) + } + } + } + + companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 6524cde5d..74deca322 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -1,6 +1,7 @@ package org.openedx.app import android.app.Application +import androidx.appcompat.app.AppCompatDelegate import com.braze.Braze import com.braze.configuration.BrazeConfig import com.braze.ui.BrazeDeeplinkHandler @@ -14,6 +15,7 @@ import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule import org.openedx.core.config.Config +import org.openedx.core.data.storage.ThemeMode import org.openedx.firebase.OEXFirebaseAnalytics class OpenEdXApp : Application() { @@ -23,6 +25,8 @@ class OpenEdXApp : Application() { override fun onCreate() { super.onCreate() + applyThemeMode() + startKoin { androidContext(this@OpenEdXApp) modules( @@ -31,6 +35,7 @@ class OpenEdXApp : Application() { screenModule ) } + if (config.getFirebaseConfig().enabled) { FirebaseApp.initializeApp(this) } @@ -69,4 +74,39 @@ class OpenEdXApp : Application() { pluginManager.addPlugin(OEXFirebaseAnalytics(context = this)) } } + + private fun applyThemeMode() { + val prefs = getSharedPreferences(BuildConfig.APPLICATION_ID, MODE_PRIVATE) + val themeModeString = prefs.getString("theme_mode", ThemeMode.SYSTEM.name) + ?: ThemeMode.SYSTEM.name + + val themeMode = try { + ThemeMode.valueOf(themeModeString) + } catch (_: Exception) { + ThemeMode.SYSTEM + } + + val nightMode = when (themeMode) { + ThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + ThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES + ThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + + AppCompatDelegate.setDefaultNightMode(nightMode) + applyLanguage() + } + + private fun applyLanguage() { + val prefs = getSharedPreferences(BuildConfig.APPLICATION_ID, MODE_PRIVATE) + val languageCode = prefs.getString("app_language", "") ?: "" + + if (languageCode.isNotEmpty()) { + // Use AppCompatDelegate for proper locale handling + val localeList = androidx.core.os.LocaleListCompat.forLanguageTags(languageCode) + AppCompatDelegate.setApplicationLocales(localeList) + } else { + // Empty string means use system language + AppCompatDelegate.setApplicationLocales(androidx.core.os.LocaleListCompat.getEmptyLocaleList()) + } + } } diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 3c8ea881e..aac165860 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -219,6 +219,23 @@ class PreferencesManager(context: Context) : override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean = getBoolean(courseName.replaceSpace("_")) + override var themeMode: org.openedx.core.data.storage.ThemeMode + set(value) { + saveString(THEME_MODE, value.name) + } + get() { + val v = getString(THEME_MODE, defValue = org.openedx.core.data.storage.ThemeMode.SYSTEM.name) + return try { + org.openedx.core.data.storage.ThemeMode.valueOf(v) + } catch (e: Exception) { + org.openedx.core.data.storage.ThemeMode.SYSTEM + } + } + + override var appLanguage: String + get() = getString(APP_LANGUAGE, "") + set(value) = saveString(APP_LANGUAGE, value) + companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" @@ -239,5 +256,7 @@ class PreferencesManager(context: Context) : private const val IS_RELATIVE_DATES_ENABLED = "IS_RELATIVE_DATES_ENABLED" private const val HIDE_INACTIVE_COURSES = "HIDE_INACTIVE_COURSES" private const val CALENDAR_USER = "CALENDAR_USER" + private const val THEME_MODE = "theme_mode" + private const val APP_LANGUAGE = "app_language" } } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 22acad07c..e9742b1c0 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -208,6 +208,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 43ca09370..f3627a0cb 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -1,5 +1,9 @@ - - - \ No newline at end of file + + + + + + + diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 5435494ba..555922496 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -4,6 +4,12 @@ import org.openedx.core.data.model.User import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoSettings +enum class ThemeMode { + LIGHT, + DARK, + SYSTEM +} + interface CorePreferences { var accessToken: String var refreshToken: String @@ -14,6 +20,7 @@ interface CorePreferences { var appConfig: AppConfig var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean - + var themeMode: ThemeMode + var appLanguage: String fun clearCorePreferences() } diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index a65411042..296f9999e 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -6,7 +6,6 @@ import android.net.Uri import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.getDarkThemeFromPreferences import org.openedx.core.utils.EmailUtil import org.openedx.foundation.extension.applyDarkModeIfEnabled import org.openedx.foundation.extension.isEmailValid @@ -132,7 +132,7 @@ private fun WebViewContent( onWebPageLoaded: () -> Unit ) { val context = LocalContext.current - val isDarkTheme = isSystemInDarkTheme() + val isDarkTheme = getDarkThemeFromPreferences() AndroidView( factory = { WebView(context).apply { diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 2ad2a4eae..0a9200e23 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -9,6 +9,10 @@ import androidx.compose.material.lightColors import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalContext +import org.koin.core.context.GlobalContext +import android.content.Context +import org.openedx.core.data.storage.ThemeMode private val DarkColorPalette = AppColors( material = darkColors( @@ -195,7 +199,7 @@ val MaterialTheme.appColors: AppColors @OptIn(ExperimentalFoundationApi::class) @Composable -fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { +fun OpenEdXTheme(darkTheme: Boolean = getDarkThemeFromPreferences(), content: @Composable () -> Unit) { val colors = if (darkTheme) { DarkColorPalette } else { @@ -213,3 +217,20 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl ) } } + +@Composable +fun getDarkThemeFromPreferences(): Boolean { + val context = LocalContext.current + val prefs = context.getSharedPreferences(context.packageName, Context.MODE_PRIVATE) + val value = prefs.getString("theme_mode", ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name + val mode = try { + ThemeMode.valueOf(value) + } catch (e: Exception) { + ThemeMode.SYSTEM + } + return when (mode) { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> isSystemInDarkTheme() + } +} diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index a82cd7da5..4702b8198 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -191,4 +191,13 @@ Not Synced Syncing to calendar… Next + + + Theme + Use system theme + Light + Dark + + + Language diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index e9f67ad48..d4bc834cf 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -23,4 +23,8 @@ interface ProfileRouter { fun navigateToManageAccount(fm: FragmentManager) fun navigateToCoursesToSync(fm: FragmentManager) + + fun navigateToThemeSettings(fm: FragmentManager) + + fun navigateToLanguageSettings(fm: FragmentManager) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/LanguageFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/LanguageFragment.kt new file mode 100644 index 000000000..41cb3ba41 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/LanguageFragment.kt @@ -0,0 +1,275 @@ +package org.openedx.profile.presentation.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.R +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.settingsHeaderBackground +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.presentation.ui.SettingsDivider + +class LanguageFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState() + + val currentLanguage = (uiState as? SettingsUIState.Data)?.appLanguage ?: "" + + LanguageScreen( + windowSize = windowSize, + currentLanguage = currentLanguage, + onLanguageSelected = { languageCode -> + viewModel.setAppLanguage(languageCode) + // Navigate back immediately, then recreate will happen + requireActivity().supportFragmentManager.popBackStack() + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + + // Recreate activity when language is changed to apply new language. + LaunchedEffect(Unit) { + viewModel.languageChanged.collect { + // Activity recreation after navigation back + requireActivity().recreate() + } + } + } + } + } + + companion object { + fun newInstance(): LanguageFragment { + return LanguageFragment() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LanguageScreen( + windowSize: WindowSize, + currentLanguage: String, + onLanguageSelected: (String) -> Unit, + onBackClick: () -> Unit +) { + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .settingsHeaderBackground() + .statusBarsInset() + ) { + Toolbar( + modifier = topBarWidth + .align(Alignment.CenterHorizontally) + .displayCutoutForLandscape(), + label = stringResource(id = R.string.core_language), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = MaterialTheme.appShapes.screenBackgroundShape, + color = MaterialTheme.appColors.background + ) { + Box( + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(30.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + val languages = remember { + listOf( + LanguageOption("en", "English"), + LanguageOption("bo", "བོད་སྐད"), + LanguageOption("zh-HK", "繁體中文"), + LanguageOption("vi", "Tiếng Việt") + ) + } + + languages.forEachIndexed { index, language -> + LanguageOption( + name = language.displayName, + code = language.code, + selected = currentLanguage == language.code, + onClick = { + onLanguageSelected(language.code) + } + ) + + if (index < languages.size - 1) { + SettingsDivider() + } + } + } + } + + Spacer(Modifier.height(24.dp)) + } + } + } + } + } + } +} + +@Composable +private fun LanguageOption( + name: String, + code: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + Modifier + .testTag("btn_language_${name.tagId()}") + .fillMaxWidth() + .clickable { + onClick() + } + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.testTag("txt_language_${name.tagId()}"), + text = name, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + if (selected) { + Icon( + modifier = Modifier + .testTag("ic_language_selected_${name.tagId()}") + .size(20.dp), + painter = painterResource(id = R.drawable.core_ic_check), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } + } +} + +private data class LanguageOption( + val code: String, + val displayName: String +) + +@Preview +@Composable +private fun LanguageScreenPreview() { + OpenEdXTheme { + LanguageScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + currentLanguage = "en", + onLanguageSelected = {}, + onBackClick = {} + ) + } +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index 217a35258..eaec377aa 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.data.storage.ThemeMode import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.foundation.presentation.rememberWindowSize @@ -94,6 +95,22 @@ class SettingsFragment : Fragment() { requireActivity().supportFragmentManager ) } + + SettingsScreenAction.ThemeSettingsClick -> { + viewModel.themeSettingsClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.LanguageSettingsClick -> { + viewModel.languageSettingsClicked( + requireActivity().supportFragmentManager + ) + } + + is SettingsScreenAction.ThemeModeSelected -> { + viewModel.setThemeMode(action.mode) + } } } ) @@ -103,6 +120,12 @@ class SettingsFragment : Fragment() { viewModel.restartApp(requireActivity().supportFragmentManager) } } + + LaunchedEffect(Unit) { + viewModel.themeChanged.collect { + requireActivity().recreate() + } + } } } } @@ -120,4 +143,7 @@ internal interface SettingsScreenAction { object VideoSettingsClick : SettingsScreenAction object ManageAccountClick : SettingsScreenAction object CalendarSettingsClick : SettingsScreenAction + object ThemeSettingsClick : SettingsScreenAction + object LanguageSettingsClick : SettingsScreenAction + data class ThemeModeSelected(val mode: ThemeMode) : SettingsScreenAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 68c773745..4cc06b0e5 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -28,6 +28,10 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -96,8 +100,7 @@ internal fun SettingsScreen( mutableStateOf( windowSize.windowSizeValue( expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier - .fillMaxWidth() + compact = Modifier.fillMaxWidth() ) ) } @@ -181,6 +184,12 @@ internal fun SettingsScreen( }, onCalendarSettingsClick = { onAction(SettingsScreenAction.CalendarSettingsClick) + }, + onThemeClick = { + onAction(SettingsScreenAction.ThemeSettingsClick) + }, + onLanguageClick = { + onAction(SettingsScreenAction.LanguageSettingsClick) } ) @@ -212,7 +221,9 @@ internal fun SettingsScreen( @Composable private fun SettingsSection( onVideoSettingsClick: () -> Unit, - onCalendarSettingsClick: () -> Unit + onCalendarSettingsClick: () -> Unit, + onThemeClick: () -> Unit, + onLanguageClick: () -> Unit ) { Column { Text( @@ -231,13 +242,27 @@ private fun SettingsSection( Column(Modifier.fillMaxWidth()) { SettingsItem( text = stringResource(id = profileR.string.profile_video), + icon = Icons.Outlined.SmartDisplay, onClick = onVideoSettingsClick ) SettingsDivider() SettingsItem( text = stringResource(id = profileR.string.profile_dates_and_calendar), + icon = Icons.Outlined.CalendarMonth, onClick = onCalendarSettingsClick ) + SettingsDivider() + SettingsItem( + text = stringResource(id = R.string.core_theme), + icon = Icons.Outlined.Palette, + onClick = onThemeClick + ) + SettingsDivider() + SettingsItem( + text = stringResource(id = R.string.core_language), + icon = Icons.Outlined.Language, + onClick = onLanguageClick + ) } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt index f2b736d45..981c1fc6d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt @@ -1,10 +1,13 @@ package org.openedx.profile.presentation.settings import org.openedx.profile.domain.model.Configuration +import org.openedx.core.data.storage.ThemeMode sealed class SettingsUIState { data class Data( val configuration: Configuration, + val themeMode: ThemeMode = ThemeMode.SYSTEM, + val appLanguage: String = "", ) : SettingsUIState() object Loading : SettingsUIState() diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 59548d1c9..b7d62eddc 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -17,6 +17,8 @@ import org.openedx.core.AppUpdateState import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.ThemeMode import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager @@ -49,9 +51,16 @@ class SettingsViewModel( private val calendarRouter: CalendarRouter, private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, + private val corePreferences: CorePreferences, ) : BaseViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(SettingsUIState.Data(configuration)) + private val _uiState: MutableStateFlow = MutableStateFlow( + SettingsUIState.Data( + configuration, + themeMode = corePreferences.themeMode, + appLanguage = corePreferences.appLanguage + ) + ) internal val uiState: StateFlow = _uiState.asStateFlow() private val _successLogout = MutableSharedFlow() @@ -66,6 +75,12 @@ class SettingsViewModel( val appUpgradeEvent: StateFlow get() = _appUpgradeEvent.asStateFlow() + private val _themeChanged = MutableSharedFlow() + val themeChanged = _themeChanged.asSharedFlow() + + private val _languageChanged = MutableSharedFlow() + val languageChanged = _languageChanged.asSharedFlow() + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private val configuration @@ -203,6 +218,25 @@ class SettingsViewModel( calendarRouter.navigateToCalendarSettings(fragmentManager) } + fun themeSettingsClicked(fragmentManager: FragmentManager) { + profileRouter.navigateToThemeSettings(fragmentManager) + } + + fun languageSettingsClicked(fragmentManager: FragmentManager) { + profileRouter.navigateToLanguageSettings(fragmentManager) + } + + fun setAppLanguage(languageCode: String) { + corePreferences.appLanguage = languageCode + viewModelScope.launch { + val currentState = _uiState.value + if (currentState is SettingsUIState.Data) { + _uiState.emit(currentState.copy(appLanguage = languageCode)) + } + _languageChanged.emit(Unit) + } + } + fun restartApp(fragmentManager: FragmentManager) { profileRouter.restartApp( fragmentManager, @@ -210,6 +244,22 @@ class SettingsViewModel( ) } + fun setThemeMode(mode: ThemeMode) { + viewModelScope.launch { + try { + corePreferences.themeMode = mode + _uiState.emit( + SettingsUIState.Data( + configuration = configuration, + themeMode = mode + ) + ) + _themeChanged.emit(Unit) + } catch (_: Exception) { + } + } + } + private fun logProfileEvent( event: ProfileAnalyticsEvent, params: Map = emptyMap(), diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/ThemeFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/ThemeFragment.kt new file mode 100644 index 000000000..26ec0a0e3 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/ThemeFragment.kt @@ -0,0 +1,265 @@ +package org.openedx.profile.presentation.settings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.R +import org.openedx.core.data.storage.ThemeMode +import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.settingsHeaderBackground +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.foundation.extension.tagId +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.presentation.ui.SettingsDivider + +class ThemeFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState() + + val currentTheme = (uiState as? SettingsUIState.Data)?.themeMode ?: ThemeMode.SYSTEM + + ThemeScreen( + windowSize = windowSize, + currentTheme = currentTheme, + onThemeSelected = { mode -> + viewModel.setThemeMode(mode) + requireActivity().supportFragmentManager.popBackStack() + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + } + ) + + LaunchedEffect(Unit) { + viewModel.themeChanged.collect { + requireActivity().recreate() + } + } + } + } + } + + companion object { + fun newInstance(): ThemeFragment { + return ThemeFragment() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun ThemeScreen( + windowSize: WindowSize, + currentTheme: ThemeMode, + onThemeSelected: (ThemeMode) -> Unit, + onBackClick: () -> Unit +) { + + val topBarWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .settingsHeaderBackground() + .statusBarsInset() + ) { + Toolbar( + modifier = topBarWidth + .align(Alignment.CenterHorizontally) + .displayCutoutForLandscape(), + label = stringResource(id = R.string.core_theme), + canShowBackBtn = true, + labelTint = MaterialTheme.appColors.settingsTitleContent, + iconTint = MaterialTheme.appColors.settingsTitleContent, + onBackClick = onBackClick + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = MaterialTheme.appShapes.screenBackgroundShape, + color = MaterialTheme.appColors.background + ) { + Box( + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .displayCutoutForLandscape(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .then(contentWidth) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(30.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + val themeOptions = listOf( + ThemeMode.SYSTEM to R.string.core_theme_system, + ThemeMode.LIGHT to R.string.core_theme_light, + ThemeMode.DARK to R.string.core_theme_dark + ) + + themeOptions.forEachIndexed { index, (mode, stringRes) -> + ThemeOption( + title = stringResource(id = stringRes), + selected = currentTheme == mode, + onClick = { + onThemeSelected(mode) + } + ) + + if (index < themeOptions.size - 1) { + SettingsDivider() + } + } + } + } + + Spacer(Modifier.height(24.dp)) + } + } + } + } + } + } +} + +@Composable +private fun ThemeOption( + title: String, + selected: Boolean, + onClick: () -> Unit +) { + Row( + Modifier + .testTag("btn_theme_${title.tagId()}") + .fillMaxWidth() + .clickable { + onClick() + } + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.testTag("txt_theme_${title.tagId()}"), + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + if (selected) { + Icon( + modifier = Modifier + .testTag("ic_theme_selected_${title.tagId()}") + .size(20.dp), + painter = painterResource(id = R.drawable.core_ic_check), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } + } +} + +@Preview +@Composable +private fun ThemeScreenPreview() { + OpenEdXTheme { + ThemeScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + currentTheme = ThemeMode.SYSTEM, + onThemeSelected = {}, + onBackClick = {} + ) + } +} + diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt index f4811135a..c15154c74 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -3,9 +3,11 @@ package org.openedx.profile.presentation.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -16,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -26,10 +29,11 @@ import org.openedx.foundation.extension.tagId @Composable fun SettingsItem( text: String, + icon: ImageVector? = null, external: Boolean = false, onClick: () -> Unit ) { - val icon = if (external) { + val trailingIcon = if (external) { Icons.AutoMirrored.Filled.OpenInNew } else { Icons.AutoMirrored.Filled.ArrowForwardIos @@ -46,20 +50,33 @@ fun SettingsItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - modifier = Modifier - .testTag("txt_${text.tagId()}") - .weight(1f), - text = text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary - ) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + modifier = Modifier.testTag("txt_${text.tagId()}"), + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary + ) + } Icon( modifier = Modifier.size(16.dp), - imageVector = icon, - contentDescription = null + imageVector = trailingIcon, + contentDescription = null, + tint = MaterialTheme.appColors.textPrimary ) } }