diff --git a/app/src/main/java/com/example/research/ui/about/AboutScreen.kt b/app/src/main/java/com/example/research/ui/about/AboutScreen.kt index 453e295..ecf50f6 100644 --- a/app/src/main/java/com/example/research/ui/about/AboutScreen.kt +++ b/app/src/main/java/com/example/research/ui/about/AboutScreen.kt @@ -1,19 +1,25 @@ package com.example.research.ui.about import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,13 +31,17 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag @@ -45,6 +55,7 @@ import com.example.research.R import com.example.research.common.ui.components.OutlinedChoiceButton import com.example.research.ui.theme.AppWindowInsets import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @@ -112,6 +123,7 @@ fun AboutScreen( contentType = { "about_library" }, ) { library -> AboutLibraryRow( + modifier = Modifier.animateItem(fadeInSpec = tween(150)), library = library, licenses = librariesData?.licenses.orEmpty(), expanded = expandedLibraryId == library.uniqueId, @@ -176,6 +188,7 @@ private fun AboutHeader(versionName: String) { @OptIn(ExperimentalLayoutApi::class) @Composable private fun AboutLibraryRow( + modifier: Modifier = Modifier, library: AboutLibrary, licenses: Map, expanded: Boolean, @@ -196,7 +209,7 @@ private fun AboutLibraryRow( ) Surface( - modifier = Modifier + modifier = modifier .fillMaxWidth() .testTag("about_library_row") .semantics { testTagsAsResourceId = true }, @@ -301,32 +314,79 @@ private fun AboutLibraryRow( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun LicenseDialog( license: AboutLicense, onDismiss: () -> Unit, onOpenWebsite: (String) -> Unit, ) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(license.name) }, - text = { - Text( - text = license.content ?: stringResource(R.string.license_text_unavailable), - modifier = Modifier.verticalScroll(rememberScrollState()) - ) - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_close)) - } - }, - dismissButton = { - license.url?.takeIf(String::isNotBlank)?.let { url -> - TextButton(onClick = { onOpenWebsite(url) }) { - Text(stringResource(R.string.website)) + var open by remember { mutableStateOf(false) } + var wasOpen by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { open = true } + val scale by animateFloatAsState( + targetValue = if (open) 1f else 0.92f, + animationSpec = tween(180), + label = "licenseDialogScale", + ) + val alpha by animateFloatAsState( + targetValue = if (open) 1f else 0f, + animationSpec = tween(180), + label = "licenseDialogAlpha", + ) + LaunchedEffect(open) { + if (open) { + wasOpen = true + } else if (wasOpen) { + snapshotFlow { alpha }.first { it == 0f } + onDismiss() + } + } + + BasicAlertDialog( + onDismissRequest = { open = false }, + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + } + ) { + val maxHeight = LocalConfiguration.current.screenHeightDp.dp - 96.dp + Surface( + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column(modifier = Modifier.padding(24.dp).heightIn(max = maxHeight)) { + Text( + text = license.name, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = license.content ?: stringResource(R.string.license_text_unavailable), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + license.url?.takeIf(String::isNotBlank)?.let { url -> + TextButton(onClick = { onOpenWebsite(url) }) { + Text(stringResource(R.string.website)) + } + } + TextButton(onClick = { open = false }) { + Text(stringResource(R.string.action_close)) + } } } } - ) + } } diff --git a/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt b/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt index 0afac6e..6b9756c 100644 --- a/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt +++ b/app/src/main/java/com/example/research/ui/main/components/SearchResultsList.kt @@ -1,5 +1,6 @@ package com.example.research.ui.main.components +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -45,12 +46,20 @@ fun SearchResultsList( contentType = results.itemContentType { "search_result" } ) { index -> results[index]?.let { entry -> - SearchResultItem( - entry = entry, - resultIndex = index, - onArticleClick = onArticleClick, - showDictionaryName = showDictionaryName - ) + Box( + modifier = Modifier.animateItem( + fadeInSpec = if (index < 8) tween(150) else null, + placementSpec = null, + fadeOutSpec = null + ) + ) { + SearchResultItem( + entry = entry, + resultIndex = index, + onArticleClick = onArticleClick, + showDictionaryName = showDictionaryName + ) + } } } diff --git a/app/src/main/java/com/example/research/ui/navigation/AppNavigation.kt b/app/src/main/java/com/example/research/ui/navigation/AppNavigation.kt index 75046e8..3322c82 100644 --- a/app/src/main/java/com/example/research/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/example/research/ui/navigation/AppNavigation.kt @@ -1,6 +1,13 @@ package com.example.research.ui.navigation import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2 import androidx.compose.runtime.* @@ -92,7 +99,21 @@ fun AppNavigation( shouldShowSettings -> Screen.Settings else -> currentScreen } - when (effectiveScreen) { + AnimatedContent( + targetState = effectiveScreen, + transitionSpec = { + val forward = initialState.ordinal < targetState.ordinal + ( + fadeIn(tween(180)) + + slideInHorizontally(tween(180)) { if (forward) it / 3 else -it / 3 } + ).togetherWith( + fadeOut(tween(180)) + + slideOutHorizontally(tween(180)) { if (forward) -it / 3 else it / 3 } + ) + }, + label = "screenTransition", + ) { screen -> + when (screen) { Screen.Home -> { MainRoute( searchViewModel = searchViewModel, @@ -142,5 +163,6 @@ fun AppNavigation( } ) } + } } } diff --git a/app/src/main/java/com/example/research/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/research/ui/settings/SettingsScreen.kt index b89a53f..1ba44c5 100644 --- a/app/src/main/java/com/example/research/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/research/ui/settings/SettingsScreen.kt @@ -3,6 +3,11 @@ package com.example.research.ui.settings import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -266,7 +271,11 @@ private fun CollapsibleSettingsSection( testTag = testTag ) - if (isExpanded) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt b/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt index 93fb225..ed4e55f 100644 --- a/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt +++ b/app/src/main/java/com/example/research/ui/settings/components/DictionaryManagement.kt @@ -1,6 +1,9 @@ package com.example.research.ui.settings.components +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -15,8 +18,10 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -260,18 +265,34 @@ private fun DictionaryListSection( modifier: Modifier = Modifier, isDeleteBlocked: () -> Boolean = { false } ) { + var appearedPaths by remember { mutableStateOf(emptySet()) } + LaunchedEffect(dictionaries) { + val visiblePaths = dictionaries.mapTo(mutableSetOf(), Dictionary::path) + appearedPaths = appearedPaths intersect visiblePaths + } Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp) ) { dictionaries.forEach { dictionary -> key(dictionary.path) { - DictionaryListItem( - dictionary = dictionary, - onToggle = { onToggleDictionary(dictionary.path) }, - onDelete = { onDeleteDictionary(dictionary) }, - isDeleteBlocked = isDeleteBlocked + val hasAppeared = dictionary.path in appearedPaths + val rowAlpha by animateFloatAsState( + targetValue = if (hasAppeared) 1f else 0f, + animationSpec = tween(150), + label = "dictionaryRowAppear", ) + LaunchedEffect(dictionary.path) { + appearedPaths = appearedPaths + dictionary.path + } + Box(modifier = Modifier.alpha(rowAlpha)) { + DictionaryListItem( + dictionary = dictionary, + onToggle = { onToggleDictionary(dictionary.path) }, + onDelete = { onDeleteDictionary(dictionary) }, + isDeleteBlocked = isDeleteBlocked + ) + } } } }