diff --git a/.circleci/config.yml b/.circleci/config.yml index b2f86e9f..a2e7e87c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2.1 orbs: - slack: circleci/slack@4.13.2 + slack: circleci/slack@4.13.3 gh: circleci/github-cli@2.3.0 executors: diff --git a/README.md b/README.md index ceffde24..6baf59db 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ https://github.com/kosenda/hiragana-converter/blob/develop/REFERENCE.md ## Typical libraries used |Name|Brief description| |:--|:--| +|AboutLibraries|OSS Licenses| |Analytics|Firebase analytics| |App Update|In App Update| |App Review|In App Review| @@ -47,7 +48,6 @@ https://github.com/kosenda/hiragana-converter/blob/develop/REFERENCE.md |Ktlint|Formatter| |Material3|Design| |Mockk|Unit test mock| -|OSS licenses plugin|OSS Licenses| |Preferences DataStore|Permanent data| |Renovate|Automated project dependency updates| |Retrofit2|Library for API communications| diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9041cf83..b4912e07 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,6 @@ plugins { id("hiraganaconverter.android.application") id("hiraganaconverter.android.application.jacoco") id("hiraganaconverter.android.hilt") - alias(libs.plugins.oss.licenses) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.secrets) alias(libs.plugins.dokka) @@ -15,6 +14,7 @@ plugins { alias(libs.plugins.firebase.crashlytics) alias(libs.plugins.firebase.perf) alias(libs.plugins.firebase.appdistribution) + alias(libs.plugins.aboutLibraries) } android { @@ -73,11 +73,11 @@ android { } } buildFeatures { - compose = true buildConfig = true + compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidxComposeCompiler.get() + composeCompiler { + enableStrongSkippingMode = true } packaging { resources { @@ -121,9 +121,6 @@ dependencies { // Timber implementation(libs.timber) - // OSS Licenses - implementation(libs.play.oss.licenses) - // Splash Screen implementation(libs.androidx.core.splashscreen) @@ -146,6 +143,9 @@ dependencies { // kotlinx serialization implementation(libs.kotlinx.serialization.json) + + // AboutLibraries + implementation(libs.aboutLibraries) } tasks.withType().configureEach { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a456f2ff..0ea420ff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,14 +29,6 @@ - - - - { ConvertHistoryScreen( viewModel = hiltViewModel(), - onBackPressed = ::navigateUp, + onBackPressed = dropUnlessResumed(block = navController::navigateUp), ) } slideHorizontallyComposable { SettingScreen( viewModel = hiltViewModel(), - onBackPressed = ::navigateUp, + onBackPressed = dropUnlessResumed(block = navController::navigateUp), ) } slideHorizontallyComposable { InfoScreen( viewModel = hiltViewModel(), - onBackPressed = ::navigateUp, + onBackPressed = dropUnlessResumed(block = navController::navigateUp), + onClickLicense = dropUnlessResumed { navigateScreen(Nav.License) }, + ) + } + slideHorizontallyComposable { + LicenseScreen( + viewModel = hiltViewModel(), + navigateLicenseDetail = ::navigateLicenseDetail, + onBackPressed = dropUnlessResumed(block = navController::navigateUp), + ) + } + slideHorizontallyComposable { + LicenseDetailScreen( + libraryName = it.toRoute().libraryName, + licenseContent = it.toRoute().licenseContent, + onBackPressed = dropUnlessResumed(block = navController::navigateUp), ) } } diff --git a/app/src/test/java/ksnd/hiraganaconverter/view/screen/ConverterScreenTest.kt b/app/src/test/java/ksnd/hiraganaconverter/view/screen/ConverterScreenTest.kt index a5a446bf..a2bdf03b 100644 --- a/app/src/test/java/ksnd/hiraganaconverter/view/screen/ConverterScreenTest.kt +++ b/app/src/test/java/ksnd/hiraganaconverter/view/screen/ConverterScreenTest.kt @@ -4,19 +4,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.captureRoboImage import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme import ksnd.hiraganaconverter.feature.converter.ConvertUiState import ksnd.hiraganaconverter.feature.converter.ConverterScreenContent -import ksnd.hiraganaconverter.view.TopBar import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -30,29 +23,18 @@ class ConverterScreenTest { @Test fun converterScreen_light() { captureRoboImage { - var topBarHeight by remember { mutableIntStateOf(0) } - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) HiraganaConverterTheme(isDarkTheme = false) { ConverterScreenContent( uiState = ConvertUiState(), snackbarHostState = SnackbarHostState(), - topBar = { - TopBar( - modifier = Modifier.onSizeChanged { topBarHeight = it.height }, - scrollBehavior = scrollBehavior, - transitionHistory = {}, - transitionSetting = {}, - transitionInfo = {}, - ) - }, - topBarHeight = topBarHeight, - scrollBehavior = scrollBehavior, + scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()), changeHiraKanaType = {}, clearAllText = {}, convert = {}, updateInputText = {}, updateOutputText = {}, hideErrorCard = {}, + navigateScreen = {}, ) } } @@ -61,29 +43,18 @@ class ConverterScreenTest { @Test fun converterScreen_dark() { captureRoboImage { - var topBarHeight by remember { mutableIntStateOf(0) } - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) HiraganaConverterTheme(isDarkTheme = true) { ConverterScreenContent( uiState = ConvertUiState(), snackbarHostState = SnackbarHostState(), - topBar = { - TopBar( - modifier = Modifier.onSizeChanged { topBarHeight = it.height }, - scrollBehavior = scrollBehavior, - transitionHistory = {}, - transitionSetting = {}, - transitionInfo = {}, - ) - }, - topBarHeight = topBarHeight, - scrollBehavior = scrollBehavior, + scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()), changeHiraKanaType = {}, clearAllText = {}, convert = {}, updateInputText = {}, updateOutputText = {}, hideErrorCard = {}, + navigateScreen = {}, ) } } diff --git a/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidApplicationPlugin.kt b/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidApplicationPlugin.kt index b4b86c9a..96072445 100644 --- a/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidApplicationPlugin.kt +++ b/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidApplicationPlugin.kt @@ -11,10 +11,10 @@ import org.gradle.kotlin.dsl.getByType class AndroidApplicationPlugin : Plugin { override fun apply(target: Project) { with(target) { - val libs = extensions.getByType().named("libs") with(pluginManager) { apply("com.android.application") apply("org.jetbrains.kotlin.android") + apply("org.jetbrains.kotlin.plugin.compose") } extensions.configure { compileSdk = 34 @@ -22,8 +22,8 @@ class AndroidApplicationPlugin : Plugin { applicationId = "ksnd.hiraganaconverter" minSdk = 26 targetSdk = 34 - versionCode = 41 - versionName = "1.30" + versionCode = 42 + versionName = "1.31" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { @@ -33,12 +33,6 @@ class AndroidApplicationPlugin : Plugin { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString() - } } } } diff --git a/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidLibraryComposePlugin.kt b/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidLibraryComposePlugin.kt index a5dcc35c..cf90659a 100644 --- a/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidLibraryComposePlugin.kt +++ b/build-logic/convention/src/main/kotlin/ksnd/hiraganaconverter/AndroidLibraryComposePlugin.kt @@ -13,16 +13,14 @@ class AndroidLibraryComposePlugin : Plugin { with(target) { with(pluginManager) { apply("com.google.devtools.ksp") + apply("org.jetbrains.kotlin.plugin.compose") } - val libs = extensions.getByType().named("libs") extensions.configure { buildFeatures { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString() - } } + val libs = extensions.getByType().named("libs") dependencies { add("implementation", libs.findLibrary("androidx.compose.material3").get()) add("implementation", libs.findLibrary("androidx.compose.ui").get()) diff --git a/build.gradle.kts b/build.gradle.kts index 3d54e5da..d4499353 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,9 +5,9 @@ plugins { jacoco alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false - alias(libs.plugins.oss.licenses) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.secrets) apply false alias(libs.plugins.dokka) apply false @@ -17,6 +17,7 @@ plugins { alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.firebase.perf) apply false alias(libs.plugins.firebase.appdistribution) apply false + alias(libs.plugins.aboutLibraries) apply false } tasks.create("jacocoTestReport") { @@ -71,19 +72,13 @@ tasks.create("jacocoTestReport") { subprojects { tasks.withType().configureEach { - kotlinOptions { + compilerOptions { val composeCompilerDir = "${project.layout.buildDirectory.get()}/compose_compiler" if (project.findProperty("composeCompilerReports") == "true") { - freeCompilerArgs += listOf( - "-P", - "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$composeCompilerDir" - ) + freeCompilerArgs.add("-P=plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=$composeCompilerDir") } if (project.findProperty("composeCompilerMetrics") == "true") { - freeCompilerArgs += listOf( - "-P", - "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$composeCompilerDir" - ) + freeCompilerArgs.add("-P=plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=$composeCompilerDir") } } } diff --git a/core/resource/src/main/res/drawable/baseline_open_in_new_24.xml b/core/resource/src/main/res/drawable/baseline_open_in_new_24.xml new file mode 100644 index 00000000..99987474 --- /dev/null +++ b/core/resource/src/main/res/drawable/baseline_open_in_new_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/core/resource/src/main/res/values-ja/strings.xml b/core/resource/src/main/res/values-ja/strings.xml index 0e2d4b81..0c8c45e7 100644 --- a/core/resource/src/main/res/values-ja/strings.xml +++ b/core/resource/src/main/res/values-ja/strings.xml @@ -16,7 +16,6 @@ テーマ 言語 フォント - すべて削除 Top ネットワークに接続していません… @@ -39,6 +38,15 @@ アプリのレビューをしていただけないでしょうか? 後で + + 情報 + 設定 + 履歴 + + + 削除確認 + 変換履歴を全て削除します。 + ひらがな カタカナ diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index b8df3974..00857e14 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -16,7 +16,6 @@ Theme Language Font - Delete all Top Not connected to network… @@ -39,6 +38,15 @@ Could you please review the application? Later + + Info + Settings + History + + + Confirmation + Delete all conversion history. + ひらがな カタカナ @@ -53,6 +61,7 @@ App Info App Name OK + Cancel Developer KSND diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index ac389d1e..e7bb072c 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -30,4 +30,7 @@ dependencies { // kotlinx serialization implementation(libs.kotlinx.serialization.json) + + // AboutLibraries + implementation(libs.aboutLibraries) } diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/navigation/Nav.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/navigation/Nav.kt index a922063f..7f03148a 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/navigation/Nav.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/navigation/Nav.kt @@ -14,4 +14,13 @@ sealed class Nav { @Serializable data object Info : Nav() + + @Serializable + data object License : Nav() + + @Serializable + data class LicenseDetail( + val libraryName: String, + val licenseContent: String, + ) : Nav() } diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/BackTopBar.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/BackTopBar.kt index 98809b8e..d45b543b 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/BackTopBar.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/BackTopBar.kt @@ -1,23 +1,37 @@ package ksnd.hiraganaconverter.core.ui.parts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import ksnd.hiraganaconverter.core.resource.R @@ -27,38 +41,64 @@ import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme @OptIn(ExperimentalMaterial3Api::class) @Composable fun BackTopBar( + title: String, scrollBehavior: TopAppBarScrollBehavior, modifier: Modifier = Modifier, onBackPressed: () -> Unit, - title: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, ) { - TopAppBar( - title = title, - modifier = modifier, - scrollBehavior = scrollBehavior, - navigationIcon = { - Box( - modifier = Modifier - .displayCutoutPadding() - .padding(start = 16.dp) - .clip(shape = CircleShape) - .size(48.dp) - .clickable(onClick = onBackPressed), - contentAlignment = Alignment.Center, - ) { - Image( - modifier = Modifier.size(28.dp), - painter = painterResource(id = R.drawable.baseline_arrow_back_24), - contentDescription = "back screen", - colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.primary), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - scrolledContainerColor = Color.Transparent, + val layoutDirection = LocalLayoutDirection.current + val isShowTopBar by remember(scrollBehavior.state.collapsedFraction) { + derivedStateOf { scrollBehavior.state.collapsedFraction != 1.toFloat() } + } + + AnimatedVisibility( + visible = isShowTopBar, + modifier = modifier.padding( + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), ), - ) + enter = fadeIn(), + exit = fadeOut(), + ) { + TopAppBar( + title = { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + modifier = modifier.background(MaterialTheme.colorScheme.surface), + scrollBehavior = scrollBehavior, + navigationIcon = { + Box( + modifier = Modifier + .padding(start = 16.dp) + .clip(shape = CircleShape) + .size(48.dp) + .clickable(onClick = onBackPressed), + contentAlignment = Alignment.Center, + ) { + Image( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.baseline_arrow_back_24), + contentDescription = "back screen", + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.primary), + ) + } + }, + actions = actions, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -67,6 +107,7 @@ fun BackTopBar( fun PreviewBackTopBar() { HiraganaConverterTheme { BackTopBar( + title = "Title", scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), onBackPressed = {}, ) diff --git a/app/src/main/java/ksnd/hiraganaconverter/view/TopBar.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/TopBar.kt similarity index 69% rename from app/src/main/java/ksnd/hiraganaconverter/view/TopBar.kt rename to core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/TopBar.kt index 95798f0b..b60c7847 100644 --- a/app/src/main/java/ksnd/hiraganaconverter/view/TopBar.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/TopBar.kt @@ -1,10 +1,15 @@ -package ksnd.hiraganaconverter.view +package ksnd.hiraganaconverter.core.ui.parts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -18,30 +23,38 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.dropUnlessResumed import ksnd.hiraganaconverter.core.resource.R import ksnd.hiraganaconverter.core.ui.isTest -import ksnd.hiraganaconverter.core.ui.parts.GooCreditImage +import ksnd.hiraganaconverter.core.ui.navigation.Nav import ksnd.hiraganaconverter.core.ui.parts.button.CustomIconButton import ksnd.hiraganaconverter.core.ui.preview.UiModePreview @OptIn(ExperimentalMaterial3Api::class) @Composable fun TopBar( - modifier: Modifier = Modifier, scrollBehavior: TopAppBarScrollBehavior, - transitionHistory: () -> Unit, - transitionSetting: () -> Unit, - transitionInfo: () -> Unit, + modifier: Modifier = Modifier, + navigateScreen: (Nav) -> Unit, ) { + val layoutDirection = LocalLayoutDirection.current val isShowTopBar by remember(scrollBehavior.state.collapsedFraction) { derivedStateOf { scrollBehavior.state.collapsedFraction != 1.toFloat() } } AnimatedVisibility( visible = isShowTopBar, - modifier = modifier, + modifier = modifier.padding( + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), + ), enter = fadeIn(), exit = fadeOut(), ) { @@ -55,21 +68,21 @@ fun TopBar( ), actions = { CustomIconButton( - modifier = Modifier.padding(horizontal = 8.dp), - contentDescription = "info", painter = painterResource(id = R.drawable.ic_outline_info_24), - onClick = transitionInfo, + contentDescription = "", + modifier = Modifier.padding(horizontal = 8.dp), + onClick = dropUnlessResumed { navigateScreen(Nav.Info) }, ) CustomIconButton( - modifier = Modifier.padding(end = 8.dp), - contentDescription = "settings", painter = painterResource(id = R.drawable.ic_outline_settings_24), - onClick = transitionSetting, + contentDescription = "", + modifier = Modifier.padding(end = 8.dp), + onClick = dropUnlessResumed { navigateScreen(Nav.Setting) }, ) CustomIconButton( - contentDescription = "history", painter = painterResource(id = R.drawable.ic_baseline_history_24), - onClick = transitionHistory, + contentDescription = "", + onClick = dropUnlessResumed { navigateScreen(Nav.History) }, ) Spacer(modifier = Modifier.weight(1f)) if (isTest().not()) { @@ -87,8 +100,6 @@ fun TopBar( fun PreviewTopBar() { TopBar( scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()), - transitionHistory = {}, - transitionSetting = {}, - transitionInfo = {}, + navigateScreen = {}, ) } diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomButtonWithBackground.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomButtonWithBackground.kt index b3cff8cb..0de3767f 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomButtonWithBackground.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomButtonWithBackground.kt @@ -5,10 +5,14 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.RippleTheme import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color @@ -34,25 +38,36 @@ fun CustomButtonWithBackground( ) { val buttonScaleState = rememberButtonScaleState() val localView = LocalView.current - IconButton( - modifier = modifier - .padding(all = 8.dp) - .size(size = 56.dp) - .scale(scale = buttonScaleState.animationScale.value), - onClick = { - localView.performHapticFeedback(CONTEXT_CLICK) - onClick() + + CompositionLocalProvider( + LocalRippleTheme provides object : RippleTheme { + @Composable + override fun defaultColor() = Color.Transparent + + @Composable + override fun rippleAlpha() = RippleAlpha(0f, 0f, 0f, 0f) }, - colors = IconButtonDefaults.iconButtonColors(containerColor = containerColor), - interactionSource = buttonScaleState.interactionSource, ) { - Image( - painter = painterResource(id = id), - contentDescription = convertDescription, - modifier = Modifier.size(36.dp), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(color = contentColor), - ) + IconButton( + modifier = modifier + .padding(all = 8.dp) + .size(size = 56.dp) + .scale(scale = buttonScaleState.animationScale.value), + onClick = { + localView.performHapticFeedback(CONTEXT_CLICK) + onClick() + }, + colors = IconButtonDefaults.iconButtonColors(containerColor = containerColor), + interactionSource = buttonScaleState.interactionSource, + ) { + Image( + painter = painterResource(id = id), + contentDescription = convertDescription, + modifier = Modifier.size(36.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(color = contentColor), + ) + } } } diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomlIconButton.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomlIconButton.kt index b374ccfe..0ac07c8c 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomlIconButton.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/CustomlIconButton.kt @@ -24,10 +24,10 @@ import ksnd.hiraganaconverter.core.ui.theme.secondaryBrush @Composable fun CustomIconButton( - modifier: Modifier = Modifier, - contentDescription: String, painter: Painter, - contentColor: Color? = MaterialTheme.colorScheme.primary, + contentDescription: String, + modifier: Modifier = Modifier, + contentColor: Color? = null, containerColor: Color = MaterialTheme.colorScheme.surface, onClick: () -> Unit, ) { @@ -45,15 +45,15 @@ fun CustomIconButton( Image( painter = painter, contentDescription = contentDescription, - colorFilter = if (contentColor == null) null else ColorFilter.tint(contentColor), + colorFilter = contentColor?.let { ColorFilter.tint(it) }, contentScale = ContentScale.Fit, modifier = Modifier .size(32.dp) .then( if (contentColor == null) { - Modifier - } else { Modifier.contentBrush(brush = secondaryBrush()) + } else { + Modifier }, ), ) diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/DeleteButton.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/DeleteButton.kt deleted file mode 100644 index 1b467b91..00000000 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/DeleteButton.kt +++ /dev/null @@ -1,78 +0,0 @@ -package ksnd.hiraganaconverter.core.ui.parts.button - -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import ksnd.hiraganaconverter.core.resource.R -import ksnd.hiraganaconverter.core.ui.preview.UiModePreview -import ksnd.hiraganaconverter.core.ui.rememberButtonScaleState -import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme - -@Composable -fun DeleteButton( - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - val buttonScaleState = rememberButtonScaleState() - Button( - modifier = modifier - .padding(vertical = 8.dp) - .defaultMinSize(minHeight = 48.dp) - .scale(scale = buttonScaleState.animationScale.value), - onClick = onClick, - interactionSource = buttonScaleState.interactionSource, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), - ) { - Row( - modifier = Modifier.wrapContentSize(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Image( - painter = painterResource(id = R.drawable.ic_baseline_delete_outline_24), - contentDescription = "convert", - colorFilter = ColorFilter.tint( - MaterialTheme.colorScheme.error, - ), - contentScale = ContentScale.Fit, - modifier = Modifier.size(36.dp), - ) - Text( - text = stringResource(id = R.string.delete_all), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 8.dp), - ) - } - } -} - -@UiModePreview -@Composable -fun PreviewDeleteButton() { - HiraganaConverterTheme { - Surface(color = MaterialTheme.colorScheme.surface) { - DeleteButton(onClick = {}) - } - } -} diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/MoveTopButton.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/MoveTopButton.kt index 4acd31fd..8f94b153 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/MoveTopButton.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/button/MoveTopButton.kt @@ -8,19 +8,22 @@ import androidx.compose.animation.slideOut import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset @@ -33,13 +36,19 @@ import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme @Composable fun MoveTopButton(scrollState: ScrollState) { val coroutineScope = rememberCoroutineScope() + val layoutDirection = LocalLayoutDirection.current val offset = IntOffset(x = 100, y = 100) - val showVisibleTopBar by remember { - derivedStateOf { scrollState.value > 0 } - } AnimatedVisibility( - visible = showVisibleTopBar, + visible = scrollState.canScrollBackward, + modifier = Modifier.padding( + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), + ), enter = scaleIn() + slideIn(initialOffset = { offset }), exit = scaleOut() + slideOut(targetOffset = { offset }), ) { diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ConversionTypeCard.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ConversionTypeCard.kt index 52e0f4a3..f5ab7b44 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ConversionTypeCard.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ConversionTypeCard.kt @@ -1,6 +1,8 @@ package ksnd.hiraganaconverter.core.ui.parts.card import android.view.HapticFeedbackConstants +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card @@ -14,6 +16,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource @@ -21,6 +24,7 @@ import androidx.compose.ui.unit.dp import ksnd.hiraganaconverter.core.model.ui.HiraKanaType import ksnd.hiraganaconverter.core.resource.R import ksnd.hiraganaconverter.core.ui.extension.noRippleClickable +import ksnd.hiraganaconverter.core.ui.isTest import ksnd.hiraganaconverter.core.ui.preview.UiModePreview import ksnd.hiraganaconverter.core.ui.rememberButtonScaleState import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme @@ -33,6 +37,12 @@ fun ConversionTypeCard( val buttonScaleState = rememberButtonScaleState() val localView = LocalView.current + val rotation by animateFloatAsState( + targetValue = if (selectedTextType == HiraKanaType.HIRAGANA) 180f else 0f, + animationSpec = tween(500), + label = "", + ) + Card( modifier = Modifier .padding(all = 8.dp) @@ -47,19 +57,39 @@ fun ConversionTypeCard( } onSelectedChange(selectedTextType) }, - ), + ) + .graphicsLayer { + if (isTest().not()) rotationY = rotation + cameraDistance = 10f * density + }, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, + containerColor = if (rotation <= 90f) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.tertiaryContainer + }, ), ) { - ConversionTypeSpinnerCardContent(selectedTextType = selectedTextType) + if (rotation <= 90f || isTest()) { + ConversionTypeSpinnerCardContent( + selectedTextType = selectedTextType, + ) + } else { + ConversionTypeSpinnerCardContent( + modifier = Modifier.graphicsLayer { rotationY = 180f }, + selectedTextType = selectedTextType, + ) + } } } @Composable -private fun ConversionTypeSpinnerCardContent(selectedTextType: HiraKanaType) { +private fun ConversionTypeSpinnerCardContent( + selectedTextType: HiraKanaType, + modifier: Modifier = Modifier, +) { Column( - modifier = Modifier.padding(all = 8.dp), + modifier = modifier.padding(all = 8.dp), ) { Text( text = stringResource(id = R.string.conversion_type), diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/CovertHistoryCard.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/CovertHistoryCard.kt index b9a8fdd6..ec92fb87 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/CovertHistoryCard.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/CovertHistoryCard.kt @@ -39,7 +39,6 @@ fun ConvertHistoryCard( val buttonScaleState = rememberButtonScaleState() Card( modifier = modifier - .padding(top = 8.dp) .wrapContentHeight() .fillMaxWidth() .scale(scale = buttonScaleState.animationScale.value) diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ErrorCard.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ErrorCard.kt index 8f375071..c09b3613 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ErrorCard.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/ErrorCard.kt @@ -29,7 +29,7 @@ import ksnd.hiraganaconverter.core.ui.preview.UiModePreview import ksnd.hiraganaconverter.core.ui.rememberButtonScaleState import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme -const val ERROR_CARD_ANIMATUIB_DURATION = 500 +const val ERROR_CARD_ANIMATE_DURATION = 500 @Composable fun ErrorCard( @@ -41,8 +41,8 @@ fun ErrorCard( AnimatedVisibility( visible = visible, modifier = Modifier.background(color = MaterialTheme.colorScheme.surface), - enter = expandVertically(expandFrom = Alignment.Top, animationSpec = tween(ERROR_CARD_ANIMATUIB_DURATION)), - exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(ERROR_CARD_ANIMATUIB_DURATION)), + enter = expandVertically(expandFrom = Alignment.Top, animationSpec = tween(ERROR_CARD_ANIMATE_DURATION)), + exit = shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(ERROR_CARD_ANIMATE_DURATION)), ) { Card( modifier = Modifier diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/TitleCard.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/TitleCard.kt index 513232f8..c5302f7c 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/TitleCard.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/card/TitleCard.kt @@ -27,9 +27,13 @@ import ksnd.hiraganaconverter.core.ui.theme.contentBrush import ksnd.hiraganaconverter.core.ui.theme.primaryBrush @Composable -fun TitleCard(text: String, painter: Painter) { +fun TitleCard( + text: String, + painter: Painter, + modifier: Modifier = Modifier, +) { Card( - modifier = Modifier.padding(top = 28.dp, bottom = 4.dp), + modifier = modifier.padding(top = 28.dp, bottom = 4.dp), colors = CardDefaults.cardColors( containerColor = Color.Transparent, ), diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/DialogTopBar.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/DialogTopBar.kt index d2b0d470..23639eca 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/DialogTopBar.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/DialogTopBar.kt @@ -42,9 +42,9 @@ fun DialogTopBar( ) { Row(modifier = Modifier.weight(1f), content = leftContent) CustomIconButton( - modifier = Modifier.padding(end = 8.dp), - contentDescription = "", painter = painterResource(id = R.drawable.baseline_close_24), + contentDescription = "", + modifier = Modifier.padding(end = 8.dp), onClick = onCloseClick, ) } diff --git a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/MovesToSiteDialog.kt b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/MovesToSiteDialog.kt index c41c8240..f996ead5 100644 --- a/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/MovesToSiteDialog.kt +++ b/core/ui/src/main/java/ksnd/hiraganaconverter/core/ui/parts/dialog/MovesToSiteDialog.kt @@ -18,16 +18,12 @@ fun MovesToSiteDialog(onDismissRequest: () -> Unit, onClick: () -> Unit, url: St text = { Text(text = url) }, confirmButton = { TextButton(onClick = onClick) { - Text( - text = "OK", - ) + Text(text = stringResource(id = R.string.ok)) } }, dismissButton = { TextButton(onClick = onDismissRequest) { - Text( - text = "Cancel", - ) + Text(text = stringResource(id = R.string.cancel)) } }, ) diff --git a/feature/converter/src/main/java/ksnd/hiraganaconverter/feature/converter/ConverterScreen.kt b/feature/converter/src/main/java/ksnd/hiraganaconverter/feature/converter/ConverterScreen.kt index b1cea8bf..1cb52d2b 100644 --- a/feature/converter/src/main/java/ksnd/hiraganaconverter/feature/converter/ConverterScreen.kt +++ b/feature/converter/src/main/java/ksnd/hiraganaconverter/feature/converter/ConverterScreen.kt @@ -37,13 +37,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -66,12 +69,14 @@ import ksnd.hiraganaconverter.core.model.ui.HiraKanaType import ksnd.hiraganaconverter.core.resource.LIMIT_CONVERT_COUNT import ksnd.hiraganaconverter.core.resource.R import ksnd.hiraganaconverter.core.ui.LocalIsConnectNetwork +import ksnd.hiraganaconverter.core.ui.navigation.Nav +import ksnd.hiraganaconverter.core.ui.parts.TopBar import ksnd.hiraganaconverter.core.ui.parts.button.ConvertButton import ksnd.hiraganaconverter.core.ui.parts.button.CustomButtonWithBackground import ksnd.hiraganaconverter.core.ui.parts.button.CustomIconButton import ksnd.hiraganaconverter.core.ui.parts.button.MoveTopButton import ksnd.hiraganaconverter.core.ui.parts.card.ConversionTypeCard -import ksnd.hiraganaconverter.core.ui.parts.card.ERROR_CARD_ANIMATUIB_DURATION +import ksnd.hiraganaconverter.core.ui.parts.card.ERROR_CARD_ANIMATE_DURATION import ksnd.hiraganaconverter.core.ui.parts.card.ErrorCard import ksnd.hiraganaconverter.core.ui.parts.card.OfflineCard import ksnd.hiraganaconverter.core.ui.preview.UiModePreview @@ -85,9 +90,7 @@ fun ConverterScreen( viewModel: ConvertViewModel, snackbarHostState: SnackbarHostState, scrollBehavior: TopAppBarScrollBehavior, - topBarHeight: Int, - modifier: Modifier = Modifier, - topBar: @Composable () -> Unit, + navigateScreen: (Nav) -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle(ConvertUiState()) val analytics = LocalAnalytics.current @@ -98,17 +101,15 @@ fun ConverterScreen( LaunchedEffect(uiState.showErrorCard) { if (uiState.convertErrorType != null && uiState.showErrorCard.not()) { - delay(ERROR_CARD_ANIMATUIB_DURATION.toLong()) + // Prevents text from disappearing during Animation + delay(ERROR_CARD_ANIMATE_DURATION.toLong()) viewModel.clearConvertErrorType() } } ConverterScreenContent( - modifier = modifier, uiState = uiState, snackbarHostState = snackbarHostState, - topBar = topBar, - topBarHeight = topBarHeight, scrollBehavior = scrollBehavior, changeHiraKanaType = viewModel::changeHiraKanaType, clearAllText = viewModel::clearAllText, @@ -116,17 +117,15 @@ fun ConverterScreen( updateInputText = viewModel::updateInputText, updateOutputText = viewModel::updateOutputText, hideErrorCard = viewModel::hideErrorCard, + navigateScreen = navigateScreen, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConverterScreenContent( - modifier: Modifier = Modifier, uiState: ConvertUiState, snackbarHostState: SnackbarHostState, - topBar: @Composable () -> Unit, - topBarHeight: Int, scrollBehavior: TopAppBarScrollBehavior, changeHiraKanaType: (HiraKanaType) -> Unit, clearAllText: () -> Unit, @@ -134,6 +133,7 @@ fun ConverterScreenContent( updateInputText: (String) -> Unit, updateOutputText: (String) -> Unit, hideErrorCard: () -> Unit, + navigateScreen: (Nav) -> Unit, ) { val focusManager = LocalFocusManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current @@ -141,27 +141,18 @@ fun ConverterScreenContent( val layoutDirection = LocalLayoutDirection.current val scrollState = rememberScrollState() val isConnectNetwork = LocalIsConnectNetwork.current + var topBarHeight by remember { mutableIntStateOf(0) } + val navigationHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() Scaffold( - modifier = modifier - .background(MaterialTheme.colorScheme.surface) - .padding( - start = WindowInsets.displayCutout - .asPaddingValues() - .calculateStartPadding(layoutDirection), - end = WindowInsets.displayCutout - .asPaddingValues() - .calculateEndPadding(layoutDirection), + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + topBar = { + TopBar( + modifier = Modifier.onSizeChanged { topBarHeight = it.height }, + scrollBehavior = scrollBehavior, + navigateScreen = navigateScreen, ) - .padding( - start = WindowInsets.navigationBars - .asPaddingValues() - .calculateStartPadding(layoutDirection), - end = WindowInsets.navigationBars - .asPaddingValues() - .calculateEndPadding(layoutDirection), - ), - topBar = topBar, + }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, containerColor = MaterialTheme.colorScheme.surface, floatingActionButton = { MoveTopButton(scrollState = scrollState) }, @@ -174,8 +165,16 @@ fun ConverterScreenContent( ) { Column( modifier = Modifier - .padding(horizontal = 8.dp) .consumeWindowInsets(innerPadding) + .padding( + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), + ) + .padding(horizontal = 8.dp) .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection) .verticalScroll(scrollState) @@ -221,6 +220,7 @@ fun ConverterScreenContent( id = R.string.limit_local_count, LIMIT_CONVERT_COUNT, ) + else -> "" }, onClick = hideErrorCard, @@ -248,7 +248,7 @@ fun ConverterScreenContent( onValueChange = updateOutputText, ) - Spacer(modifier = Modifier.height(120.dp)) + Spacer(modifier = Modifier.height(120.dp + navigationHeight)) } } } @@ -281,18 +281,18 @@ private fun BeforeOrAfterTextField( if (isBefore) { CustomIconButton( - modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), - contentDescription = "pasteText", painter = painterResource(id = R.drawable.ic_baseline_content_paste_24), + contentDescription = "", + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), onClick = { onValueChange(clipboardManager.getText()?.text ?: "") }, ) } else { CustomIconButton( - modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), - contentDescription = "share", painter = painterResource(id = R.drawable.baseline_share_24), + contentDescription = "", + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), onClick = { context.startActivity( ShareCompat.IntentBuilder(context) @@ -305,7 +305,7 @@ private fun BeforeOrAfterTextField( CustomIconButton( modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), - contentDescription = "copyText", + contentDescription = "", painter = painterResource(id = R.drawable.ic_baseline_content_copy_24), onClick = { clipboardManager.setText(AnnotatedString(text)) @@ -361,8 +361,6 @@ fun PreviewConverterScreenContent( ConverterScreenContent( uiState = uiState, snackbarHostState = remember { SnackbarHostState() }, - topBar = { }, - topBarHeight = 0, scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()), changeHiraKanaType = {}, clearAllText = {}, @@ -370,6 +368,7 @@ fun PreviewConverterScreenContent( updateInputText = {}, updateOutputText = {}, hideErrorCard = {}, + navigateScreen = {}, ) } } diff --git a/feature/converter/src/test/java/ksnd/hiraganaconverter/feature/converter/ConvertViewModelTest.kt b/feature/converter/src/test/java/ksnd/hiraganaconverter/feature/converter/ConvertViewModelTest.kt index 48777134..08bea2d1 100644 --- a/feature/converter/src/test/java/ksnd/hiraganaconverter/feature/converter/ConvertViewModelTest.kt +++ b/feature/converter/src/test/java/ksnd/hiraganaconverter/feature/converter/ConvertViewModelTest.kt @@ -14,7 +14,10 @@ import ksnd.hiraganaconverter.core.testing.MainDispatcherRule import ksnd.hiraganaconverter.core.ui.navigation.Nav import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class ConvertViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() diff --git a/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryDetailDialog.kt b/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryDetailDialog.kt index 6561d4c2..4cf9c80d 100644 --- a/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryDetailDialog.kt +++ b/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryDetailDialog.kt @@ -22,9 +22,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ClipboardManager @@ -68,12 +65,6 @@ private fun ConvertHistoryDetailDialogContent( val clipboardManager: ClipboardManager = LocalClipboardManager.current val scrollState = rememberScrollState() - val isScrolled by remember { - derivedStateOf { - scrollState.value > 10 - } - } - Scaffold( modifier = Modifier .fillMaxHeight(0.95f) @@ -82,7 +73,7 @@ private fun ConvertHistoryDetailDialogContent( .clip(RoundedCornerShape(16.dp)), topBar = { DialogTopBar( - isScrolled = isScrolled, + isScrolled = scrollState.canScrollBackward, leftContent = { Text( text = historyData.time, @@ -141,9 +132,9 @@ private fun BeforeOrAfterText( color = MaterialTheme.colorScheme.onSurface, ) CustomIconButton( - modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), - contentDescription = "copyText", painter = painterResource(id = R.drawable.ic_baseline_content_copy_24), + contentDescription = "", + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp, end = 16.dp), onClick = { clipboardManager.setText( AnnotatedString( diff --git a/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryScreen.kt b/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryScreen.kt index 6d653c7e..5f695bb3 100644 --- a/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryScreen.kt +++ b/feature/history/src/main/java/ksnd/hiraganaconverter/feature/history/ConvertHistoryScreen.kt @@ -1,41 +1,52 @@ package ksnd.hiraganaconverter.feature.history -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -46,7 +57,7 @@ import ksnd.hiraganaconverter.core.model.ConvertHistoryData import ksnd.hiraganaconverter.core.resource.R import ksnd.hiraganaconverter.core.ui.extension.noRippleClickable import ksnd.hiraganaconverter.core.ui.parts.BackTopBar -import ksnd.hiraganaconverter.core.ui.parts.button.DeleteButton +import ksnd.hiraganaconverter.core.ui.parts.button.CustomIconButton import ksnd.hiraganaconverter.core.ui.parts.card.ConvertHistoryCard import ksnd.hiraganaconverter.core.ui.preview.UiModePreview import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme @@ -81,7 +92,7 @@ fun ConvertHistoryScreen( } } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConvertHistoryScreenContent( state: ConvertHistoryUiState, @@ -94,73 +105,103 @@ private fun ConvertHistoryScreenContent( val layoutDirection = LocalLayoutDirection.current val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + var topBarHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current.density + val navigationHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + var isShowConfirmDeleteDialog by remember { mutableStateOf(false) } Scaffold( modifier = Modifier .nestedScroll(scrollBehavior.nestedScrollConnection) - .background(MaterialTheme.colorScheme.surface) - .displayCutoutPadding(), + .background(MaterialTheme.colorScheme.surface), topBar = { BackTopBar( + title = stringResource(id = R.string.title_history), scrollBehavior = scrollBehavior, - modifier = Modifier.noRippleClickable { - coroutineScope.launch { - lazyListState.animateScrollToItem(0) + modifier = Modifier + .noRippleClickable { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } } - }, + .onSizeChanged { topBarHeight = it.height }, onBackPressed = onBackPressed, ) { if (state.convertHistories.isNotEmpty()) { - Row { - Spacer(modifier = Modifier.weight(1f)) - DeleteButton( - modifier = Modifier.padding(end = 16.dp), - onClick = deleteAllConvertHistory, - ) - } + CustomIconButton( + painter = painterResource(id = R.drawable.ic_baseline_delete_outline_24), + contentDescription = "", + modifier = Modifier.padding(end = 16.dp), + contentColor = MaterialTheme.colorScheme.error, + onClick = { isShowConfirmDeleteDialog = true }, + ) } } }, - ) { padding -> + ) { innerPadding -> Column( modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding) .padding( - paddingValues = PaddingValues( - start = padding.calculateStartPadding(layoutDirection), - top = padding.calculateTopPadding(), - end = padding.calculateEndPadding(layoutDirection), - ), - ) - .fillMaxSize(), + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), + ), ) { if (state.convertHistories.isEmpty()) { EmptyHistoryImage() } else { LazyColumn( state = lazyListState, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + Spacer(modifier = Modifier.height((topBarHeight / density).toInt().dp)) + } items( items = state.convertHistories, key = { history -> history.id }, ) { history -> ConvertHistoryCard( - modifier = Modifier - .padding(horizontal = 16.dp) - .then( - // adding the condition because it behaves strangely when set while scrolling - if (lazyListState.isScrollInProgress) Modifier else Modifier.animateItemPlacement(), - ), + modifier = Modifier.padding(horizontal = 16.dp), beforeText = history.before, time = history.time, onClick = { showConvertHistoryDetailDialog(history) }, onDeleteClick = { deleteConvertHistory(history) }, ) } - item { Spacer(modifier = Modifier.height(48.dp)) } + item { Spacer(modifier = Modifier.height(48.dp + navigationHeight)) } } } } } + + if (isShowConfirmDeleteDialog) { + AlertDialog( + onDismissRequest = { isShowConfirmDeleteDialog = false }, + title = { Text(text = stringResource(id = R.string.delete_history_dialog_title)) }, + text = { Text(text = stringResource(id = R.string.delete_history_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + deleteAllConvertHistory() + isShowConfirmDeleteDialog = false + }, + ) { + Text(text = stringResource(id = R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { isShowConfirmDeleteDialog = false }) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + ) + } } @Composable @@ -172,7 +213,7 @@ private fun EmptyHistoryImage() { Column { Image( painter = painterResource(id = R.drawable.desert), - contentDescription = "no data", + contentDescription = "", modifier = Modifier .fillMaxWidth() .size(144.dp), diff --git a/feature/info/build.gradle.kts b/feature/info/build.gradle.kts index ae51381e..8ab9baf3 100644 --- a/feature/info/build.gradle.kts +++ b/feature/info/build.gradle.kts @@ -25,5 +25,5 @@ dependencies { implementation(project(":core:model")) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.accompanist.webView) - implementation(libs.play.oss.licenses) + implementation(libs.aboutLibraries) } diff --git a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/InfoScreen.kt b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/InfoScreen.kt index d7f0d2d0..ebfa13d7 100644 --- a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/InfoScreen.kt +++ b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/InfoScreen.kt @@ -1,21 +1,23 @@ package ksnd.hiraganaconverter.feature.info -import android.content.Intent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -33,6 +35,7 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -43,7 +46,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource @@ -52,8 +57,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import kotlinx.coroutines.launch import ksnd.hiraganaconverter.core.analytics.LocalAnalytics import ksnd.hiraganaconverter.core.analytics.Screen @@ -74,6 +77,7 @@ import ksnd.hiraganaconverter.core.ui.theme.urlColor fun InfoScreen( viewModel: InfoViewModel, onBackPressed: () -> Unit, + onClickLicense: () -> Unit, ) { val analytics = LocalAnalytics.current @@ -81,7 +85,11 @@ fun InfoScreen( analytics.logScreen(Screen.INFO) } - InfoScreenContent(versionName = viewModel.versionName, onBackPressed = onBackPressed) + InfoScreenContent( + versionName = viewModel.versionName, + onBackPressed = onBackPressed, + onClickLicense = onClickLicense, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -89,6 +97,7 @@ fun InfoScreen( private fun InfoScreenContent( versionName: String, onBackPressed: () -> Unit, + onClickLicense: () -> Unit, ) { val urlHandler = LocalUriHandler.current val context = LocalContext.current @@ -98,43 +107,51 @@ private fun InfoScreenContent( val scrollState = rememberScrollState() var isShowMovesToAppSiteDialog by remember { mutableStateOf(false) } var isShowMovesToApiSiteDialog by remember { mutableStateOf(false) } + var topBarHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current.density + val navigationHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() Scaffold( modifier = Modifier .nestedScroll(scrollBehavior.nestedScrollConnection) - .background(MaterialTheme.colorScheme.surface) - .displayCutoutPadding(), + .background(MaterialTheme.colorScheme.surface), topBar = { BackTopBar( + title = stringResource(id = R.string.title_info), scrollBehavior = scrollBehavior, - modifier = Modifier.noRippleClickable { - coroutineScope.launch { - scrollState.animateScrollTo(0) + modifier = Modifier + .noRippleClickable { + coroutineScope.launch { + scrollState.animateScrollTo(0) + } } - }, + .onSizeChanged { topBarHeight = it.height }, onBackPressed = onBackPressed, ) }, ) { innerPadding -> Column( modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .consumeWindowInsets(innerPadding) .padding( - paddingValues = PaddingValues( - start = innerPadding.calculateStartPadding(layoutDirection), - top = innerPadding.calculateTopPadding(), - end = innerPadding.calculateEndPadding(layoutDirection), - ), + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), ) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp) - .fillMaxSize(), + .padding(horizontal = 16.dp), ) { + Spacer(modifier = Modifier.height((topBarHeight / density).toInt().dp)) AppInfoContent(versionName = versionName, onURLClick = { isShowMovesToAppSiteDialog = true }) DeveloperInfoContent() APIInfoContent(onURLClick = { isShowMovesToApiSiteDialog = true }) - LicensesContent() + LicensesContent(onClickLicense = onClickLicense) PrivacyPolicyContent() - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(48.dp + navigationHeight)) } } @@ -250,17 +267,17 @@ private fun DeveloperInfoContent() { FlowRow { BodyMedium(text = stringResource(id = R.string.developer_name)) CustomIconButton( - contentDescription = "", painter = painterResource(id = R.drawable.ic_github_logo), - contentColor = null, + contentDescription = "", + contentColor = Color.Black, containerColor = Color.White, onClick = { uriHandler.openUri(uri = "https://github.com/kosenda") }, ) CustomIconButton( - modifier = Modifier.padding(start = 8.dp), - contentDescription = "", painter = painterResource(id = R.drawable.ic_x_logo), - contentColor = null, + contentDescription = "", + modifier = Modifier.padding(start = 8.dp), + contentColor = Color.White, containerColor = Color.Black, onClick = { uriHandler.openUri(uri = "https://twitter.com/ksnd_dev") }, ) @@ -316,20 +333,16 @@ private fun APIInfoContent(onURLClick: () -> Unit) { } @Composable -private fun LicensesContent() { - val context = LocalContext.current +private fun LicensesContent(onClickLicense: () -> Unit) { val buttonText = stringResource(id = R.string.oss_licenses) + TitleCard( text = stringResource(id = R.string.licenses_title), painter = painterResource(id = R.drawable.ic_outline_info_24), ) TransitionButton( text = buttonText, - onClick = { - val intent = Intent(context, OssLicensesMenuActivity::class.java) - intent.putExtra("title", buttonText) - ContextCompat.startActivity(context, intent, null) - }, + onClick = onClickLicense, ) } @@ -379,6 +392,7 @@ fun PreviewInfoScreenContent() { InfoScreenContent( versionName = "1.0.0", onBackPressed = {}, + onClickLicense = {}, ) } } diff --git a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/PrivacyPolicyContent.kt b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/PrivacyPolicyContent.kt index c72e8c8e..3877e141 100644 --- a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/PrivacyPolicyContent.kt +++ b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/PrivacyPolicyContent.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -63,7 +62,6 @@ fun PrivacyPolicyContent() { val webViewState = rememberWebViewState(url = stringResource(id = R.string.privacy_policy_url)) val navigator = rememberWebViewNavigator() val scrollState = rememberScrollState() - val isScrollTop by remember(scrollState.value) { derivedStateOf { scrollState.value == 0 } } val analytics = LocalAnalytics.current LaunchedEffect(isShowWebView) { @@ -102,7 +100,7 @@ fun PrivacyPolicyContent() { modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomEnd, ) { - this@ModalBottomSheet.AnimatedVisibility(visible = isScrollTop.not()) { + this@ModalBottomSheet.AnimatedVisibility(visible = scrollState.canScrollBackward) { ToTopFloatingButton( scrollState = scrollState, sizeChangedHeight = { floatingPlayerHeight = it }, diff --git a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/LicenseScreen.kt b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/LicenseScreen.kt new file mode 100644 index 00000000..1ef2c439 --- /dev/null +++ b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/LicenseScreen.kt @@ -0,0 +1,222 @@ +package ksnd.hiraganaconverter.feature.info.licence + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.ui.compose.m3.util.author +import com.mikepenz.aboutlibraries.ui.compose.m3.util.htmlReadyLicenseContent +import kotlinx.coroutines.launch +import ksnd.hiraganaconverter.core.resource.R +import ksnd.hiraganaconverter.core.ui.extension.noRippleClickable +import ksnd.hiraganaconverter.core.ui.parts.BackTopBar +import ksnd.hiraganaconverter.core.ui.preview.UiModePreview +import ksnd.hiraganaconverter.core.ui.rememberButtonScaleState +import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme +import ksnd.hiraganaconverter.feature.info.licence.mock.MockLibs + +@Composable +fun LicenseScreen( + viewModel: LicenseViewModel, + navigateLicenseDetail: (libraryName: String, licenseContent: String) -> Unit, + onBackPressed: () -> Unit, +) { + val libs by viewModel.libs.collectAsStateWithLifecycle() + + LicenseContent( + libs = libs, + navigateLicenseDetail = navigateLicenseDetail, + onBackPressed = onBackPressed, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LicenseContent( + libs: Libs?, + navigateLicenseDetail: (libraryName: String, licenseContent: String) -> Unit, + onBackPressed: () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + val navigationHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Scaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .background(MaterialTheme.colorScheme.surface) + .displayCutoutPadding(), + topBar = { + BackTopBar( + title = stringResource(id = R.string.licenses_title), + scrollBehavior = scrollBehavior, + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + }, + onBackPressed = onBackPressed, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.padding( + paddingValues = PaddingValues( + start = innerPadding.calculateStartPadding(LocalLayoutDirection.current), + top = innerPadding.calculateTopPadding(), + end = innerPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + state = lazyListState, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items( + items = libs?.libraries ?: emptyList(), + key = { it.uniqueId }, + ) { library -> + LibraryItem( + library = library, + navigateLicenseDetail = navigateLicenseDetail, + ) + } + item { Spacer(modifier = Modifier.height(48.dp + navigationHeight)) } + } + } +} + +@Composable +private fun LibraryItem( + library: Library, + navigateLicenseDetail: (libraryName: String, licenseContent: String) -> Unit, +) { + val urlHandler = LocalUriHandler.current + val buttonScaleState = rememberButtonScaleState() + val isNotExistLicenseContent = library.licenses.firstOrNull()?.htmlReadyLicenseContent.isNullOrBlank() + + Card( + modifier = Modifier + .scale(scale = buttonScaleState.animationScale.value) + .noRippleClickable( + interactionSource = buttonScaleState.interactionSource, + onClick = dropUnlessResumed { + if (isNotExistLicenseContent) { + library.licenses.first().url?.let { + urlHandler.openUri(it) + } + } else { + navigateLicenseDetail(library.name, library.licenses.first().htmlReadyLicenseContent!!) + } + }, + ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(all = 8.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = library.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + + if (library.author.isNotBlank()) { + Text( + text = library.author, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + library.artifactVersion?.let { version -> + Text( + text = "version: %s".format(version), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.secondary, + ) + } + + library.licenses.forEach { license -> + Badge( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = license.name, + modifier = Modifier.padding(horizontal = 4.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + + if (isNotExistLicenseContent) { + Image( + modifier = Modifier.size(28.dp), + painter = painterResource(id = R.drawable.baseline_open_in_new_24), + contentDescription = "open license", + colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.primary), + ) + } + } + } +} + +@UiModePreview +@Composable +fun PreviewLicenseContent() { + HiraganaConverterTheme { + LicenseContent( + libs = MockLibs.item, + navigateLicenseDetail = { _, _ -> }, + onBackPressed = {}, + ) + } +} diff --git a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/LicenseViewModel.kt b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/LicenseViewModel.kt new file mode 100644 index 00000000..7e06418b --- /dev/null +++ b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/LicenseViewModel.kt @@ -0,0 +1,34 @@ +package ksnd.hiraganaconverter.feature.info.licence + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.util.withContext +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import ksnd.hiraganaconverter.core.resource.di.IODispatcher +import javax.inject.Inject + +@HiltViewModel +class LicenseViewModel @Inject constructor( + @ApplicationContext private val context: Context, + @IODispatcher private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + private val _libs = MutableStateFlow(null) + val libs: StateFlow = _libs + + init { + getLicenses() + } + + private fun getLicenses() { + viewModelScope.launch(ioDispatcher) { + _libs.value = Libs.Builder().withContext(context).build() + } + } +} diff --git a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/licensedetail/LicenseDetailScreen.kt b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/licensedetail/LicenseDetailScreen.kt new file mode 100644 index 00000000..86adc0c9 --- /dev/null +++ b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/licensedetail/LicenseDetailScreen.kt @@ -0,0 +1,108 @@ +package ksnd.hiraganaconverter.feature.info.licence.licensedetail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.unit.dp +import com.mikepenz.aboutlibraries.ui.compose.m3.HtmlText +import kotlinx.coroutines.launch +import ksnd.hiraganaconverter.core.ui.extension.noRippleClickable +import ksnd.hiraganaconverter.core.ui.parts.BackTopBar +import ksnd.hiraganaconverter.core.ui.preview.UiModePreview +import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LicenseDetailScreen( + libraryName: String, + licenseContent: String, + onBackPressed: () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + val coroutineScope = rememberCoroutineScope() + val scrollState = rememberScrollState() + val navigationBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Scaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .background(MaterialTheme.colorScheme.surface) + .displayCutoutPadding(), + topBar = { + BackTopBar( + title = libraryName, + scrollBehavior = scrollBehavior, + modifier = Modifier.noRippleClickable { + coroutineScope.launch { + scrollState.animateScrollTo(0) + } + }, + onBackPressed = onBackPressed, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding( + paddingValues = PaddingValues( + start = innerPadding.calculateStartPadding(LocalLayoutDirection.current), + top = innerPadding.calculateTopPadding(), + end = innerPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + ) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp), + ) { + Text( + text = libraryName, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + + HtmlText( + html = licenseContent, + modifier = Modifier.padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondary, + ) + + Spacer(modifier = Modifier.height(48.dp + navigationBarHeight)) + } + } +} + +@UiModePreview +@Composable +fun PreviewLicenseDetailScreen() { + HiraganaConverterTheme { + LicenseDetailScreen( + libraryName = "SampleLibrary", + licenseContent = LoremIpsum().values.first(), + onBackPressed = {}, + ) + } +} diff --git a/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/mock/MockLibs.kt b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/mock/MockLibs.kt new file mode 100644 index 00000000..4917d8ef --- /dev/null +++ b/feature/info/src/main/java/ksnd/hiraganaconverter/feature/info/licence/mock/MockLibs.kt @@ -0,0 +1,42 @@ +package ksnd.hiraganaconverter.feature.info.licence.mock + +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.entity.Developer +import com.mikepenz.aboutlibraries.entity.Library +import com.mikepenz.aboutlibraries.entity.License +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf + +object MockLibs { + private val mockLicense = License( + name = "SampleLicense", + url = "https://example.com", + licenseContent = "SampleLicenseContent", + hash = "1", + ) + + private val mockLibrary = Library( + uniqueId = "1", + artifactVersion = "1.0.0", + name = "SampleLibrary", + description = "", + website = "", + developers = persistentListOf( + Developer("SampleDeveloper", "https://example.com"), + ), + licenses = persistentSetOf(mockLicense), + organization = null, + scm = null, + ) + + val item = Libs( + libraries = persistentListOf( + mockLibrary, + mockLibrary.copy(uniqueId = "2", licenses = persistentSetOf()), + mockLibrary.copy(uniqueId = "3", licenses = persistentSetOf(mockLicense, mockLicense.copy(hash = "2"))), + ), + licenses = persistentSetOf( + mockLicense, + ), + ) +} diff --git a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingScreen.kt b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingScreen.kt index ceb961a4..65d99de1 100644 --- a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingScreen.kt +++ b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingScreen.kt @@ -2,18 +2,19 @@ package ksnd.hiraganaconverter.feature.setting import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -23,14 +24,17 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -42,11 +46,12 @@ import ksnd.hiraganaconverter.core.model.ui.Theme import ksnd.hiraganaconverter.core.resource.R import ksnd.hiraganaconverter.core.ui.extension.noRippleClickable import ksnd.hiraganaconverter.core.ui.parts.BackTopBar -import ksnd.hiraganaconverter.core.ui.parts.button.CustomRadioButton -import ksnd.hiraganaconverter.core.ui.parts.button.TransitionButton -import ksnd.hiraganaconverter.core.ui.parts.card.TitleCard import ksnd.hiraganaconverter.core.ui.preview.UiModePreview import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme +import ksnd.hiraganaconverter.feature.setting.section.SettingFontSection +import ksnd.hiraganaconverter.feature.setting.section.SettingInAppUpdateSection +import ksnd.hiraganaconverter.feature.setting.section.SettingLanguageSection +import ksnd.hiraganaconverter.feature.setting.section.SettingThemeSection @Composable fun SettingScreen( @@ -83,6 +88,9 @@ private fun SettingScreenContent( val isShowSelectLanguageDialog = rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() + var topBarHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current.density + val navigationHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() if (isShowSelectLanguageDialog.value) { SelectLanguageDialog( @@ -91,137 +99,58 @@ private fun SettingScreenContent( } Scaffold( - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) - .background(MaterialTheme.colorScheme.surface) - .displayCutoutPadding(), + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .background(MaterialTheme.colorScheme.surface), topBar = { BackTopBar( + title = stringResource(id = R.string.title_settings), scrollBehavior = scrollBehavior, - modifier = Modifier.noRippleClickable { - coroutineScope.launch { - scrollState.animateScrollTo(0) + modifier = Modifier + .noRippleClickable { + coroutineScope.launch { + scrollState.animateScrollTo(0) + } } - }, + .onSizeChanged { topBarHeight = it.height }, onBackPressed = onBackPressed, ) }, ) { innerPadding -> Column( modifier = Modifier - .displayCutoutPadding() - .padding( - paddingValues = PaddingValues( - start = innerPadding.calculateStartPadding(layoutDirection), - top = innerPadding.calculateTopPadding(), - end = innerPadding.calculateEndPadding(layoutDirection), - ), - ) .fillMaxSize() .verticalScroll(scrollState) + .consumeWindowInsets(innerPadding) + .padding( + start = WindowInsets.displayCutout + .asPaddingValues() + .calculateStartPadding(layoutDirection), + end = WindowInsets.displayCutout + .asPaddingValues() + .calculateEndPadding(layoutDirection), + ) .padding(horizontal = 16.dp), ) { - SettingThemeContent( + Spacer(modifier = Modifier.height((topBarHeight / density).toInt().dp)) + SettingThemeSection( onRadioButtonClick = updateTheme, selectedTheme = uiState.theme, ) - SettingLanguageContent( + SettingLanguageSection( onClick = { isShowSelectLanguageDialog.value = true }, ) - SettingFontContent( + SettingFontSection( selectFontType = uiState.fontType, onClickFontType = { fontType -> updateFontType(fontType) }, ) - SettingInAppUpdateContent( + SettingInAppUpdateSection( enableInAppUpdate = uiState.enableInAppUpdate, onCheckedChange = updateUseInAppUpdate, ) - Spacer(modifier = Modifier.height(48.dp)) - } - } -} - -@Composable -private fun SettingThemeContent( - selectedTheme: Theme, - onRadioButtonClick: (Theme) -> Unit, -) { - val modeRadioResourceTriple: List> = listOf( - Triple( - Theme.NIGHT, - stringResource(id = R.string.dark_mode), - painterResource(id = R.drawable.ic_baseline_brightness_2_24), - ), - Triple( - Theme.DAY, - stringResource(id = R.string.light_mode), - painterResource(id = R.drawable.ic_baseline_brightness_low_24), - ), - Triple( - Theme.AUTO, - stringResource(id = R.string.auto_mode), - painterResource(id = R.drawable.ic_baseline_brightness_auto_24), - ), - ) - - TitleCard( - text = stringResource(id = R.string.theme_setting), - painter = painterResource(id = R.drawable.ic_baseline_brightness_4_24), - ) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - modifier = Modifier.padding(vertical = 8.dp), - ) { - modeRadioResourceTriple.map { resource -> - val (theme, displayThemeName, painter) = resource - CustomRadioButton( - isSelected = theme == selectedTheme, - buttonText = displayThemeName, - painter = painter, - onClick = { onRadioButtonClick(theme) }, - ) - } - } -} - -@Composable -private fun SettingLanguageContent(onClick: () -> Unit) { - TitleCard( - text = stringResource(id = R.string.language_setting), - painter = painterResource(id = R.drawable.ic_baseline_language_24), - ) - TransitionButton( - text = stringResource(id = R.string.select_language), - onClick = onClick, - ) -} - -@Composable -private fun SettingFontContent( - selectFontType: FontType, - onClickFontType: (FontType) -> Unit, -) { - TitleCard( - text = stringResource(id = R.string.font_setting), - painterResource(id = R.drawable.ic_baseline_text_fields_24), - ) - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - modifier = Modifier.padding(vertical = 8.dp), - ) { - Column(modifier = Modifier.padding(vertical = 8.dp)) { - FontType.entries.forEach { fontType -> - CustomRadioButton( - isSelected = fontType == selectFontType, - buttonText = fontType.fontName, - onClick = { onClickFontType(fontType) }, - ) - } + Spacer(modifier = Modifier.height(48.dp + navigationHeight)) } } } @@ -230,9 +159,7 @@ private fun SettingFontContent( @Composable fun PreviewSettingScreenContent() { HiraganaConverterTheme { - Surface( - modifier = Modifier.fillMaxSize(), - ) { + Surface { SettingScreenContent( uiState = SettingsUiState(), updateTheme = {}, diff --git a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingFontSection.kt b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingFontSection.kt new file mode 100644 index 00000000..863b00f9 --- /dev/null +++ b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingFontSection.kt @@ -0,0 +1,63 @@ +package ksnd.hiraganaconverter.feature.setting.section + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import ksnd.hiraganaconverter.core.model.ui.FontType +import ksnd.hiraganaconverter.core.resource.R +import ksnd.hiraganaconverter.core.ui.parts.button.CustomRadioButton +import ksnd.hiraganaconverter.core.ui.parts.card.TitleCard +import ksnd.hiraganaconverter.core.ui.preview.UiModePreview +import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme + +@Composable +fun SettingFontSection( + selectFontType: FontType, + onClickFontType: (FontType) -> Unit, +) { + TitleCard( + text = stringResource(id = R.string.font_setting), + painterResource(id = R.drawable.ic_baseline_text_fields_24), + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.padding(vertical = 8.dp), + ) { + FontType.entries.forEachIndexed { index, fontType -> + CustomRadioButton( + isSelected = fontType == selectFontType, + buttonText = fontType.fontName, + onClick = { onClickFontType(fontType) }, + ) + if (index != FontType.entries.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.surface, + ) + } + } + } +} + +@UiModePreview +@Composable +fun PreviewSettingFontSection() { + HiraganaConverterTheme { + Surface { + Column { + SettingFontSection(FontType.DEFAULT) { } + } + } + } +} diff --git a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingInAppUpdateContent.kt b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingInAppUpdateSection.kt similarity index 65% rename from feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingInAppUpdateContent.kt rename to feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingInAppUpdateSection.kt index 5f5f3d13..f92b7ac6 100644 --- a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/SettingInAppUpdateContent.kt +++ b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingInAppUpdateSection.kt @@ -1,11 +1,12 @@ -package ksnd.hiraganaconverter.feature.setting +package ksnd.hiraganaconverter.feature.setting.section +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,9 +17,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import ksnd.hiraganaconverter.core.resource.R import ksnd.hiraganaconverter.core.ui.parts.card.TitleCard +import ksnd.hiraganaconverter.core.ui.preview.UiModePreview +import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme @Composable -fun SettingInAppUpdateContent( +fun SettingInAppUpdateSection( enableInAppUpdate: Boolean, onCheckedChange: (Boolean) -> Unit, ) { @@ -33,12 +36,14 @@ fun SettingInAppUpdateContent( modifier = Modifier.padding(vertical = 8.dp), ) { Row( - modifier = Modifier.padding(all = 8.dp).fillMaxSize(), + modifier = Modifier.padding(all = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(id = R.string.in_app_update_show_update), - modifier = Modifier.padding(start = 16.dp).weight(1f), + modifier = Modifier + .padding(start = 16.dp) + .weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, ) @@ -46,3 +51,18 @@ fun SettingInAppUpdateContent( } } } + +@UiModePreview +@Composable +fun PreviewSettingInAppUpdateSection() { + HiraganaConverterTheme { + Surface { + Column { + SettingInAppUpdateSection( + enableInAppUpdate = true, + onCheckedChange = {}, + ) + } + } + } +} diff --git a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingLanguageSection.kt b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingLanguageSection.kt new file mode 100644 index 00000000..ca2a6504 --- /dev/null +++ b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingLanguageSection.kt @@ -0,0 +1,36 @@ +package ksnd.hiraganaconverter.feature.setting.section + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import ksnd.hiraganaconverter.core.resource.R +import ksnd.hiraganaconverter.core.ui.parts.button.TransitionButton +import ksnd.hiraganaconverter.core.ui.parts.card.TitleCard +import ksnd.hiraganaconverter.core.ui.preview.UiModePreview +import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme + +@Composable +fun SettingLanguageSection(onClick: () -> Unit) { + TitleCard( + text = stringResource(id = R.string.language_setting), + painter = painterResource(id = R.drawable.ic_baseline_language_24), + ) + TransitionButton( + text = stringResource(id = R.string.select_language), + onClick = onClick, + ) +} + +@UiModePreview +@Composable +fun PreviewSettingLanguageSection() { + HiraganaConverterTheme { + Surface { + Column { + SettingLanguageSection {} + } + } + } +} diff --git a/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingThemeSection.kt b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingThemeSection.kt new file mode 100644 index 00000000..e7a16765 --- /dev/null +++ b/feature/setting/src/main/java/ksnd/hiraganaconverter/feature/setting/section/SettingThemeSection.kt @@ -0,0 +1,84 @@ +package ksnd.hiraganaconverter.feature.setting.section + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import ksnd.hiraganaconverter.core.model.ui.FontType +import ksnd.hiraganaconverter.core.model.ui.Theme +import ksnd.hiraganaconverter.core.resource.R +import ksnd.hiraganaconverter.core.ui.parts.button.CustomRadioButton +import ksnd.hiraganaconverter.core.ui.parts.card.TitleCard +import ksnd.hiraganaconverter.core.ui.preview.UiModePreview +import ksnd.hiraganaconverter.core.ui.theme.HiraganaConverterTheme + +@Composable +fun SettingThemeSection( + selectedTheme: Theme, + onRadioButtonClick: (Theme) -> Unit, +) { + TitleCard( + text = stringResource(id = R.string.theme_setting), + painter = painterResource(id = R.drawable.ic_baseline_brightness_4_24), + ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier.padding(vertical = 8.dp), + ) { + listOf( + Triple( + Theme.NIGHT, + stringResource(id = R.string.dark_mode), + painterResource(id = R.drawable.ic_baseline_brightness_2_24), + ), + Triple( + Theme.DAY, + stringResource(id = R.string.light_mode), + painterResource(id = R.drawable.ic_baseline_brightness_low_24), + ), + Triple( + Theme.AUTO, + stringResource(id = R.string.auto_mode), + painterResource(id = R.drawable.ic_baseline_brightness_auto_24), + ), + ).forEachIndexed { index, (theme, displayThemeName, painter) -> + CustomRadioButton( + isSelected = theme == selectedTheme, + buttonText = displayThemeName, + painter = painter, + onClick = { onRadioButtonClick(theme) }, + ) + if (index != FontType.entries.size - 1) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.surface, + ) + } + } + } +} + +@UiModePreview +@Composable +fun PreviewSettingThemeSection() { + HiraganaConverterTheme { + Surface { + Column { + SettingThemeSection( + selectedTheme = Theme.NIGHT, + onRadioButtonClick = {}, + ) + } + } + } +} diff --git a/feature/setting/src/test/java/ksnd/hiraganaconverter/feature/setting/SettingsViewModelTest.kt b/feature/setting/src/test/java/ksnd/hiraganaconverter/feature/setting/SettingsViewModelTest.kt index 41cf0ccd..6962858b 100644 --- a/feature/setting/src/test/java/ksnd/hiraganaconverter/feature/setting/SettingsViewModelTest.kt +++ b/feature/setting/src/test/java/ksnd/hiraganaconverter/feature/setting/SettingsViewModelTest.kt @@ -1,6 +1,7 @@ package ksnd.hiraganaconverter.feature.setting import io.mockk.coVerify +import io.mockk.coVerifyCount import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flowOf @@ -34,9 +35,11 @@ class SettingsViewModelTest { @Test fun uiState_initial_callRepositoryMethods() = runTest { - coVerify(exactly = 1) { dataStoreRepository.theme() } - coVerify(exactly = 1) { dataStoreRepository.fontType() } - coVerify(exactly = 1) { dataStoreRepository.enableInAppUpdate() } + coVerifyCount { + 1 * { dataStoreRepository.theme() } + 1 * { dataStoreRepository.fontType() } + 1 * { dataStoreRepository.enableInAppUpdate() } + } } @Test @@ -50,7 +53,7 @@ class SettingsViewModelTest { fun updateFontType_newFontType_isCalledUpdateFontType() = runTest { viewModel.updateFontType(NEW_FONT_TYPE) - coVerify(exactly = 1) { dataStoreRepository.updateFontType(fontType = NEW_FONT_TYPE) } + coVerify { dataStoreRepository.updateFontType(fontType = NEW_FONT_TYPE) } } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a0a8c342..0ffc3859 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,53 +1,51 @@ [versions] accompanist = "0.34.0" activity = "1.9.0" -androidGradlePlugin = "8.4.0" -androidxAppCompat = "1.7.0-beta01" +androidGradlePlugin = "8.4.1" +androidxAppCompat = "1.7.0" androidxCompose = "1.6.7" androidxComposeMaterial3 = "1.2.1" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.1.1" androidxHilt = "1.2.0" -androidxLifecycle = "2.7.0" -androidxNavigation = "2.8.0-alpha08" +androidxLifecycle = "2.8.1" +androidxNavigation = "2.8.0-beta01" androidxTestCore = "1.5.0" appUpdate = "2.1.0" coil = "2.6.0" -coroutines = "1.8.0" -coroutineTest = "1.8.0" +coroutines = "1.8.1" +coroutineTest = "1.8.1" dokka = "1.9.20" -gmsPlugin = "4.4.1" -firebaseBom = "32.8.1" -firebaseCrashlyticsPlugin = "2.9.9" +gmsPlugin = "4.4.2" +firebaseBom = "33.1.0" +firebaseCrashlyticsPlugin = "3.0.1" firebasePerfPlugin = "1.4.2" -firebaseAppdistributionPlugin = "4.2.0" +firebaseAppdistributionPlugin = "5.0.0" hilt = "2.51.1" junit4 = "4.13.2" -kotlin = "1.9.23" +kotlin = "2.0.0" kotlinxSerializationJson = "1.6.3" -kotlinxDatetime = "0.5.0" -ksp = "1.9.23-1.0.20" +kotlinxDatetime = "0.6.0" +ksp = "2.0.0-1.0.21" ktlint = "1.2.1" lazyColumnScrollbar = "1.9.0" -lottie = "6.4.0" +lottie = "6.4.1" okhttp3 = "4.12.0" -ossLicenses = "17.0.1" -ossLicensesPlugin = "0.10.6" playReview = "2.0.1" retrofit = "2.11.0" retrofitSerialization = "1.0.0" -robolectric = "4.12.1" +robolectric = "4.12.2" room = "2.6.1" secrets = "2.0.1" timber = "5.0.1" truth = "1.4.2" turbine = "1.1.0" -mockk = "1.13.10" -roborazzi = "1.13.0" +mockk = "1.13.11" +roborazzi = "1.20.0" showkase = "1.0.2" +aboutLibraries = "11.2.1" # ** I'm using it, so no deletions allowed. ** -androidxComposeCompiler = "1.5.11" jacoco = "0.8.10" [libraries] @@ -88,9 +86,6 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # Lottie lottie = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } -# Play Services -play-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" } - # Ktlint ktlint = { group = "com.pinterest.ktlint", name = "ktlint-cli", version.ref = "ktlint" } @@ -158,13 +153,16 @@ showkase = { group = "com.airbnb.android", name = "showkase", version.ref = "sho showkase-annotation = { group = "com.airbnb.android", name = "showkase-annotation", version.ref = "showkase" } showkase-processor = { group = "com.airbnb.android", name = "showkase-processor", version.ref = "showkase" } +# AboutLibraries +aboutLibraries = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -oss-licenses = { id = "com.google.android.gms.oss-licenses-plugin", version.ref = "ossLicensesPlugin" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } @@ -173,3 +171,4 @@ firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = " firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppdistributionPlugin" } firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" } androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugin" } +aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23a..a4413138 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a42..b740cf13 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/.