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**: 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue