From d086df7287028da7b8fd8234222f51c387a09210 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Sun, 26 May 2024 09:23:35 +0700 Subject: [PATCH] feat: Onboarding screen (partial) --- app/build.gradle.kts | 8 +- .../dev/yokai/domain/base/BasePreferences.kt | 3 + .../yokai/presentation/component/AppIcon.kt | 65 ++++ .../yokai/presentation/component/ThemeItem.kt | 332 ++++++++++++++++++ .../core/util/ModifierExtensions.kt | 7 + .../presentation/onboarding/InfoScreen.kt | 184 ++++++++++ .../onboarding/OnboardingController.kt | 37 ++ .../onboarding/OnboardingScreen.kt | 90 +++++ .../onboarding/steps/OnboardingStep.kt | 11 + .../onboarding/steps/ThemeStep.kt | 212 +++++++++++ .../dev/yokai/presentation/theme/Constants.kt | 21 ++ .../tachiyomi/core/preference/Preference.kt | 10 + .../kanade/tachiyomi/ui/main/MainActivity.kt | 4 + app/src/main/res/values/strings.xml | 4 + gradle.properties | 3 +- gradle/compose.versions.toml | 4 + gradle/libs.versions.toml | 2 +- 17 files changed, 987 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/dev/yokai/presentation/component/AppIcon.kt create mode 100644 app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt create mode 100644 app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt create mode 100644 app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt create mode 100644 app/src/main/java/dev/yokai/presentation/theme/Constants.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1c1b384b12..12ac014761 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,15 +147,9 @@ android { dependencies { // Compose implementation(androidx.activity.compose) - implementation(compose.foundation) - implementation(compose.animation) - implementation(compose.ui) + implementation(compose.bundles.compose) debugImplementation(compose.ui.tooling) - implementation(compose.ui.tooling.preview) - implementation(compose.material) - implementation(compose.material3) implementation(libs.compose.theme.adapter3) - implementation(compose.icons) implementation(libs.accompanist.webview) implementation(androidx.glance.appwidget) diff --git a/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt b/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt index 6e734222f9..1ed4e892c4 100644 --- a/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt +++ b/app/src/main/java/dev/yokai/domain/base/BasePreferences.kt @@ -2,6 +2,7 @@ package dev.yokai.domain.base import androidx.annotation.StringRes import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.Preference import eu.kanade.tachiyomi.core.preference.PreferenceStore import eu.kanade.tachiyomi.core.preference.getEnum import eu.kanade.tachiyomi.extension.util.ExtensionInstaller @@ -17,4 +18,6 @@ class BasePreferences(private val preferenceStore: PreferenceStore) { } fun displayProfile() = preferenceStore.getString("pref_display_profile_key", "") + + fun hasShownOnboarding() = preferenceStore.getBoolean(Preference.appStateKey("onboarding_complete"), false) } diff --git a/app/src/main/java/dev/yokai/presentation/component/AppIcon.kt b/app/src/main/java/dev/yokai/presentation/component/AppIcon.kt new file mode 100644 index 0000000000..33e7c275b0 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/AppIcon.kt @@ -0,0 +1,65 @@ +package dev.yokai.presentation.component + +import android.graphics.Bitmap +import android.graphics.drawable.AdaptiveIconDrawable +import android.os.Build +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import eu.kanade.tachiyomi.R + +@Composable +fun AppIcon(size: Dp, modifier: Modifier = Modifier) { + ResourcesCompat.getDrawable( + LocalContext.current.resources, + R.mipmap.ic_launcher, + LocalContext.current.theme + ) + ?.let { drawable -> + val bitmap = + Bitmap.createBitmap( + drawable.intrinsicWidth, + drawable.intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + modifier = modifier.requiredSize(size) + ) + } +} + +@Composable +fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter { + val res = LocalContext.current.resources + val theme = LocalContext.current.theme + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android O supports adaptive icons, try loading this first (even though this is least + // likely to be the format). + val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable + if (adaptiveIcon != null) { + BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap()) + } else { + // We couldn't load the drawable as an Adaptive Icon, just use painterResource + painterResource(id) + } + } else { + // We're not on Android O or later, just use painterResource + painterResource(id) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt b/app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt new file mode 100644 index 0000000000..5821ddecf4 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/component/ThemeItem.kt @@ -0,0 +1,332 @@ +package dev.yokai.presentation.component + +import android.content.Context +import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.google.accompanist.themeadapter.material3.createMdc3Theme +import dev.yokai.presentation.theme.HalfAlpha +import dev.yokai.presentation.theme.SecondaryItemAlpha +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.Themes +import eu.kanade.tachiyomi.util.system.isInNightMode + +private data class ContextTheme( + val colorScheme: ColorScheme, + val isThemeMatchesApp: Boolean, + val theme: Themes, + val isDarkTheme: Boolean, +) + +private fun Context.colorSchemeFromAdapter(theme: Themes, isDarkTheme: Boolean): ContextTheme { + val configuration = Configuration(this.resources.configuration) + configuration.uiMode = + if (isDarkTheme) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + val themeContext = this.createConfigurationContext(configuration) + themeContext.setTheme(theme.styleRes) + + @Suppress("DEPRECATION") val colorScheme = + createMdc3Theme( + context = themeContext, + layoutDirection = LayoutDirection.Ltr, + setTextColors = true, + readTypography = false, + ) + .colorScheme!! + + val themeMatchesApp = + if (this.isInNightMode()) { + isDarkTheme + } else { + !isDarkTheme + } + + return ContextTheme(colorScheme, themeMatchesApp, theme, isDarkTheme) +} + +@Composable +fun ThemeItem(theme: Themes, isDarkTheme: Boolean, selected: Boolean, onClick: () -> Unit) { + val context = LocalContext.current + val contextTheme = context.colorSchemeFromAdapter(theme, isDarkTheme) + + ThemeItemNaive(contextTheme = contextTheme, selected = selected, onClick = onClick) +} + +@Composable +private fun ThemeItemNaive(contextTheme: ContextTheme, selected: Boolean, onClick: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(110.dp)) { + ThemePreviewItem( + contextTheme.colorScheme, + selected, + selectedColor = MaterialTheme.colorScheme.primary, + contextTheme.isThemeMatchesApp, + onClick + ) + + Text( + text = stringResource(id = if (contextTheme.isDarkTheme) contextTheme.theme.darkNameRes else contextTheme.theme.nameRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +fun ThemePreviewItem( + colorScheme: ColorScheme, + selected: Boolean, + selectedColor: Color, + themeMatchesApp: Boolean, + onClick: () -> Unit, +) { + val actualSelectedColor = + when { + themeMatchesApp && selected -> colorScheme.primary + selected -> selectedColor.copy(alpha = HalfAlpha) + else -> Color.Transparent + } + + val padding = 6 + val outer = 26 + val inner = outer - padding + OutlinedCard( + onClick = onClick, + modifier = Modifier + .height(180.dp) + .fillMaxWidth(), + shape = RoundedCornerShape(outer.dp), + border = BorderStroke(width = Size.tiny, color = actualSelectedColor), + ) { + OutlinedCard( + modifier = Modifier + .height(180.dp) + .fillMaxWidth() + .padding(6.dp), + shape = RoundedCornerShape(inner.dp), + colors = CardDefaults.outlinedCardColors(containerColor = colorScheme.background), + border = BorderStroke(width = 1.dp, color = colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .padding(Size.small), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .height(15.dp) + .weight(0.7f) + .padding(start = Size.tiny, end = Size.small) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + Box( + modifier = Modifier.weight(0.3f), + contentAlignment = Alignment.CenterEnd, + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = stringResource(R.string.selected), + tint = selectedColor, + ) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .weight(0.6f) + .padding(horizontal = Size.small), + ) { + Box( + modifier = Modifier + .height(30.dp) + .fillMaxWidth() + .background( + color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha), + shape = RoundedCornerShape(8.dp), + ), + ) + Row( + modifier = Modifier + .height(30.dp) + .fillMaxWidth() + .padding(top = Size.small, end = Size.small), + ) { + Box( + modifier = Modifier + .height(15.dp) + .weight(0.8f) + .padding(end = Size.tiny) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + + Box( + modifier = Modifier + .height(15.dp) + .weight(0.3f) + .background( + color = colorScheme.secondary, + shape = RoundedCornerShape(6.dp), + ), + ) + } + Row( + modifier = Modifier + .height(15.dp) + .fillMaxWidth() + .padding(end = Size.medium), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.5f) + .padding(end = Size.tiny) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.6f) + .background( + color = colorScheme.onSurface, + shape = RoundedCornerShape(6.dp), + ), + ) + } + } + Surface( + color = colorScheme.surfaceVariant, + tonalElevation = Size.small, + ) { + Row( + modifier = Modifier + .height(30.dp) + .fillMaxWidth() + .padding(vertical = Size.extraTiny, horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .background( + color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha), + shape = CircleShape, + ), + ) + } + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .background( + color = colorScheme.secondary, + shape = CircleShape, + ), + ) + } + Box( + modifier = Modifier + .fillMaxHeight() + .weight(0.2f), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(6.dp) + .background( + color = colorScheme.onSurface.copy(alpha = SecondaryItemAlpha), + shape = CircleShape, + ), + ) + } + } + } + } + } +} + +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +fun ThemeItemPreviewDark() { + val contextTheme = ContextTheme( + colorScheme = darkColorScheme(), + isThemeMatchesApp = true, + theme = Themes.DEFAULT, + isDarkTheme = true, + ) + Surface { + ThemeItemNaive(contextTheme = contextTheme, selected = true) {} + } +} + +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Composable +fun ThemeItemPreviewLight() { + val contextTheme = ContextTheme( + colorScheme = lightColorScheme(), + isThemeMatchesApp = true, + theme = Themes.DEFAULT, + isDarkTheme = false, + ) + Surface { + ThemeItemNaive(contextTheme = contextTheme, selected = false) {} + } +} diff --git a/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt new file mode 100644 index 0000000000..7516806c28 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/core/util/ModifierExtensions.kt @@ -0,0 +1,7 @@ +package dev.yokai.presentation.core.util + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import dev.yokai.presentation.theme.SecondaryItemAlpha + +fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha) diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt b/app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt new file mode 100644 index 0000000000..e887afaf6e --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/InfoScreen.kt @@ -0,0 +1,184 @@ +package dev.yokai.presentation.onboarding + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RocketLaunch +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.zIndex +import dev.yokai.presentation.core.util.secondaryItemAlpha +import dev.yokai.presentation.theme.Size + +@Composable +fun InfoScreen( + icon: ImageVector, + headingText: String, + subtitleText: String, + tint: Color = MaterialTheme.colorScheme.primary, + acceptText: String, + onAcceptClick: () -> Unit, + canAccept: Boolean, + rejectText: String? = null, + onRejectClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + InfoScreen( + icon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(bottom = Size.small) + .size(Size.huge), + tint = tint, + ) + }, + headingText = headingText, + subtitleText = subtitleText, + acceptText = acceptText, + onAcceptClick = onAcceptClick, + canAccept = canAccept, + rejectText = rejectText, + onRejectClick = onRejectClick, + content = content, + ) +} + +@Composable +fun InfoScreen( + icon: @Composable () -> Unit, + headingText: String, + subtitleText: String, + tint: Color = MaterialTheme.colorScheme.primary, + acceptText: String, + onAcceptClick: () -> Unit, + canAccept: Boolean, + rejectText: String? = null, + onRejectClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + bottomBar = { + val strokeWidth = Dp.Hairline + val borderColor = MaterialTheme.colorScheme.outline + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.background) + .drawBehind { + drawLine( + borderColor, + Offset(0f, 0f), + Offset(size.width, 0f), + strokeWidth.value, + ) + } + .windowInsetsPadding(NavigationBarDefaults.windowInsets) + .padding( + horizontal = Size.medium, + vertical = Size.small, + ), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + enabled = canAccept, + colors = ButtonDefaults.buttonColors(containerColor = tint), + onClick = onAcceptClick, + ) { + Text(text = acceptText) + } + if (rejectText != null && onRejectClick != null) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + border = + BorderStroke( + width = Size.extraExtraTiny, + color = tint, + ), + onClick = onRejectClick, + ) { + Text(text = rejectText) + } + } + } + }, + ) { paddingValues -> + // Status bar scrim + Box( + modifier = Modifier + .zIndex(2f) + .secondaryItemAlpha() + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth() + .height(paddingValues.calculateTopPadding()), + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(paddingValues) + .padding(top = Size.huge) + .padding(horizontal = Size.medium), + ) { + icon() + + Text( + text = headingText, + style = MaterialTheme.typography.headlineLarge, + ) + Text( + text = subtitleText, + modifier = Modifier + .secondaryItemAlpha() + .padding(vertical = Size.small), + style = MaterialTheme.typography.titleSmall, + ) + + content() + } + } +} + +@Preview +@Composable +private fun InfoScreenPreview() { + InfoScreen( + icon = Icons.Outlined.RocketLaunch, + headingText = "Welcome!", + subtitleText = "Subtitle", + acceptText = "Accept", + onAcceptClick = {}, + canAccept = true, + rejectText = "Reject", + onRejectClick = {}, + ) { + Text(text = "Hello World") + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt new file mode 100644 index 0000000000..90284afc28 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingController.kt @@ -0,0 +1,37 @@ +package dev.yokai.presentation.onboarding + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import dev.yokai.domain.base.BasePreferences +import eu.kanade.tachiyomi.core.preference.collectAsState +import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController +import uy.kohesive.injekt.injectLazy + +class OnboardingController : + BaseComposeController() { + + val basePreferences by injectLazy() + + @Composable + override fun ScreenContent() { + + val hasShownOnboarding by basePreferences.hasShownOnboarding().collectAsState() + + val finishOnboarding: () -> Unit = { + basePreferences.hasShownOnboarding().set(true) + router.popCurrentController() + } + + BackHandler( + enabled = !hasShownOnboarding, + onBack = { + // Prevent exiting if onboarding hasn't been completed + }, + ) + + OnboardingScreen( + onComplete = finishOnboarding + ) + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000000..8a4a1a25d5 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/OnboardingScreen.kt @@ -0,0 +1,90 @@ +package dev.yokai.presentation.onboarding + +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import dev.yokai.presentation.onboarding.steps.ThemeStep +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import soup.compose.material.motion.animation.materialSharedAxisX +import soup.compose.material.motion.animation.rememberSlideDistance + +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Composable +fun OnboardingScreen( + onComplete: () -> Unit = {} +) { + val slideDistance = rememberSlideDistance() + + var currentStep by rememberSaveable { mutableIntStateOf(0) } + val steps = remember { + listOf( + ThemeStep(), + ThemeStep(), + ThemeStep(), + ) + } + val isLastStep = currentStep == steps.lastIndex + + BackHandler(enabled = currentStep != 0, onBack = { currentStep-- }) + + InfoScreen( + icon = Icons.Outlined.RocketLaunch, + headingText = stringResource(R.string.onboarding_heading), + subtitleText = stringResource(R.string.onboarding_description), + tint = MaterialTheme.colorScheme.primary, + acceptText = stringResource( + if (isLastStep) + R.string.onboarding_finish + else { + R.string.next + } + ), + canAccept = steps[currentStep].isComplete, + onAcceptClick = { + if (isLastStep) { + onComplete() + } else { + currentStep++ + } + }, + ) { + Box( + modifier = Modifier + .padding(vertical = Size.small) + .clip(MaterialTheme.shapes.small) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + AnimatedContent( + targetState = currentStep, + transitionSpec = { + materialSharedAxisX( + forward = targetState > initialState, + slideDistance = slideDistance, + ) + }, + label = "stepContent", + ) { + steps[currentStep].Content() + } + } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt new file mode 100644 index 0000000000..0ead72e670 --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/OnboardingStep.kt @@ -0,0 +1,11 @@ +package dev.yokai.presentation.onboarding.steps + +import androidx.compose.runtime.Composable + +internal interface OnboardingStep { + + val isComplete: Boolean + + @Composable + fun Content() +} diff --git a/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt new file mode 100644 index 0000000000..26f06d739e --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/onboarding/steps/ThemeStep.kt @@ -0,0 +1,212 @@ +package dev.yokai.presentation.onboarding.steps + +import android.app.Activity +import android.content.Context +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import com.google.android.material.color.DynamicColors +import dev.yokai.presentation.component.ThemeItem +import dev.yokai.presentation.theme.Size +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.preference.collectAsState +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.system.Themes +import eu.kanade.tachiyomi.util.system.appDelegateNightMode +import uy.kohesive.injekt.injectLazy + +class ThemeStep : OnboardingStep { + override val isComplete: Boolean = true + + private val preferences: PreferencesHelper by injectLazy() + + @Composable + override fun Content() { + val context = LocalContext.current + + val nightModePreference = preferences.nightMode() + + val nightMode by nightModePreference.collectAsState() + + val followingSystemTheme by remember(nightMode) { + derivedStateOf { nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } + } + + val darkAppTheme by preferences.darkTheme().collectAsState() + val lightAppTheme by preferences.lightTheme().collectAsState() + val supportsDynamic = DynamicColors.isDynamicColorAvailable() + + Themes.entries + .filter { + (!it.isDarkTheme || it.followsSystem) && + (it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic) + } + .toSet() + + val lightThemes by remember { + derivedStateOf { + Themes.entries + .filter { + (!it.isDarkTheme || it.followsSystem) && + (it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic) + } + .toSet() + } + } + + val darkThemes by remember { + derivedStateOf { + Themes.entries + .filter { + (it.isDarkTheme || it.followsSystem) && + (it.styleRes != R.style.Theme_Tachiyomi_Monet || supportsDynamic) + } + .toSet() + } + } + + Column(modifier = Modifier.padding(Size.medium)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(id = R.string.follow_system_theme)) + Switch( + checked = nightMode == AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + colors = + SwitchDefaults.colors( + checkedTrackColor = MaterialTheme.colorScheme.primary + ), + onCheckedChange = { + when (it) { + true -> { + preferences + .nightMode() + .set(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + (context as? Activity)?.let { activity -> + ActivityCompat.recreate(activity) + } + } + false -> preferences.nightMode().set(context.appDelegateNightMode()) + } + } + ) + } + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Size.medium) + ) { + lightThemes.forEach { theme -> + val isSelected = + remember(darkAppTheme, lightAppTheme, nightMode) { + isSelected(theme, false, darkAppTheme, lightAppTheme, nightMode) + } + ThemeItem( + theme = theme, + isDarkTheme = false, + selected = isSelected, + onClick = { + themeClicked( + theme, + context, + isSelected = isSelected, + followingSystemTheme = followingSystemTheme, + isDarkTheme = false + ) + } + ) + } + } + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Size.medium) + ) { + darkThemes.forEach { theme -> + val isSelected = + remember(darkAppTheme, lightAppTheme, nightMode) { + isSelected(theme, true, darkAppTheme, lightAppTheme, nightMode) + } + ThemeItem( + theme = theme, + isDarkTheme = true, + selected = isSelected, + onClick = { + themeClicked( + theme, + context, + isSelected = isSelected, + followingSystemTheme = followingSystemTheme, + isDarkTheme = true + ) + } + ) + } + } + } + } + + private fun isSelected( + theme: Themes, + isDarkTheme: Boolean, + darkAppTheme: Themes, + lightAppTheme: Themes, + nightMode: Int + ): Boolean { + return when (nightMode) { + AppCompatDelegate.MODE_NIGHT_YES -> darkAppTheme == theme && isDarkTheme + AppCompatDelegate.MODE_NIGHT_NO -> lightAppTheme == theme && !isDarkTheme + else -> + (darkAppTheme == theme && isDarkTheme) || (lightAppTheme == theme && !isDarkTheme) + } + } + + private fun themeClicked( + theme: Themes, + context: Context, + isSelected: Boolean, + followingSystemTheme: Boolean, + isDarkTheme: Boolean + ) { + val nightMode = + when (isDarkTheme) { + true -> { + preferences.darkTheme().set(theme) + AppCompatDelegate.MODE_NIGHT_YES + } + false -> { + preferences.lightTheme().set(theme) + AppCompatDelegate.MODE_NIGHT_NO + } + } + + if (followingSystemTheme && isSelected) { + preferences.nightMode().set(nightMode) + } else if (!followingSystemTheme) { + preferences.nightMode().set(nightMode) + } + + (context as? Activity)?.let { activity -> ActivityCompat.recreate(activity) } + } +} diff --git a/app/src/main/java/dev/yokai/presentation/theme/Constants.kt b/app/src/main/java/dev/yokai/presentation/theme/Constants.kt new file mode 100644 index 0000000000..f49ac886dd --- /dev/null +++ b/app/src/main/java/dev/yokai/presentation/theme/Constants.kt @@ -0,0 +1,21 @@ +package dev.yokai.presentation.theme + +import androidx.compose.ui.unit.dp + +const val SecondaryItemAlpha = .78f +const val HalfAlpha = .5f + +object Size { + val none = 0.dp + val extraExtraTiny = 1.dp + val extraTiny = 2.dp + val tiny = 4.dp + val small = 8.dp + val smedium = 12.dp + val medium = 16.dp + val large = 24.dp + val extraLarge = 32.dp + val huge = 48.dp + val extraHuge = 56.dp + val navBarSize = 68.dp +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt b/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt index 2950d0db48..6addefaa11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/core/preference/Preference.kt @@ -1,5 +1,9 @@ package eu.kanade.tachiyomi.core.preference +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -73,3 +77,9 @@ fun Preference.toggle(): Boolean { set(!get()) return get() } + +@Composable +fun Preference.collectAsState(): State { + val flow = remember(this) { changes() } + return flow.collectAsState(initial = get()) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 1b718dc092..833fb0e49d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -76,6 +76,7 @@ import com.google.common.primitives.Ints.max import dev.yokai.domain.base.BasePreferences import dev.yokai.domain.ui.settings.ReaderPreferences import dev.yokai.presentation.extension.repo.ExtensionRepoController +import dev.yokai.presentation.onboarding.OnboardingController import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.Migrations import eu.kanade.tachiyomi.R @@ -519,6 +520,9 @@ open class MainActivity : BaseActivity() { // Set start screen if (!handleIntentAction(intent)) { goToStartingTab() + if (!basePreferences.hasShownOnboarding().get()) { + router.pushController(OnboardingController().withFadeInTransaction()) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec21a3405c..5c84c59472 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,10 @@ TachiyomiJ2K requires access to all files in Android 11 to download chapters, create automatic backups, and read local series. \n\nOn the next screen, enable \"Allow access to manage all files.\" TachiyomiJ2K requires access to all files to download chapters. Tap here, then enable \"Allow access to manage all files.\" + Welcome! + Let\'s pick some defaults. You can always change these things later in the settings. + Get started + diff --git a/gradle.properties b/gradle.properties index 146a70167a..be89a213a4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,6 +22,5 @@ android.enableJetifier=true android.useAndroidX=true org.gradle.jvmargs=-Xmx2048M org.gradle.caching=true -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false diff --git a/gradle/compose.versions.toml b/gradle/compose.versions.toml index 04dbcd4210..891b20f114 100644 --- a/gradle/compose.versions.toml +++ b/gradle/compose.versions.toml @@ -8,8 +8,12 @@ animation = { module = "androidx.compose.animation:animation", version.ref = "co foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } material = { module = "androidx.compose.material:material", version.ref = "compose" } material3 = { module = "androidx.compose.material3:material3", version = "1.2.1" } +material-motion = { module = "io.github.fornewid:material-motion-compose-core", version = "1.0.7" } lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } + +[bundles] +compose = [ "animation", "foundation", "material", "material3", "material-motion", "ui", "ui-tooling-preview", "icons" ] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2de17c3d9a..a7febdfd7b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ flexbox = { module = "com.google.android.flexbox:flexbox", version = "3.0.0" } flexible-adapter-ui = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter-ui", version.ref = "flexible-adapter" } flexible-adapter = { module = "com.github.arkon.FlexibleAdapter:flexible-adapter", version.ref = "flexible-adapter" } google-services = { module = "com.google.gms:google-services", version = "4.4.1" } -gradle = { module = "com.android.tools.build:gradle", version = "8.1.4" } +gradle = { module = "com.android.tools.build:gradle", version = "8.2.0" } guava = { module = "com.google.guava:guava", version = "31.1-android" } image-decoder = { module = "com.github.tachiyomiorg:image-decoder", version = "e08e9be535" } injekt-core = { module = "com.github.inorichi.injekt:injekt-core", version = "65b0440" }