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:
Paweł Orzech 2026-01-31 12:43:59 +01:00
parent 46cd22b2e4
commit dff40712c5
No known key found for this signature in database
10 changed files with 98 additions and 36 deletions

View file

@ -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**: In-app language override using AppCompatDelegate
- **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)
- **Fixed**: Login crash caused by `ParameterizedType` casting error at runtime

View file

@ -14,8 +14,8 @@ android {
applicationId = "com.fastmask"
minSdk = 26
targetSdk = 34
versionCode = 3
versionName = "1.2"
versionCode = 4
versionName = "1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View file

@ -1,7 +1,30 @@
package com.fastmask
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
@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
}
}
}

View file

@ -4,15 +4,12 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.navigation.compose.rememberNavController
import com.fastmask.data.local.SettingsDataStore
import com.fastmask.domain.repository.AuthRepository
import com.fastmask.ui.navigation.FastMaskNavHost
import com.fastmask.ui.navigation.NavRoutes
@ -26,15 +23,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var authRepository: AuthRepository
@Inject
lateinit var settingsDataStore: SettingsDataStore
private var isReady = false
override fun onCreate(savedInstanceState: Bundle?) {
// Restore saved language before anything else
restoreSavedLanguage()
val splashScreen = installSplashScreen()
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)
}
}
}

View file

@ -14,7 +14,7 @@ import kotlinx.coroutines.runBlocking
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@Singleton
class SettingsDataStore @Inject constructor(
@ -22,12 +22,12 @@ class SettingsDataStore @Inject constructor(
) {
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]
}
suspend fun setLanguage(languageCode: String?) {
context.dataStore.edit { preferences ->
context.settingsDataStore.edit { preferences ->
if (languageCode == null) {
preferences.remove(languageKey)
} else {
@ -38,7 +38,17 @@ class SettingsDataStore @Inject constructor(
fun getLanguageBlocking(): String? {
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]
}
}
}
}

View file

@ -46,6 +46,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
@ -95,10 +96,12 @@ fun MaskedEmailListScreen(
val createDesc = stringResource(R.string.email_list_create_description)
// 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
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.loadMaskedEmails()
delay(250L)
viewModel.refreshMaskedEmails()
}
}

View file

@ -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) {
_uiState.update {
it.copy(

View file

@ -3,6 +3,7 @@ package com.fastmask.ui.navigation
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
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.settings.SettingsScreen
private const val TRANSITION_DURATION_MS = 300
private const val TRANSITION_DURATION_MS = 220
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@ -36,26 +37,26 @@ fun FastMaskNavHost(
enterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(TRANSITION_DURATION_MS)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
)
},
exitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.Start,
animationSpec = tween(TRANSITION_DURATION_MS)
) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
)
},
popEnterTransition = {
slideIntoContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(TRANSITION_DURATION_MS)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS))
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
)
},
popExitTransition = {
slideOutOfContainer(
towards = AnimatedContentTransitionScope.SlideDirection.End,
animationSpec = tween(TRANSITION_DURATION_MS)
) + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS))
animationSpec = tween(TRANSITION_DURATION_MS, easing = FastOutSlowInEasing)
)
}
) {
composable(

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<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:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item>