Release v1.3: Fix choppy navigation and language persistence
- Simplify navigation animations (slide-only, 220ms, FastOutSlowInEasing) - Add soft refresh to prevent shimmer when returning from Settings - Move language restoration to Application class for reliable persistence - Fix theme parent to AppCompat.DayNight for proper locale support - Delay refresh 250ms to complete navigation animation smoothly
This commit is contained in:
parent
46cd22b2e4
commit
dff40712c5
10 changed files with 98 additions and 36 deletions
|
|
@ -196,6 +196,10 @@ Contributions are welcome! Here's how you can help:
|
||||||
- **New**: Localization support for 20 languages (English, Chinese, Spanish, Hindi, Arabic, Portuguese, Bengali, Russian, Japanese, French, German, Korean, Italian, Turkish, Vietnamese, Polish, Ukrainian, Dutch, Thai, Indonesian)
|
- **New**: Localization support for 20 languages (English, Chinese, Spanish, Hindi, Arabic, Portuguese, Bengali, Russian, Japanese, French, German, Korean, Italian, Turkish, Vietnamese, Polish, Ukrainian, Dutch, Thai, Indonesian)
|
||||||
- **New**: In-app language override using AppCompatDelegate
|
- **New**: In-app language override using AppCompatDelegate
|
||||||
- **Improved**: All UI strings now use centralized string resources
|
- **Improved**: All UI strings now use centralized string resources
|
||||||
|
- **Fixed**: Choppy navigation transitions between Settings and List screens
|
||||||
|
- **Fixed**: Language preference now persists correctly across app restarts
|
||||||
|
- **Improved**: Navigation animations simplified (slide-only, 220ms with FastOutSlowInEasing)
|
||||||
|
- **Improved**: Soft refresh prevents shimmer loading when returning from Settings
|
||||||
|
|
||||||
### v1.2 (January 2026)
|
### v1.2 (January 2026)
|
||||||
- **Fixed**: Login crash caused by `ParameterizedType` casting error at runtime
|
- **Fixed**: Login crash caused by `ParameterizedType` casting error at runtime
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ android {
|
||||||
applicationId = "com.fastmask"
|
applicationId = "com.fastmask"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "1.2"
|
versionName = "1.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,30 @@
|
||||||
package com.fastmask
|
package com.fastmask
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import com.fastmask.data.local.SettingsDataStore
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class FastMaskApplication : Application()
|
class FastMaskApplication : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
// Restore saved language after super.onCreate() but before any Activity starts.
|
||||||
|
// We read directly from DataStore here since Hilt injection happens during super.onCreate().
|
||||||
|
restoreSavedLanguage()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreSavedLanguage() {
|
||||||
|
try {
|
||||||
|
val savedLanguageCode = SettingsDataStore.getLanguageBlocking(this)
|
||||||
|
if (savedLanguageCode != null) {
|
||||||
|
val localeList = LocaleListCompat.forLanguageTags(savedLanguageCode)
|
||||||
|
AppCompatDelegate.setApplicationLocales(localeList)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If reading fails, use system default locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,12 @@ import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.fastmask.data.local.SettingsDataStore
|
|
||||||
import com.fastmask.domain.repository.AuthRepository
|
import com.fastmask.domain.repository.AuthRepository
|
||||||
import com.fastmask.ui.navigation.FastMaskNavHost
|
import com.fastmask.ui.navigation.FastMaskNavHost
|
||||||
import com.fastmask.ui.navigation.NavRoutes
|
import com.fastmask.ui.navigation.NavRoutes
|
||||||
|
|
@ -26,15 +23,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var authRepository: AuthRepository
|
lateinit var authRepository: AuthRepository
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var settingsDataStore: SettingsDataStore
|
|
||||||
|
|
||||||
private var isReady = false
|
private var isReady = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// Restore saved language before anything else
|
|
||||||
restoreSavedLanguage()
|
|
||||||
|
|
||||||
val splashScreen = installSplashScreen()
|
val splashScreen = installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
|
@ -65,12 +56,4 @@ class MainActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreSavedLanguage() {
|
|
||||||
val savedLanguageCode = settingsDataStore.getLanguageBlocking()
|
|
||||||
if (savedLanguageCode != null) {
|
|
||||||
val localeList = LocaleListCompat.forLanguageTags(savedLanguageCode)
|
|
||||||
AppCompatDelegate.setApplicationLocales(localeList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import kotlinx.coroutines.runBlocking
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class SettingsDataStore @Inject constructor(
|
class SettingsDataStore @Inject constructor(
|
||||||
|
|
@ -22,12 +22,12 @@ class SettingsDataStore @Inject constructor(
|
||||||
) {
|
) {
|
||||||
private val languageKey = stringPreferencesKey("language_code")
|
private val languageKey = stringPreferencesKey("language_code")
|
||||||
|
|
||||||
val languageFlow: Flow<String?> = context.dataStore.data.map { preferences ->
|
val languageFlow: Flow<String?> = context.settingsDataStore.data.map { preferences ->
|
||||||
preferences[languageKey]
|
preferences[languageKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setLanguage(languageCode: String?) {
|
suspend fun setLanguage(languageCode: String?) {
|
||||||
context.dataStore.edit { preferences ->
|
context.settingsDataStore.edit { preferences ->
|
||||||
if (languageCode == null) {
|
if (languageCode == null) {
|
||||||
preferences.remove(languageKey)
|
preferences.remove(languageKey)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -38,7 +38,17 @@ class SettingsDataStore @Inject constructor(
|
||||||
|
|
||||||
fun getLanguageBlocking(): String? {
|
fun getLanguageBlocking(): String? {
|
||||||
return runBlocking {
|
return runBlocking {
|
||||||
context.dataStore.data.first()[languageKey]
|
context.settingsDataStore.data.first()[languageKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LANGUAGE_KEY = stringPreferencesKey("language_code")
|
||||||
|
|
||||||
|
fun getLanguageBlocking(context: Context): String? {
|
||||||
|
return runBlocking {
|
||||||
|
context.settingsDataStore.data.first()[LANGUAGE_KEY]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
|
@ -95,10 +96,12 @@ fun MaskedEmailListScreen(
|
||||||
val createDesc = stringResource(R.string.email_list_create_description)
|
val createDesc = stringResource(R.string.email_list_create_description)
|
||||||
|
|
||||||
// Refresh the list when the screen resumes (e.g., after creating a new email)
|
// Refresh the list when the screen resumes (e.g., after creating a new email)
|
||||||
|
// Delay refresh to allow navigation animation to complete smoothly
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
LaunchedEffect(lifecycleOwner) {
|
LaunchedEffect(lifecycleOwner) {
|
||||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
viewModel.loadMaskedEmails()
|
delay(250L)
|
||||||
|
viewModel.refreshMaskedEmails()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,44 @@ class MaskedEmailListViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshMaskedEmails() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Don't show loading if we already have data (soft refresh)
|
||||||
|
if (_uiState.value.emails.isEmpty()) {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaskedEmailsUseCase().fold(
|
||||||
|
onSuccess = { emails ->
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
emails = emails.sortedByDescending { email -> email.createdAt },
|
||||||
|
filteredEmails = filterEmails(
|
||||||
|
emails,
|
||||||
|
it.searchQuery,
|
||||||
|
it.selectedFilter
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
// Only show error if we have no data
|
||||||
|
if (_uiState.value.emails.isEmpty()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = error.message ?: "Failed to load emails"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(isLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onSearchQueryChange(query: String) {
|
fun onSearchQueryChange(query: String) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.fastmask.ui.navigation
|
||||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
@ -19,7 +20,7 @@ import com.fastmask.ui.detail.MaskedEmailDetailScreen
|
||||||
import com.fastmask.ui.list.MaskedEmailListScreen
|
import com.fastmask.ui.list.MaskedEmailListScreen
|
||||||
import com.fastmask.ui.settings.SettingsScreen
|
import com.fastmask.ui.settings.SettingsScreen
|
||||||
|
|
||||||
private const val TRANSITION_DURATION_MS = 300
|
private const val TRANSITION_DURATION_MS = 220
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -36,26 +37,26 @@ fun FastMaskNavHost(
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
slideIntoContainer(
|
slideIntoContainer(
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||||
animationSpec = tween(TRANSITION_DURATION_MS)
|
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
|
||||||
) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
|
)
|
||||||
},
|
},
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
slideOutOfContainer(
|
slideOutOfContainer(
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
towards = AnimatedContentTransitionScope.SlideDirection.Start,
|
||||||
animationSpec = tween(TRANSITION_DURATION_MS)
|
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
|
||||||
) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
|
)
|
||||||
},
|
},
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideIntoContainer(
|
slideIntoContainer(
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
||||||
animationSpec = tween(TRANSITION_DURATION_MS)
|
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
|
||||||
) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
|
)
|
||||||
},
|
},
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutOfContainer(
|
slideOutOfContainer(
|
||||||
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
towards = AnimatedContentTransitionScope.SlideDirection.End,
|
||||||
animationSpec = tween(TRANSITION_DURATION_MS)
|
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
|
||||||
) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
composable(
|
composable(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar">
|
<style name="Theme.FastMask" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<style name="Theme.FastMask" parent="android:Theme.Material.Light.NoActionBar">
|
<style name="Theme.FastMask" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue