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**: 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

View file

@ -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 {

View file

@ -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
}
}
}

View file

@ -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)
}
}
} }

View file

@ -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]
}
} }
} }
} }

View file

@ -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()
} }
} }

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) { fun onSearchQueryChange(query: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(

View file

@ -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(

View file

@ -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>

View file

@ -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>