diff --git a/README.md b/README.md index 47967d7..2f8420c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7dbe926..b729b4f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/fastmask/FastMaskApplication.kt b/app/src/main/java/com/fastmask/FastMaskApplication.kt index d2e00fe..c6187c9 100644 --- a/app/src/main/java/com/fastmask/FastMaskApplication.kt +++ b/app/src/main/java/com/fastmask/FastMaskApplication.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/fastmask/MainActivity.kt b/app/src/main/java/com/fastmask/MainActivity.kt index f0da3fd..ff75911 100644 --- a/app/src/main/java/com/fastmask/MainActivity.kt +++ b/app/src/main/java/com/fastmask/MainActivity.kt @@ -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) - } - } } diff --git a/app/src/main/java/com/fastmask/data/local/SettingsDataStore.kt b/app/src/main/java/com/fastmask/data/local/SettingsDataStore.kt index 85edc60..c4258eb 100644 --- a/app/src/main/java/com/fastmask/data/local/SettingsDataStore.kt +++ b/app/src/main/java/com/fastmask/data/local/SettingsDataStore.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton -private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +val Context.settingsDataStore: DataStore 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 = context.dataStore.data.map { preferences -> + val languageFlow: Flow = 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] + } } } } diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt index d656b68..cbb8846 100644 --- a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt @@ -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() } } diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt index b1a5efc..5dfc3a7 100644 --- a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListViewModel.kt @@ -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( diff --git a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt index bc57b3b..57a5874 100644 --- a/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt +++ b/app/src/main/java/com/fastmask/ui/navigation/FastMaskNavHost.kt @@ -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( diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index 1da3572..c2f5e90 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -1,6 +1,6 @@ -