diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49249e37..f1b5f39b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,6 +86,14 @@ android { jvmTarget = "17" } + lint { + // lifecycle-runtime-lint's NullSafeMutableLiveData detector was built when + // KaCallableMemberCall was an interface. Kotlin 2.2 makes it a class, so the + // detector crashes lintVitalAnalyzeRelease. Newer lifecycle builds that fix + // this require compileSdk 37 and AGP 9. + disable += "NullSafeMutableLiveData" + } + packaging { jniLibs { useLegacyPackaging = true @@ -95,7 +103,7 @@ android { dependencies { // Compose BOM - manages all Compose library versions - val composeBom = platform("androidx.compose:compose-bom:2024.09.00") + val composeBom = platform("androidx.compose:compose-bom:2025.11.00") implementation(composeBom) // Compose UI @@ -122,6 +130,10 @@ dependencies { // AppCompat (DayNight theme support) implementation("androidx.appcompat:appcompat:1.7.0") + // Liquid glass surfaces. 1.0.0 is the newest Android artifact this toolchain can compile. + // https://github.com/Kyant0/AndroidLiquidGlass + implementation("io.github.kyant0:backdrop:1.0.0") + // Kotlin Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") diff --git a/app/src/main/cpp/fusion/include/fusion.h b/app/src/main/cpp/fusion/include/fusion.h index 9a84e5a3..469a004f 100644 --- a/app/src/main/cpp/fusion/include/fusion.h +++ b/app/src/main/cpp/fusion/include/fusion.h @@ -18,6 +18,7 @@ struct FusionConfig { std::string unityDataDir; std::string unityVersion; bool useOriginalLibUnity = false; + bool disableTieredCompilation = false; }; extern FusionConfig g_config; diff --git a/app/src/main/cpp/fusion/src/dotnet.cpp b/app/src/main/cpp/fusion/src/dotnet.cpp index be1d76ca..ef3224ec 100644 --- a/app/src/main/cpp/fusion/src/dotnet.cpp +++ b/app/src/main/cpp/fusion/src/dotnet.cpp @@ -161,14 +161,17 @@ bool dotnet_start_runtime() setenv("DOTNET_ReadyToRun", "0", 1); - /* CoreCLR JIT aborts (SIGABRT inside libclrjit, abort msg "terminating") - when compiling the huge methods generated by large modpacks on Android 16. - Tier 0 quick-JIT runs on the caller thread (UnityMain) where the crash - happens; disabling tiering switches every method to the full compiler - path as a workaround. */ - setenv("DOTNET_TieredCompilation", "0", 1); - - /* Initialize CoreCLR 鈥?with W^X retry (FusionCore pattern) */ + /* Optional workaround for a libclrjit abort while compiling huge methods. + Off unless the launcher setting says so: the full-JIT path can load + plugins without applying their in-game content. */ + if (g_config.disableTieredCompilation) { + setenv("DOTNET_TieredCompilation", "0", 1); + LOGI("Tiered compilation disabled by launcher setting"); + } else { + setenv("DOTNET_TieredCompilation", "1", 1); + } + + /* Initialize CoreCLR — with W^X retry (FusionCore pattern) */ setenv("DOTNET_EnableWriteXorExecute", "0", 1); void *host = nullptr; diff --git a/app/src/main/cpp/fusion/src/fusion.cpp b/app/src/main/cpp/fusion/src/fusion.cpp index 24ebc6d4..4c0c76e3 100644 --- a/app/src/main/cpp/fusion/src/fusion.cpp +++ b/app/src/main/cpp/fusion/src/fusion.cpp @@ -96,6 +96,7 @@ static bool parse_fusion_config(const char *text, FusionConfig *out) else if (key == "unityDataDirectory") out->unityDataDir = value; else if (key == "unityVersion") out->unityVersion = value; else if (key == "useOriginalLibUnity") out->useOriginalLibUnity = (value == "true"); + else if (key == "disableTieredCompilation") out->disableTieredCompilation = (value == "true"); } return !out->appLibraryDir.empty(); @@ -114,6 +115,7 @@ static bool stage_fusion_config(const FusionConfig &config) LOGI(" unityDataDir: %s", config.unityDataDir.c_str()); LOGI(" unityVersion: %s", config.unityVersion.c_str()); LOGI(" useOrigUnity: %s", config.useOriginalLibUnity ? "true" : "false"); + LOGI(" noTieredJit: %s", config.disableTieredCompilation ? "true" : "false"); /* Set library paths */ std::string unityPath; diff --git a/app/src/main/java/com/bepinex/android/BootstrapActivity.kt b/app/src/main/java/com/bepinex/android/BootstrapActivity.kt index dd25fccf..b44af3e9 100644 --- a/app/src/main/java/com/bepinex/android/BootstrapActivity.kt +++ b/app/src/main/java/com/bepinex/android/BootstrapActivity.kt @@ -145,8 +145,10 @@ class BootstrapActivity : Activity() { // 3. Prepare Fusion state (paths, extract zips, copy data, detect version) val useUnstripped = AppSettings.isUseUnstrippedLibUnity(this) + val disableTieredCompilation = AppSettings.isTieredCompilationDisabled(this) BepInExLog.i("Use unstripped libunity: $useUnstripped") - preparedConfig = prepareFusionState(targetPackage, gameContext, useUnstripped) + BepInExLog.i("Disable tiered compilation: $disableTieredCompilation") + preparedConfig = prepareFusionState(targetPackage, gameContext, useUnstripped, disableTieredCompilation) // 4. Register game native libraries (match FusionCore: no exclusions) updateProgress(getString(R.string.bootstrap_status_registering_libraries), "", 60) @@ -368,7 +370,8 @@ class BootstrapActivity : Activity() { private fun prepareFusionState( targetPackage: String, gameContext: Context, - useUnstripped: Boolean = false + useUnstripped: Boolean = false, + disableTieredCompilation: Boolean = false ): FusionConfig { var gameLibDir = gameContext.applicationInfo.nativeLibraryDir if (gameLibDir.isNullOrEmpty()) { @@ -496,7 +499,8 @@ class BootstrapActivity : Activity() { dotnetDirectory = dotnetDir.absolutePath, unityDataDirectory = copiedData.absolutePath, unityVersion = unityVersion, - useOriginalLibUnity = useOriginalLibUnity + useOriginalLibUnity = useOriginalLibUnity, + disableTieredCompilation = disableTieredCompilation ) } diff --git a/app/src/main/java/com/bepinex/android/MainActivity.kt b/app/src/main/java/com/bepinex/android/MainActivity.kt index daca12d7..50aad5ab 100644 --- a/app/src/main/java/com/bepinex/android/MainActivity.kt +++ b/app/src/main/java/com/bepinex/android/MainActivity.kt @@ -39,6 +39,7 @@ import com.bepinex.android.settings.AppSettings import com.bepinex.android.ui.navigation.BepInExNavHost import com.bepinex.android.ui.onboarding.OnboardingHost import com.bepinex.android.ui.theme.BepInExTheme +import com.bepinex.android.ui.theme.LiquidGlassHost import com.bepinex.android.update.UpdateChecker import com.bepinex.android.update.AnnouncementDialog import com.bepinex.android.update.IncompleteTranslationDialog @@ -91,6 +92,7 @@ class MainActivity : ComponentActivity() { private var themeMode by mutableStateOf(AppSettings.ThemeMode.SYSTEM) private var language by mutableStateOf(AppSettings.Language.SYSTEM) private var dynamicColor by mutableStateOf(false) + private var liquidGlass by mutableStateOf(false) private var animationDisabled by mutableStateOf(false) // Update check state @@ -171,6 +173,7 @@ class MainActivity : ComponentActivity() { themeMode = AppSettings.getThemeMode(this) language = AppSettings.getLanguage(this) dynamicColor = AppSettings.isDynamicColorEnabled(this) + liquidGlass = AppSettings.isLiquidGlassEnabled(this) animationDisabled = AppSettings.isAnimationDisabled(this) showOnboarding = !AppSettings.isOnboardingCompleted(this) @@ -1182,6 +1185,7 @@ class MainActivity : ComponentActivity() { private fun setupContent() { setContent { BepInExTheme(themeMode = themeMode, dynamicColor = dynamicColor) { + LiquidGlassHost(enabled = liquidGlass) { if (showOnboarding) { OnboardingHost( detectedGames = detectedGames, @@ -1209,6 +1213,7 @@ class MainActivity : ComponentActivity() { themeMode = themeMode, language = language, dynamicColor = dynamicColor, + liquidGlass = liquidGlass, animationDisabled = animationDisabled, onSelectGame = { selectGame(it) }, onRescan = { @@ -1221,6 +1226,10 @@ class MainActivity : ComponentActivity() { onDynamicColorChanged = { enabled -> dynamicColor = enabled }, + onLiquidGlassChanged = { enabled -> + liquidGlass = enabled + AppSettings.setLiquidGlassEnabled(this@MainActivity, enabled) + }, onAnimationDisabledChanged = { disabled -> animationDisabled = disabled }, @@ -1310,6 +1319,7 @@ class MainActivity : ComponentActivity() { } ) } + } } } } diff --git a/app/src/main/java/com/bepinex/android/fusion/FusionConfig.kt b/app/src/main/java/com/bepinex/android/fusion/FusionConfig.kt index 28889224..1ed0aaef 100644 --- a/app/src/main/java/com/bepinex/android/fusion/FusionConfig.kt +++ b/app/src/main/java/com/bepinex/android/fusion/FusionConfig.kt @@ -24,7 +24,10 @@ data class FusionConfig( val unityVersion: String, /** If true, use the game's original libunity.so instead of downloading a patched one */ - val useOriginalLibUnity: Boolean = false + val useOriginalLibUnity: Boolean = false, + + /** If true, start CoreCLR with DOTNET_TieredCompilation=0. */ + val disableTieredCompilation: Boolean = false ) { /** * Serialize to the config file format expected by native fusion.cpp parser. @@ -40,5 +43,6 @@ data class FusionConfig( appendLine("unityDataDirectory=${unityDataDirectory}") appendLine("unityVersion=${unityVersion}") appendLine("useOriginalLibUnity=${useOriginalLibUnity}") + appendLine("disableTieredCompilation=${disableTieredCompilation}") } } diff --git a/app/src/main/java/com/bepinex/android/settings/AppSettings.kt b/app/src/main/java/com/bepinex/android/settings/AppSettings.kt index 73db7557..8a2fa01b 100644 --- a/app/src/main/java/com/bepinex/android/settings/AppSettings.kt +++ b/app/src/main/java/com/bepinex/android/settings/AppSettings.kt @@ -18,7 +18,9 @@ object AppSettings { private const val KEY_FLOATING_MOD_MENU = "floating_mod_menu" private const val KEY_ACTIVE_MODPACK_PREFIX = "active_modpack_" private const val KEY_USE_UNSTRIPPED_LIBUNITY = "use_unstripped_libunity" + private const val KEY_DISABLE_TIERED_COMPILATION = "disable_tiered_compilation" private const val KEY_USE_DYNAMIC_COLOR = "use_dynamic_color" + private const val KEY_LIQUID_GLASS = "liquid_glass" private const val KEY_LAST_SEEN_ANNOUNCEMENT = "last_seen_announcement_date" private const val KEY_ANIMATION_DISABLED = "animation_disabled" private const val KEY_LANGUAGE_INCOMPLETE_SHOWN = "language_incomplete_shown" @@ -176,6 +178,14 @@ object AppSettings { prefs(context).edit().putBoolean(KEY_USE_UNSTRIPPED_LIBUNITY, enabled).apply() } + /** When true, CoreCLR is started with DOTNET_TieredCompilation=0. Default off. */ + fun isTieredCompilationDisabled(context: Context): Boolean = + prefs(context).getBoolean(KEY_DISABLE_TIERED_COMPILATION, false) + + fun setTieredCompilationDisabled(context: Context, disabled: Boolean) { + prefs(context).edit().putBoolean(KEY_DISABLE_TIERED_COMPILATION, disabled).apply() + } + // Dynamic Color (Material You / Monet) fun isDynamicColorEnabled(context: Context): Boolean = @@ -185,6 +195,13 @@ object AppSettings { prefs(context).edit().putBoolean(KEY_USE_DYNAMIC_COLOR, enabled).apply() } + fun isLiquidGlassEnabled(context: Context): Boolean = + prefs(context).getBoolean(KEY_LIQUID_GLASS, false) + + fun setLiquidGlassEnabled(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_LIQUID_GLASS, enabled).apply() + } + // Animation fun isAnimationDisabled(context: Context): Boolean = diff --git a/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt b/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt index 612422ac..879d7c73 100644 --- a/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt +++ b/app/src/main/java/com/bepinex/android/ui/components/ConfigEditorDialog.kt @@ -2,6 +2,7 @@ package com.bepinex.android.ui.components import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -19,6 +20,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalSoftwareKeyboardController import com.bepinex.android.R +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import java.io.File /** @@ -55,8 +60,10 @@ fun ConfigEditorDialog( } Scaffold( + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Column { Text( @@ -100,9 +107,7 @@ fun ConfigEditorDialog( ) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -157,7 +162,7 @@ fun ConfigEditorDialog( } if (showDiscardDialog) { - AlertDialog( + GlassAlertDialog( onDismissRequest = { showDiscardDialog = false }, title = { Text(stringResource(R.string.config_editor_discard_title)) }, text = { Text(stringResource(R.string.config_editor_discard_msg)) }, diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/Capsule.kt b/app/src/main/java/com/bepinex/android/ui/liquid/Capsule.kt new file mode 100644 index 00000000..c6b4f886 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/Capsule.kt @@ -0,0 +1,6 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.graphics.Shape + +internal fun Capsule(): Shape = RoundedCornerShape(50) diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/DampedDragAnimation.kt b/app/src/main/java/com/bepinex/android/ui/liquid/DampedDragAnimation.kt new file mode 100644 index 00000000..ace5e699 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/DampedDragAnimation.kt @@ -0,0 +1,138 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.MutatorMutex +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.unit.IntSize +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.abs + +class DampedDragAnimation( + private val animationScope: CoroutineScope, + val initialValue: Float, + val valueRange: ClosedRange, + val visibilityThreshold: Float, + val initialScale: Float, + val pressedScale: Float, + val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit, + val onDragStopped: DampedDragAnimation.() -> Unit, + val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit, +) { + + private val valueAnimationSpec = + spring(1f, 1000f, visibilityThreshold) + private val velocityAnimationSpec = + spring(0.5f, 300f, visibilityThreshold * 10f) + private val pressProgressAnimationSpec = + spring(1f, 1000f, 0.001f) + private val scaleXAnimationSpec = + spring(0.6f, 250f, 0.001f) + private val scaleYAnimationSpec = + spring(0.7f, 250f, 0.001f) + + private val valueAnimation = + Animatable(initialValue, visibilityThreshold) + private val velocityAnimation = + Animatable(0f, 5f) + private val pressProgressAnimation = + Animatable(0f, 0.001f) + private val scaleXAnimation = + Animatable(initialScale, 0.001f) + private val scaleYAnimation = + Animatable(initialScale, 0.001f) + + private val mutatorMutex = MutatorMutex() + + private val velocityTracker = VelocityTracker() + + val value: Float get() = valueAnimation.value + val progress: Float get() = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start) + val targetValue: Float get() = valueAnimation.targetValue + val pressProgress: Float get() = pressProgressAnimation.value + val scaleX: Float get() = scaleXAnimation.value + val scaleY: Float get() = scaleYAnimation.value + val velocity: Float get() = velocityAnimation.value + + val modifier: Modifier = Modifier.pointerInput(Unit) { + inspectDragGestures( + onDragStart = { down -> + onDragStarted(down.position) + press() + }, + onDragEnd = { + onDragStopped() + release() + }, + onDragCancel = { + onDragStopped() + release() + } + ) { change, dragAmount -> + onDrag(size, dragAmount) + } + } + + fun press() { + velocityTracker.resetTracking() + animationScope.launch { + launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(pressedScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(pressedScale, scaleYAnimationSpec) } + } + } + + fun release() { + animationScope.launch { + withFrameNanos { } + if (value != targetValue) { + val threshold = (valueRange.endInclusive - valueRange.start) * 0.025f + snapshotFlow { valueAnimation.value } + .filter { abs(it - valueAnimation.targetValue) < threshold } + .first() + } + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { scaleXAnimation.animateTo(initialScale, scaleXAnimationSpec) } + launch { scaleYAnimation.animateTo(initialScale, scaleYAnimationSpec) } + } + } + + fun updateValue(value: Float) { + val targetValue = value.coerceIn(valueRange) + animationScope.launch { + launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) { updateVelocity() } } + } + } + + fun animateToValue(value: Float) { + animationScope.launch { + mutatorMutex.mutate { + press() + val targetValue = value.coerceIn(valueRange) + launch { valueAnimation.animateTo(targetValue, valueAnimationSpec) } + if (velocity != 0f) { + launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) } + } + release() + } + } + } + + private fun updateVelocity() { + velocityTracker.addPosition( + System.currentTimeMillis(), + Offset(value, 0f) + ) + val targetVelocity = velocityTracker.calculateVelocity().x / (valueRange.endInclusive - valueRange.start) + animationScope.launch { velocityAnimation.animateTo(targetVelocity, velocityAnimationSpec) } + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/DragGestureInspector.kt b/app/src/main/java/com/bepinex/android/ui/liquid/DragGestureInspector.kt new file mode 100644 index 00000000..322d33a6 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/DragGestureInspector.kt @@ -0,0 +1,86 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.util.fastFirstOrNull + +suspend fun PointerInputScope.inspectDragGestures( + onDragStart: (down: PointerInputChange) -> Unit = {}, + onDragEnd: (change: PointerInputChange) -> Unit = {}, + onDragCancel: () -> Unit = {}, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit +) { + awaitEachGesture { + val initialDown = awaitFirstDown(false, PointerEventPass.Initial) + + val down = awaitFirstDown(false) + val drag = initialDown + + onDragStart(down) + onDrag(drag, Offset.Zero) + val upEvent = + drag( + pointerId = drag.id, + onDrag = { onDrag(it, it.positionChange()) } + ) + if (upEvent == null) { + onDragCancel() + } else { + onDragEnd(upEvent) + } + } +} + +private suspend inline fun AwaitPointerEventScope.drag( + pointerId: PointerId, + onDrag: (PointerInputChange) -> Unit +): PointerInputChange? { + val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true + if (isPointerUp) { + return null + } + var pointer = pointerId + while (true) { + val change = awaitDragOrUp(pointer) ?: return null + if (change.isConsumed) { + return null + } + if (change.changedToUpIgnoreConsumed()) { + return change + } + onDrag(change) + pointer = change.id + } +} + +private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( + pointerId: PointerId +): PointerInputChange? { + var pointer = pointerId + while (true) { + val event = awaitPointerEvent() + val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null + if (dragEvent.changedToUpIgnoreConsumed()) { + val otherDown = event.changes.fastFirstOrNull { it.pressed } + if (otherDown == null) { + return dragEvent + } else { + pointer = otherDown.id + } + } else { + val hasDragged = dragEvent.previousPosition != dragEvent.position + if (hasDragged) { + return dragEvent + } + } + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/InteractiveHighlight.kt b/app/src/main/java/com/bepinex/android/ui/liquid/InteractiveHighlight.kt new file mode 100644 index 00000000..3b2c0124 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/InteractiveHighlight.kt @@ -0,0 +1,75 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class InteractiveHighlight( + val animationScope: CoroutineScope, + val position: (size: Size, offset: Offset) -> Offset = { _, offset -> offset } +) { + + private val pressProgressAnimationSpec = + spring(0.5f, 300f, 0.001f) + private val positionAnimationSpec = + spring(0.5f, 300f, Offset.VisibilityThreshold) + + private val pressProgressAnimation = + Animatable(0f, 0.001f) + private val positionAnimation = + Animatable(Offset.Zero, Offset.VectorConverter, Offset.VisibilityThreshold) + + private var startPosition = Offset.Zero + val pressProgress: Float get() = pressProgressAnimation.value + val offset: Offset get() = positionAnimation.value - startPosition + + val modifier: Modifier = + Modifier.drawWithContent { + val progress = pressProgressAnimation.value + if (progress > 0f) { + drawRect( + Color.White.copy(0.25f * progress), + blendMode = BlendMode.Plus + ) + } + drawContent() + } + + val gestureModifier: Modifier = + Modifier.pointerInput(animationScope) { + inspectDragGestures( + onDragStart = { down -> + startPosition = down.position + animationScope.launch { + launch { pressProgressAnimation.animateTo(1f, pressProgressAnimationSpec) } + launch { positionAnimation.snapTo(startPosition) } + } + }, + onDragEnd = { + animationScope.launch { + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } + } + }, + onDragCancel = { + animationScope.launch { + launch { pressProgressAnimation.animateTo(0f, pressProgressAnimationSpec) } + launch { positionAnimation.animateTo(startPosition, positionAnimationSpec) } + } + } + ) { change, _ -> + animationScope.launch { positionAnimation.snapTo(change.position) } + } + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTab.kt b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTab.kt new file mode 100644 index 00000000..ccfdab42 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTab.kt @@ -0,0 +1,50 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp + + +internal val LocalLiquidBottomTabScale = + staticCompositionLocalOf { { 1f } } + +@Composable +fun RowScope.LiquidBottomTab( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val scale = LocalLiquidBottomTabScale.current + Column( + modifier + .clip(Capsule()) + .clickable( + interactionSource = null, + indication = null, + role = Role.Tab, + onClick = onClick + ) + .fillMaxHeight() + .weight(1f) + .graphicsLayer { + val scale = scale() + scaleX = scale + scaleY = scale + }, + verticalArrangement = Arrangement.spacedBy(2f.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + content = content + ) +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt new file mode 100644 index 00000000..c494c954 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt @@ -0,0 +1,291 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastRoundToInt +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.bepinex.android.ui.liquid.DampedDragAnimation +import com.bepinex.android.ui.liquid.InteractiveHighlight +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow + +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.sign + +@Composable +fun LiquidBottomTabs( + selectedTabIndex: () -> Int, + onTabSelected: (index: Int) -> Unit, + backdrop: Backdrop, + tabsCount: Int, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + val scheme = MaterialTheme.colorScheme + val isLightTheme = scheme.background.luminance() > 0.5f + val accentColor = scheme.primary + val containerColor = scheme.surface.copy(alpha = 0.4f) + + val tabsBackdrop = rememberLayerBackdrop() + + BoxWithConstraints( + modifier, + contentAlignment = Alignment.CenterStart + ) { + val density = LocalDensity.current + val tabWidth = with(density) { + (constraints.maxWidth.toFloat() - 8f.dp.toPx()) / tabsCount + } + + val offsetAnimation = remember { Animatable(0f) } + val panelOffset by remember(density) { + derivedStateOf { + val fraction = (offsetAnimation.value / constraints.maxWidth).fastCoerceIn(-1f, 1f) + with(density) { + 4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction)) + } + } + } + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val animationScope = rememberCoroutineScope() + var currentIndex by remember(selectedTabIndex) { + mutableIntStateOf(selectedTabIndex()) + } + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = selectedTabIndex().toFloat(), + valueRange = 0f..(tabsCount - 1).toFloat(), + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 78f / 56f, + onDragStarted = {}, + onDragStopped = { + val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1) + currentIndex = targetIndex + animateToValue(targetIndex.toFloat()) + animationScope.launch { + offsetAnimation.animateTo( + 0f, + spring(1f, 300f, 0.5f) + ) + } + }, + onDrag = { _, dragAmount -> + updateValue( + (targetValue + dragAmount.x / tabWidth * if (isLtr) 1f else -1f) + .fastCoerceIn(0f, (tabsCount - 1).toFloat()) + ) + animationScope.launch { + offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x) + } + } + ) + } + LaunchedEffect(selectedTabIndex) { + snapshotFlow { selectedTabIndex() } + .collectLatest { index -> + currentIndex = index + } + } + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { currentIndex } + .drop(1) + .collectLatest { index -> + dampedDragAnimation.animateToValue(index.toFloat()) + onTabSelected(index) + } + } + + val interactiveHighlight = remember(animationScope) { + InteractiveHighlight( + animationScope = animationScope, + position = { size, offset -> + Offset( + if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset + else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset, + size.height / 2f + ) + } + ) + } + + Row( + Modifier + .graphicsLayer { + translationX = panelOffset + } + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(24f.dp.toPx(), 24f.dp.toPx()) + }, + layerBlock = { + val progress = dampedDragAnimation.pressProgress + val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) + scaleX = scale + scaleY = scale + }, + onDrawSurface = { drawRect(containerColor) } + ) + .then(interactiveHighlight.modifier) + .height(64f.dp) + .fillMaxWidth() + .padding(4f.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CompositionLocalProvider(LocalContentColor provides scheme.onSurface) { + content() + } + } + + CompositionLocalProvider( + LocalLiquidBottomTabScale provides { + lerp(1f, 1.2f, dampedDragAnimation.pressProgress) + } + ) { + Row( + Modifier + .clearAndSetSemantics {} + .alpha(0f) + .layerBackdrop(tabsBackdrop) + .graphicsLayer { + translationX = panelOffset + } + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + vibrancy() + blur(8f.dp.toPx()) + lens( + 24f.dp.toPx() * progress, + 24f.dp.toPx() * progress + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Default.copy(alpha = progress) + }, + onDrawSurface = { drawRect(containerColor) } + ) + .then(interactiveHighlight.modifier) + .height(56f.dp) + .fillMaxWidth() + .padding(horizontal = 4f.dp) + .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), + verticalAlignment = Alignment.CenterVertically, + content = content + ) + } + + Box( + Modifier + .padding(horizontal = 4f.dp) + .graphicsLayer { + translationX = + if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset + else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset + } + .then(interactiveHighlight.gestureModifier) + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop), + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + lens( + 10f.dp.toPx() * progress, + 14f.dp.toPx() * progress, + chromaticAberration = true + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Default.copy(alpha = progress) + }, + shadow = { + val progress = dampedDragAnimation.pressProgress + Shadow(alpha = progress) + }, + innerShadow = { + val progress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 8f.dp * progress, + alpha = progress + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val progress = dampedDragAnimation.pressProgress + drawRect( + if (isLightTheme) Color.Black.copy(0.1f) + else Color.White.copy(0.1f), + alpha = 1f - progress + ) + drawRect(Color.Black.copy(alpha = 0.03f * progress)) + } + ) + .height(56f.dp) + .fillMaxWidth(1f / tabsCount) + ) + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/LiquidButton.kt b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidButton.kt new file mode 100644 index 00000000..73a0b86b --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidButton.kt @@ -0,0 +1,143 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +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.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtMost +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.bepinex.android.ui.liquid.InteractiveHighlight +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy + +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.tanh + +@Composable +fun LiquidButton( + onClick: () -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier, + isInteractive: Boolean = true, + tint: Color = Color.Unspecified, + surfaceColor: Color = Color.Unspecified, + content: @Composable RowScope.() -> Unit +) { + val animationScope = rememberCoroutineScope() + + val interactiveHighlight = remember(animationScope) { + InteractiveHighlight( + animationScope = animationScope + ) + } + + Row( + modifier + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + vibrancy() + blur(2f.dp.toPx()) + lens(12f.dp.toPx(), 24f.dp.toPx()) + }, + layerBlock = if (isInteractive) { + { + val width = size.width + val height = size.height + + val progress = interactiveHighlight.pressProgress + val scale = lerp(1f, 1f + 4f.dp.toPx() / size.height, progress) + + val maxOffset = size.minDimension + val initialDerivative = 0.05f + val offset = interactiveHighlight.offset + translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) + translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + + val maxDragScale = 4f.dp.toPx() / size.height + val offsetAngle = atan2(offset.y, offset.x) + scaleX = + scale + + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) + scaleY = + scale + + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) + } + } else { + null + }, + onDrawSurface = { + if (tint.isSpecified) { + drawRect(tint, blendMode = BlendMode.Hue) + drawRect(tint.copy(alpha = 0.75f)) + } + if (surfaceColor.isSpecified) { + drawRect(surfaceColor) + } + } + ) + .clickable( + interactionSource = null, + indication = if (isInteractive) null else LocalIndication.current, + role = Role.Button, + onClick = onClick + ) + .then( + if (isInteractive) { + Modifier + .then(interactiveHighlight.modifier) + .then(interactiveHighlight.gestureModifier) + } else { + Modifier + } + ) + .height(48f.dp) + .padding(horizontal = 16f.dp), + horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} + +@Composable +fun LiquidButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit +) { + val backdrop = com.bepinex.android.ui.theme.LocalBackdrop.current + if (backdrop == null || !enabled) { + androidx.compose.material3.Button( + onClick = onClick, + modifier = modifier, + enabled = enabled, + content = content + ) + } else { + LiquidButton(onClick = onClick, backdrop = backdrop, modifier = modifier, content = content) + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/LiquidSlider.kt b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidSlider.kt new file mode 100644 index 00000000..20063f86 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidSlider.kt @@ -0,0 +1,243 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.material3.Slider +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +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.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastRoundToInt +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.bepinex.android.ui.liquid.DampedDragAnimation +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow + +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun LiquidSlider( + value: () -> Float, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + visibilityThreshold: Float, + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val isLightTheme = !isSystemInDarkTheme() + val accentColor = + if (isLightTheme) Color(0xFF0088FF) + else Color(0xFF0091FF) + val trackColor = + if (isLightTheme) Color(0xFF787878).copy(0.2f) + else Color(0xFF787880).copy(0.36f) + + val trackBackdrop = rememberLayerBackdrop() + + BoxWithConstraints( + modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart + ) { + val trackWidth = constraints.maxWidth + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val animationScope = rememberCoroutineScope() + var didDrag by remember { mutableStateOf(false) } + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = value(), + valueRange = valueRange, + visibilityThreshold = visibilityThreshold, + initialScale = 1f, + pressedScale = 1.5f, + onDragStarted = {}, + onDragStopped = { + if (didDrag) { + onValueChange(targetValue) + } + }, + onDrag = { _, dragAmount -> + if (!didDrag) { + didDrag = dragAmount.x != 0f + } + val delta = (valueRange.endInclusive - valueRange.start) * (dragAmount.x / trackWidth) + onValueChange( + if (isLtr) (targetValue + delta).coerceIn(valueRange) + else (targetValue - delta).coerceIn(valueRange) + ) + } + ) + } + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { value() } + .collectLatest { value -> + if (dampedDragAnimation.targetValue != value) { + dampedDragAnimation.updateValue(value) + } + } + } + + Box(Modifier.layerBackdrop(trackBackdrop)) { + Box( + Modifier + .clip(Capsule()) + .background(trackColor) + .pointerInput(animationScope) { + detectTapGestures { position -> + val delta = (valueRange.endInclusive - valueRange.start) * (position.x / trackWidth) + val targetValue = + (if (isLtr) valueRange.start + delta + else valueRange.endInclusive - delta) + .coerceIn(valueRange) + dampedDragAnimation.animateToValue(targetValue) + onValueChange(targetValue) + } + } + .height(6f.dp) + .fillMaxWidth() + ) + + Box( + Modifier + .clip(Capsule()) + .background(accentColor) + .height(6f.dp) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val width = (constraints.maxWidth * dampedDragAnimation.progress).fastRoundToInt() + layout(width, placeable.height) { + placeable.place(0, 0) + } + } + ) + } + + Box( + Modifier + .graphicsLayer { + translationX = + (-size.width / 2f + trackWidth * dampedDragAnimation.progress) + .fastCoerceIn(-size.width / 4f, trackWidth - size.width * 3f / 4f) * if (isLtr) 1f else -1f + } + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop( + backdrop, + rememberBackdrop(trackBackdrop) { drawBackdrop -> + val progress = dampedDragAnimation.pressProgress + val scaleX = lerp(2f / 3f, 1f, progress) + val scaleY = lerp(0f, 1f, progress) + scale(scaleX, scaleY) { + drawBackdrop() + } + } + ), + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + blur(8f.dp.toPx() * (1f - progress)) + lens( + 10f.dp.toPx() * progress, + 14f.dp.toPx() * progress, + chromaticAberration = true + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Ambient.copy( + width = Highlight.Ambient.width / 1.5f, + blurRadius = Highlight.Ambient.blurRadius / 1.5f, + alpha = progress + ) + }, + shadow = { + Shadow( + radius = 4f.dp, + color = Color.Black.copy(alpha = 0.05f) + ) + }, + innerShadow = { + val progress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 4f.dp * progress, + alpha = progress + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val progress = dampedDragAnimation.pressProgress + drawRect(Color.White.copy(alpha = 1f - progress)) + } + ) + .size(40f.dp, 24f.dp) + ) + } +} + +@Composable +fun LiquidSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange = 0f..1f, + enabled: Boolean = true +) { + val backdrop = com.bepinex.android.ui.theme.LocalBackdrop.current + if (backdrop == null || !enabled) { + Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + valueRange = valueRange + ) + } else { + LiquidSlider( + value = { value }, + onValueChange = onValueChange, + valueRange = valueRange, + visibilityThreshold = (valueRange.endInclusive - valueRange.start) * 0.001f, + backdrop = backdrop, + modifier = modifier + ) + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/liquid/LiquidToggle.kt b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidToggle.kt new file mode 100644 index 00000000..0c24d42e --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidToggle.kt @@ -0,0 +1,224 @@ +package com.bepinex.android.ui.liquid + +import androidx.compose.material3.Switch +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberBackdrop +import com.kyant.backdrop.backdrops.rememberCombinedBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.bepinex.android.ui.liquid.DampedDragAnimation +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow + +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun LiquidToggle( + selected: () -> Boolean, + onSelect: (Boolean) -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier +) { + val isLightTheme = !isSystemInDarkTheme() + val accentColor = + if (isLightTheme) Color(0xFF34C759) + else Color(0xFF30D158) + val trackColor = + if (isLightTheme) Color(0xFF787878).copy(0.2f) + else Color(0xFF787880).copy(0.36f) + + val density = LocalDensity.current + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val dragWidth = with(density) { 20f.dp.toPx() } + val animationScope = rememberCoroutineScope() + var didDrag by remember { mutableStateOf(false) } + var fraction by remember { mutableFloatStateOf(if (selected()) 1f else 0f) } + val dampedDragAnimation = remember(animationScope) { + DampedDragAnimation( + animationScope = animationScope, + initialValue = fraction, + valueRange = 0f..1f, + visibilityThreshold = 0.001f, + initialScale = 1f, + pressedScale = 1.5f, + onDragStarted = {}, + onDragStopped = { + if (didDrag) { + fraction = if (targetValue >= 0.5f) 1f else 0f + onSelect(fraction == 1f) + didDrag = false + } else { + fraction = if (selected()) 0f else 1f + onSelect(fraction == 1f) + } + }, + onDrag = { _, dragAmount -> + if (!didDrag) { + didDrag = dragAmount.x != 0f + } + val delta = dragAmount.x / dragWidth + fraction = + if (isLtr) (fraction + delta).fastCoerceIn(0f, 1f) + else (fraction - delta).fastCoerceIn(0f, 1f) + } + ) + } + LaunchedEffect(dampedDragAnimation) { + snapshotFlow { fraction } + .collectLatest { fraction -> + dampedDragAnimation.updateValue(fraction) + } + } + LaunchedEffect(selected) { + snapshotFlow { selected() } + .collectLatest { isSelected -> + val target = if (isSelected) 1f else 0f + if (target != fraction) { + fraction = target + dampedDragAnimation.animateToValue(target) + } + } + } + + val trackBackdrop = rememberLayerBackdrop() + + Box( + modifier, + contentAlignment = Alignment.CenterStart + ) { + Box( + Modifier + .layerBackdrop(trackBackdrop) + .clip(Capsule()) + .drawBehind { + val fraction = dampedDragAnimation.value + drawRect(lerp(trackColor, accentColor, fraction)) + } + .size(64f.dp, 28f.dp) + ) + + Box( + Modifier + .graphicsLayer { + val fraction = dampedDragAnimation.value + val padding = 2f.dp.toPx() + translationX = + if (isLtr) lerp(padding, padding + dragWidth, fraction) + else lerp(-padding, -(padding + dragWidth), fraction) + } + .semantics { + role = Role.Switch + } + .then(dampedDragAnimation.modifier) + .drawBackdrop( + backdrop = rememberCombinedBackdrop( + backdrop, + rememberBackdrop(trackBackdrop) { drawBackdrop -> + val progress = dampedDragAnimation.pressProgress + val scaleX = lerp(2f / 3f, 0.75f, progress) + val scaleY = lerp(0f, 0.75f, progress) + scale(scaleX, scaleY) { + drawBackdrop() + } + } + ), + shape = { Capsule() }, + effects = { + val progress = dampedDragAnimation.pressProgress + blur(8f.dp.toPx() * (1f - progress)) + lens( + 5f.dp.toPx() * progress, + 10f.dp.toPx() * progress, + chromaticAberration = true + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Ambient.copy( + width = Highlight.Ambient.width / 1.5f, + blurRadius = Highlight.Ambient.blurRadius / 1.5f, + alpha = progress + ) + }, + shadow = { + Shadow( + radius = 4f.dp, + color = Color.Black.copy(alpha = 0.05f) + ) + }, + innerShadow = { + val progress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 4f.dp * progress, + alpha = progress + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 50f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val progress = dampedDragAnimation.pressProgress + drawRect(Color.White.copy(alpha = 1f - progress)) + } + ) + .size(40f.dp, 24f.dp) + ) + } +} + +@Composable +fun LiquidToggle( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val backdrop = com.bepinex.android.ui.theme.LocalBackdrop.current + if (backdrop == null) { + Switch(checked = checked, onCheckedChange = onCheckedChange, modifier = modifier) + } else { + LiquidToggle( + selected = { checked }, + onSelect = onCheckedChange, + backdrop = backdrop, + modifier = modifier + ) + } +} + diff --git a/app/src/main/java/com/bepinex/android/ui/navigation/BepInExNavHost.kt b/app/src/main/java/com/bepinex/android/ui/navigation/BepInExNavHost.kt index b811dfa9..2ccb72c5 100644 --- a/app/src/main/java/com/bepinex/android/ui/navigation/BepInExNavHost.kt +++ b/app/src/main/java/com/bepinex/android/ui/navigation/BepInExNavHost.kt @@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* @@ -53,6 +55,12 @@ import com.bepinex.android.modpack.ModpackManager import com.bepinex.android.modpack.ModpackMeta import com.bepinex.android.settings.AppSettings import com.bepinex.android.ui.screens.TextViewerScreen +import com.bepinex.android.ui.liquid.LiquidBottomTab +import com.bepinex.android.ui.liquid.LiquidBottomTabs +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.LocalBackdrop +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface import com.bepinex.android.ui.onboarding.CoachMarkOverlay import com.bepinex.android.ui.onboarding.CoachMarkStep import com.bepinex.android.ui.onboarding.CoachMarkTargets @@ -138,6 +146,7 @@ fun BepInExNavHost( themeMode: AppSettings.ThemeMode, language: AppSettings.Language, dynamicColor: Boolean, + liquidGlass: Boolean, animationDisabled: Boolean, // Callbacks onSelectGame: (GameDetector.DetectedGame) -> Unit, @@ -146,6 +155,7 @@ fun BepInExNavHost( onThemeChanged: (AppSettings.ThemeMode) -> Unit, onLanguageChanged: (AppSettings.Language) -> Unit, onDynamicColorChanged: (Boolean) -> Unit, + onLiquidGlassChanged: (Boolean) -> Unit, onAnimationDisabledChanged: (Boolean) -> Unit, onClearBepInEx: (String) -> Unit, onClearDotnet: (String) -> Unit, @@ -510,7 +520,7 @@ fun BepInExNavHost( CompositionLocalProvider(LocalCoachMarkTargets provides coachTargets) { Box(modifier = Modifier.fillMaxSize()) { Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0) ) { _ -> val navContent: @Composable () -> Unit = { @@ -785,6 +795,9 @@ fun BepInExNavHost( var useUnstrippedLibUnity by remember { mutableStateOf(AppSettings.isUseUnstrippedLibUnity(settingsContext)) } + var disableTieredCompilation by remember { + mutableStateOf(AppSettings.isTieredCompilationDisabled(settingsContext)) + } var dynamicColor by remember { mutableStateOf(AppSettings.isDynamicColorEnabled(settingsContext)) } @@ -795,10 +808,12 @@ fun BepInExNavHost( themeMode = themeMode, language = language, dynamicColor = dynamicColor, + liquidGlass = liquidGlass, animationDisabled = animationDisabledSetting, floatingLogInGame = floatingLogInGame, floatingModMenu = floatingModMenu, useUnstrippedLibUnity = useUnstrippedLibUnity, + disableTieredCompilation = disableTieredCompilation, onNavigateToAbout = { navController.navigate(NavRoutes.ABOUT) }, onThemeChanged = onThemeChanged, onLanguageChanged = onLanguageChanged, @@ -807,6 +822,7 @@ fun BepInExNavHost( dynamicColor = enabled onDynamicColorChanged(enabled) }, + onLiquidGlassChanged = onLiquidGlassChanged, onAnimationDisabledChanged = { disabled -> AppSettings.setAnimationDisabled(settingsContext, disabled) animationDisabledSetting = disabled @@ -823,6 +839,10 @@ fun BepInExNavHost( AppSettings.setUseUnstrippedLibUnity(settingsContext, enabled) useUnstrippedLibUnity = enabled }, + onDisableTieredCompilationChanged = { disabled -> + AppSettings.setTieredCompilationDisabled(settingsContext, disabled) + disableTieredCompilation = disabled + }, onClearBepInEx = { onClearBepInEx(packageName) }, onClearDotnet = { onClearDotnet(packageName) }, onClearLibUnity = { onClearLibUnity(packageName) }, @@ -1198,7 +1218,7 @@ fun BepInExNavHost( } } exportProgress?.let { progress -> - AlertDialog( + GlassAlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.modpack_export)) }, text = { @@ -1232,7 +1252,7 @@ fun BepInExNavHost( ) } if (importJob?.isActive == true) { - AlertDialog( + GlassAlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.modpack_import)) }, text = { @@ -1249,7 +1269,7 @@ fun BepInExNavHost( ) } versionMismatchPrompt?.let { prompt -> - AlertDialog( + GlassAlertDialog( onDismissRequest = { versionMismatchPrompt = null }, title = { Text(stringResource(R.string.modpack_game_version_mismatch_title)) }, text = { Text(prompt.message) }, @@ -1275,7 +1295,10 @@ fun BepInExNavHost( if (isTablet && showBottomBar) { Row(modifier = Modifier.fillMaxSize()) { NavigationRail( - containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.glassSurface( + RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp) + ), + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface), contentColor = MaterialTheme.colorScheme.onSurface ) { NavigationRailItem( @@ -1315,7 +1338,53 @@ fun BepInExNavHost( } } - if (!isTablet) { + fun openMainPage(index: Int) { + if ((index == 1 || index == 3) && selectedGame == null) return + if (pagerState.currentPage == index) return + composeScope.launch { + if (animationDisabled) pagerState.scrollToPage(index) else pagerState.animateScrollToPage(index) + } + } + val glassBackdrop = LocalBackdrop.current + if (!isTablet && glassBackdrop != null) { + LiquidBottomTabs( + selectedTabIndex = { pagerState.currentPage }, + onTabSelected = ::openMainPage, + backdrop = glassBackdrop, + tabsCount = 4, + modifier = Modifier + .align(Alignment.BottomCenter) + .onSizeChanged { bottomBarHeightPx = it.height } + .offset { + IntOffset( + x = 0, + y = ((1f - bottomBarFraction) * bottomBarHeightPx).roundToInt() + ) + } + .navigationBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + LiquidBottomTab(onClick = { openMainPage(0) }) { + Icon(Icons.Filled.SportsEsports, stringResource(R.string.nav_games)) + Text(stringResource(R.string.nav_games), style = MaterialTheme.typography.labelSmall) + } + LiquidBottomTab( + onClick = { openMainPage(1) }, + modifier = Modifier.onGloballyPositioned { coachTargets.updateModpacks(it) } + ) { + Icon(Icons.Filled.FolderZip, stringResource(R.string.nav_modpacks)) + Text(stringResource(R.string.nav_modpacks), style = MaterialTheme.typography.labelSmall) + } + LiquidBottomTab(onClick = { openMainPage(2) }) { + Icon(Icons.Filled.Storefront, stringResource(R.string.nav_market)) + Text(stringResource(R.string.nav_market), style = MaterialTheme.typography.labelSmall) + } + LiquidBottomTab(onClick = { openMainPage(3) }) { + Icon(Icons.Filled.Settings, stringResource(R.string.nav_settings)) + Text(stringResource(R.string.nav_settings), style = MaterialTheme.typography.labelSmall) + } + } + } else if (!isTablet) { NavigationBar( modifier = Modifier .align(Alignment.BottomCenter) @@ -1325,8 +1394,9 @@ fun BepInExNavHost( x = 0, y = ((1f - bottomBarFraction) * bottomBarHeightPx).roundToInt() ) - }, - containerColor = MaterialTheme.colorScheme.surface, + } + .glassSurface(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)), + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface), contentColor = MaterialTheme.colorScheme.onSurface ) { NavigationBarItem( diff --git a/app/src/main/java/com/bepinex/android/ui/onboarding/CoachMarkOverlay.kt b/app/src/main/java/com/bepinex/android/ui/onboarding/CoachMarkOverlay.kt index 2a05b849..3007be4a 100644 --- a/app/src/main/java/com/bepinex/android/ui/onboarding/CoachMarkOverlay.kt +++ b/app/src/main/java/com/bepinex/android/ui/onboarding/CoachMarkOverlay.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.bepinex.android.R +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface data class CoachMarkStep( val rect: Rect?, @@ -115,12 +117,13 @@ fun CoachMarkOverlay( ) { Surface( shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surface, + color = glassContainerColor(MaterialTheme.colorScheme.surface), tonalElevation = 4.dp, shadowElevation = 6.dp, modifier = Modifier .widthIn(max = 360.dp) .align(if (holeInLowerHalf) Alignment.TopCenter else Alignment.BottomCenter) + .glassSurface(RoundedCornerShape(16.dp)) ) { Column(Modifier.padding(18.dp)) { Text( @@ -143,7 +146,7 @@ fun CoachMarkOverlay( TextButton(onClick = onSkip) { Text(stringResource(R.string.coach_skip)) } - Button( + com.bepinex.android.ui.liquid.LiquidButton( onClick = { if (last) onFinished() else index = safeIndex + 1 } diff --git a/app/src/main/java/com/bepinex/android/ui/onboarding/OnboardingHost.kt b/app/src/main/java/com/bepinex/android/ui/onboarding/OnboardingHost.kt index 53db35ef..17c2499c 100644 --- a/app/src/main/java/com/bepinex/android/ui/onboarding/OnboardingHost.kt +++ b/app/src/main/java/com/bepinex/android/ui/onboarding/OnboardingHost.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.bepinex.android.GameDetector import com.bepinex.android.R +import com.bepinex.android.ui.theme.glassContainerColor import com.bepinex.android.update.MarkdownText import kotlinx.coroutines.launch @@ -84,7 +85,7 @@ fun OnboardingHost( Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = glassContainerColor(MaterialTheme.colorScheme.background) ) { Column( modifier = Modifier @@ -190,7 +191,7 @@ fun OnboardingHost( Text(stringResource(R.string.onboarding_back)) } } - Button( + com.bepinex.android.ui.liquid.LiquidButton( onClick = { if (lastPage) onFinished() else goTo(page + 1) }, @@ -316,9 +317,8 @@ private fun OnboardingGamePage( textAlign = TextAlign.Center ) Spacer(Modifier.height(20.dp)) - FilledTonalButton( - onClick = onRescan, - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp) + com.bepinex.android.ui.liquid.LiquidButton( + onClick = onRescan ) { Text(stringResource(R.string.scan_again)) } @@ -411,9 +411,8 @@ private fun PermissionGrantControl( ) } } else { - Button( - onClick = onGrant, - shape = RoundedCornerShape(14.dp) + com.bepinex.android.ui.liquid.LiquidButton( + onClick = onGrant ) { Text(grantButtonText) } diff --git a/app/src/main/java/com/bepinex/android/ui/screens/AboutScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/AboutScreen.kt index 340e73e5..210b669e 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/AboutScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/AboutScreen.kt @@ -27,8 +27,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import com.bepinex.android.R +import com.bepinex.android.ui.theme.GlassDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -40,18 +43,17 @@ fun AboutScreen( var showSponsorDialog by remember { mutableStateOf(false) } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Text(stringResource(R.string.nav_about)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.back)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -85,9 +87,10 @@ fun AboutScreen( Spacer(Modifier.height(12.dp)) Card( + modifier = Modifier.glassSurface(RoundedCornerShape(12.dp)), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + containerColor = glassContainerColor(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) ) ) { Text( @@ -101,9 +104,10 @@ fun AboutScreen( Spacer(Modifier.height(16.dp)) Card( + modifier = Modifier.glassSurface(RoundedCornerShape(12.dp)), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + containerColor = glassContainerColor(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)) ) ) { Row( @@ -196,12 +200,13 @@ private fun AboutActionButton(title: String, subtitle: String, onClick: () -> Un Card( shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ), modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) .clickable(onClick = onClick) + .glassSurface(RoundedCornerShape(12.dp)) ) { Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) { Text( @@ -224,11 +229,12 @@ private fun AboutActionButton(title: String, subtitle: String, onClick: () -> Un private fun SponsorDialog(onDismiss: () -> Unit) { val context = LocalContext.current - Dialog(onDismissRequest = onDismiss) { + GlassDialog(onDismissRequest = onDismiss) { Card( + modifier = Modifier.glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface) ) ) { Column( @@ -298,7 +304,7 @@ private fun CreditProjectCard(name: String, desc: String, url: String, context: Card( shape = RoundedCornerShape(10.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ), modifier = Modifier .fillMaxWidth() @@ -306,6 +312,7 @@ private fun CreditProjectCard(name: String, desc: String, url: String, context: .clickable { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } + .glassSurface(RoundedCornerShape(10.dp)) ) { Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { Text( diff --git a/app/src/main/java/com/bepinex/android/ui/screens/CreditsScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/CreditsScreen.kt index 3989fd76..558f5bbd 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/CreditsScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/CreditsScreen.kt @@ -19,6 +19,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.bepinex.android.R +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -26,18 +29,17 @@ fun CreditsScreen(onNavigateBack: () -> Unit) { val context = LocalContext.current Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Text(stringResource(R.string.about_credits)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.back)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -142,7 +144,7 @@ private fun CreditProjectCard(name: String, desc: String, url: String, context: Card( shape = RoundedCornerShape(10.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ), modifier = Modifier .fillMaxWidth() @@ -150,6 +152,7 @@ private fun CreditProjectCard(name: String, desc: String, url: String, context: .clickable { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } + .glassSurface(RoundedCornerShape(10.dp)) ) { Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { Text( diff --git a/app/src/main/java/com/bepinex/android/ui/screens/GameScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/GameScreen.kt index b9b24d9c..cc7ae9a6 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/GameScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/GameScreen.kt @@ -55,7 +55,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -72,6 +71,10 @@ import androidx.core.graphics.drawable.toBitmap import com.bepinex.android.GameDetector import com.bepinex.android.R import com.bepinex.android.ui.onboarding.LocalCoachMarkTargets +import com.bepinex.android.ui.theme.LocalLiquidGlass +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors /** * Main game selection and mod management screen. @@ -102,7 +105,7 @@ fun GameScreen( isSwitchingModpack: Boolean = false ) { Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( @@ -125,9 +128,10 @@ fun GameScreen( windowInsets = WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + modifier = Modifier.glassSurface( + RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp) ), + colors = glassTopBarColors(), actions = { val coachTargets = LocalCoachMarkTargets.current IconButton(onClick = onShowAnnouncement) { @@ -269,10 +273,12 @@ fun GameScreen( @Composable private fun ScanningCard() { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ) ) { Row( @@ -295,10 +301,12 @@ private fun ScanningCard() { @Composable private fun EmptyGamesCard(onRescan: () -> Unit) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ) ) { Column( @@ -346,14 +354,17 @@ private fun GameSelectionCard( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .glassSurface(RoundedCornerShape(14.dp)), shape = RoundedCornerShape(14.dp), colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } + containerColor = glassContainerColor( + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) ) ) { Row( @@ -416,12 +427,15 @@ private fun SelectedGameCard( Card( modifier = Modifier .fillMaxWidth() - .animateContentSize(), + .animateContentSize() + .glassSurface(RoundedCornerShape(18.dp)), shape = RoundedCornerShape(18.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface) ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + elevation = CardDefaults.cardElevation( + defaultElevation = if (LocalLiquidGlass.current) 0.dp else 1.dp + ) ) { Column(Modifier.padding(18.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -538,13 +552,12 @@ private fun SelectedGameActions( onLaunch: () -> Unit, modifier: Modifier = Modifier ) { - Button( + com.bepinex.android.ui.liquid.LiquidButton( onClick = onLaunch, modifier = modifier .fillMaxWidth() .height(56.dp), - enabled = canLaunch, - shape = RoundedCornerShape(14.dp) + enabled = canLaunch ) { Icon(Icons.Filled.PlayArrow, contentDescription = null) Spacer(Modifier.width(8.dp)) @@ -566,13 +579,16 @@ private fun ManageSavesButton( onClick: () -> Unit, modifier: Modifier = Modifier ) { + val capsule = RoundedCornerShape(50) FilledTonalButton( onClick = onClick, - modifier = modifier.height(40.dp), - shape = RoundedCornerShape(50), + modifier = modifier + .height(40.dp) + .glassSurface(capsule), + shape = capsule, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, + containerColor = glassContainerColor(MaterialTheme.colorScheme.secondaryContainer), contentColor = MaterialTheme.colorScheme.onSecondaryContainer ) ) { @@ -619,10 +635,11 @@ private fun StatusChip( failed -> stringResource(R.string.framework_setup_failed) else -> stringResource(R.string.framework_setting_up) } + val capsule = RoundedCornerShape(50) Surface( - modifier = modifier, - shape = RoundedCornerShape(50), - color = containerColor + modifier = modifier.glassSurface(capsule), + shape = capsule, + color = glassContainerColor(containerColor) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -648,14 +665,18 @@ private fun ModpackChip( activeModpackEnabledCount: Int, activeModpackModCount: Int ) { + val capsule = RoundedCornerShape(50) + val fill = if (activeModpackName != null) { + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.65f) + } else { + MaterialTheme.colorScheme.surfaceVariant + } Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(10.dp), - color = if (activeModpackName != null) { - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.65f) - } else { - MaterialTheme.colorScheme.surfaceVariant - } + modifier = Modifier + .fillMaxWidth() + .glassSurface(capsule), + shape = capsule, + color = glassContainerColor(fill) ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), diff --git a/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt index a459acd6..08ddaffb 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/LogViewerScreen.kt @@ -7,6 +7,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -25,6 +26,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import com.bepinex.android.R +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -134,9 +138,10 @@ fun LogViewerScreen( } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Text(stringResource(R.string.log_title)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { @@ -160,9 +165,7 @@ fun LogViewerScreen( Icon(Icons.Filled.Settings, stringResource(R.string.viewer_settings)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -174,9 +177,9 @@ fun LogViewerScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().glassSurface(MaterialTheme.shapes.medium), shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + color = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) { when { readResult.error != null -> { @@ -300,18 +303,17 @@ fun LogViewerSettingsScreen( BackHandler { onNavigateBack() } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Text(stringResource(R.string.log_settings)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.back)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -323,9 +325,9 @@ fun LogViewerSettingsScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().glassSurface(MaterialTheme.shapes.medium), shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + color = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -341,7 +343,7 @@ fun LogViewerSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = autoScroll, onCheckedChange = onAutoScrollChange) + com.bepinex.android.ui.liquid.LiquidToggle(checked = autoScroll, onCheckedChange = onAutoScrollChange) } HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) @@ -359,7 +361,7 @@ fun LogViewerSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = wordWrap, onCheckedChange = onWordWrapChange) + com.bepinex.android.ui.liquid.LiquidToggle(checked = wordWrap, onCheckedChange = onWordWrapChange) } HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) @@ -377,7 +379,7 @@ fun LogViewerSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = showLineNumbers, onCheckedChange = onLineNumbersChange) + com.bepinex.android.ui.liquid.LiquidToggle(checked = showLineNumbers, onCheckedChange = onLineNumbersChange) } } } diff --git a/app/src/main/java/com/bepinex/android/ui/screens/MarketModDetailScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/MarketModDetailScreen.kt index 18b57ae2..ee14aa12 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/MarketModDetailScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/MarketModDetailScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Sync -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -60,7 +59,6 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -88,6 +86,10 @@ import com.bepinex.android.modpack.PeekedModpackInfo import com.bepinex.android.settings.AppSettings import com.bepinex.android.shortcut.ModpackShortcutHelper import com.bepinex.android.ui.components.MarkdownContent +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -323,7 +325,7 @@ fun MarketModDetailScreen( } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( @@ -339,9 +341,10 @@ fun MarketModDetailScreen( windowInsets = WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + modifier = Modifier.glassSurface( + RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp) + ), + colors = glassTopBarColors() ) } ) { padding -> @@ -460,7 +463,7 @@ fun MarketModDetailScreen( onCancel = { cancelInstall() } ) } else if (importing) { - AlertDialog( + GlassAlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.modpack_import)) }, text = { @@ -479,7 +482,7 @@ fun MarketModDetailScreen( versionMismatch?.let { peeked -> val scanRoot = pendingModpack?.scanRoot ?: return@let - AlertDialog( + GlassAlertDialog( onDismissRequest = { cleanupInstall() }, title = { Text(stringResource(R.string.modpack_game_version_mismatch_title)) }, text = { @@ -612,7 +615,9 @@ private fun MarketDetailHeroCard( onInstall: (String) -> Unit ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation() @@ -714,11 +719,10 @@ private fun MarketDetailHeroCard( Spacer(Modifier.height(14.dp)) - Button( + com.bepinex.android.ui.liquid.LiquidButton( onClick = { if (primaryUrl.isNotBlank()) onInstall(primaryUrl) }, enabled = primaryUrl.isNotBlank() && installEnabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth() ) { val canInstall = item.canInstallDirect Icon( @@ -751,10 +755,13 @@ private fun MarketDetailTabBar( onSelect: (MarketDetailTab) -> Unit ) { val selectedIndex = tabs.indexOf(selected).coerceAtLeast(0) + val tabShape = RoundedCornerShape(12.dp) TabRow( selectedTabIndex = selectedIndex, - modifier = Modifier.clip(RoundedCornerShape(12.dp)), - containerColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .clip(tabShape) + .glassSurface(tabShape), + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant), contentColor = MaterialTheme.colorScheme.primary ) { tabs.forEach { item -> @@ -783,7 +790,9 @@ private fun MarketInfoTab( onOpenVideo: () -> Unit ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation() @@ -804,7 +813,9 @@ private fun MarketInfoTab( Spacer(Modifier.height(12.dp)) Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation() @@ -871,7 +882,9 @@ private fun MarketDownloadTab( val hasAnyLink = item.downloadDirectUrl.isNotBlank() || cloudUrl.isNotBlank() Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation() @@ -917,7 +930,9 @@ private fun MarketSourceTab( onOpenUrl: (String) -> Unit ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation() @@ -1090,7 +1105,7 @@ private fun MarketDirectProgressDialog( MarketInstaller.Progress.Phase.Scanning -> R.string.market_direct_scanning } ) - AlertDialog( + GlassAlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.market_download)) }, text = { @@ -1133,7 +1148,7 @@ private fun MarketDirectImportDialog( onDismiss: () -> Unit ) { val foundLabel = dllNames.joinToString() - AlertDialog( + GlassAlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.market_direct_import_title)) }, text = { diff --git a/app/src/main/java/com/bepinex/android/ui/screens/MarketScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/MarketScreen.kt index 7af8b3aa..e2a83455 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/MarketScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/MarketScreen.kt @@ -58,12 +58,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -78,6 +78,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.nestedscroll.NestedScrollConnection @@ -98,6 +99,10 @@ import com.bepinex.android.market.MarketCategory import com.bepinex.android.market.MarketMod import com.bepinex.android.market.MarketTag import com.bepinex.android.ui.components.plainTextFromMarkdown +import com.bepinex.android.ui.theme.LocalLiquidGlass +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -257,11 +262,46 @@ fun MarketScreen( val featuredMods = remember(filteredMods, browsing) { if (browsing) filteredMods.filter { it.isFeatured } else emptyList() } + val glass = LocalLiquidGlass.current + val showMarketBars = !(isLoading && mods.isEmpty()) && !(loadFailed && mods.isEmpty()) + val marketBars: @Composable () -> Unit = { + MarketFilterBar( + filter = filter, + tags = tags, + selectedTagId = selectedTagId, + onFilterChange = { next -> + if (next == MarketFilter.Author && filter == MarketFilter.Author) { + selectedAuthor = null + } + selectedTagId = null + filter = next + }, + onTagSelect = { tagId -> + selectedAuthor = null + filter = MarketFilter.All + selectedTagId = tagId + } + ) + if (categories.isNotEmpty() && !showingAuthorDirectory) { + MarketCategoryBar( + categories = categories, + selectedId = selectedCategoryId, + onSelect = { selectedCategoryId = it } + ) + } + } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { + Column( + modifier = if (glass) { + Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)) + } else { + Modifier + } + ) { TopAppBar( title = { Text( @@ -316,24 +356,15 @@ fun MarketScreen( windowInsets = WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { AnimatedVisibility(visible = searchExpanded) { OutlinedTextField( value = searchQuery, onValueChange = { searchQuery = it }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 4.dp), placeholder = { Text( stringResource( @@ -346,41 +377,29 @@ fun MarketScreen( singleLine = true, shape = RoundedCornerShape(16.dp), colors = OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = glassContainerColor( + MaterialTheme.colorScheme.surfaceVariant + ), + focusedContainerColor = glassContainerColor( + MaterialTheme.colorScheme.surfaceVariant + ), unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedBorderColor = MaterialTheme.colorScheme.primary ) ) } - - if (!(isLoading && mods.isEmpty()) && !(loadFailed && mods.isEmpty())) { - MarketFilterBar( - filter = filter, - tags = tags, - selectedTagId = selectedTagId, - onFilterChange = { next -> - if (next == MarketFilter.Author && filter == MarketFilter.Author) { - selectedAuthor = null - } - selectedTagId = null - filter = next - }, - onTagSelect = { tagId -> - selectedAuthor = null - filter = MarketFilter.All - selectedTagId = tagId - } - ) - if (categories.isNotEmpty() && !showingAuthorDirectory) { - MarketCategoryBar( - categories = categories, - selectedId = selectedCategoryId, - onSelect = { selectedCategoryId = it } - ) - } + if (showMarketBars) { + marketBars() } - + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Box(Modifier.fillMaxSize()) { PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = { loadMods(forceRefresh = true) }, @@ -509,8 +528,17 @@ fun MarketScreen( onClick = { scope.launch { listState.animateScrollToItem(0) } }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + modifier = Modifier.glassSurface(RoundedCornerShape(50)), + containerColor = glassContainerColor( + MaterialTheme.colorScheme.primaryContainer + ), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = if (LocalLiquidGlass.current) 0.dp else 6.dp, + pressedElevation = if (LocalLiquidGlass.current) 0.dp else 6.dp, + focusedElevation = if (LocalLiquidGlass.current) 0.dp else 6.dp, + hoveredElevation = if (LocalLiquidGlass.current) 0.dp else 8.dp + ) ) { Icon( imageVector = Icons.Filled.KeyboardArrowUp, @@ -522,6 +550,7 @@ fun MarketScreen( } } } + } } } } @@ -588,7 +617,7 @@ private fun MarketFilterBar( onFilterChange: (MarketFilter) -> Unit, onTagSelect: (String?) -> Unit ) { - val chipColors = FilterChipDefaults.filterChipColors( + val chipColors = marketGlassChipColors( selectedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), selectedLabelColor = MaterialTheme.colorScheme.primary ) @@ -650,7 +679,7 @@ private fun MarketFilterBar( selected = selected, onClick = { onTagSelect(if (selected) null else tag.id.ifEmpty { tag.name }) }, label = { Text(tag.name) }, - colors = FilterChipDefaults.filterChipColors( + colors = marketGlassChipColors( selectedContainerColor = (tagColor ?: MaterialTheme.colorScheme.primary) .copy(alpha = 0.16f), selectedLabelColor = tagColor ?: MaterialTheme.colorScheme.primary @@ -667,6 +696,25 @@ private fun MarketFilterBar( } } +@Composable +private fun marketGlassChipColors( + selectedContainerColor: Color, + selectedLabelColor: Color +) = if (LocalLiquidGlass.current) { + FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.onSurface, + iconColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = selectedContainerColor, + selectedLabelColor = selectedLabelColor + ) +} else { + FilterChipDefaults.filterChipColors( + selectedContainerColor = selectedContainerColor, + selectedLabelColor = selectedLabelColor + ) +} + @Composable private fun marketChipBorder(selected: Boolean) = FilterChipDefaults.filterChipBorder( enabled = true, @@ -681,7 +729,7 @@ private fun MarketCategoryBar( selectedId: String?, onSelect: (String?) -> Unit ) { - val chipColors = FilterChipDefaults.filterChipColors( + val chipColors = marketGlassChipColors( selectedContainerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.16f), selectedLabelColor = MaterialTheme.colorScheme.secondary ) @@ -734,7 +782,8 @@ private fun MarketAuthorRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation() @@ -849,7 +898,8 @@ private fun MarketHotCard( Card( modifier = Modifier .width(124.dp) - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation(), @@ -909,7 +959,8 @@ private fun MarketModCard( Card( modifier = modifier .fillMaxWidth() - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .glassSurface(MarketCardShape), shape = MarketCardShape, colors = marketCardColors(), elevation = marketCardElevation(), @@ -1048,7 +1099,7 @@ internal val MarketCardShape = RoundedCornerShape(18.dp) @Composable internal fun marketCardColors() = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface), contentColor = MaterialTheme.colorScheme.onSurface ) @@ -1120,9 +1171,11 @@ internal fun MarketTagChip(mod: MarketMod) { scheme.outlineVariant ) } + val capsule = RoundedCornerShape(50) Surface( - shape = RoundedCornerShape(50), - color = container, + modifier = Modifier.glassSurface(capsule), + shape = capsule, + color = glassContainerColor(container), border = BorderStroke(1.dp, border) ) { Text( diff --git a/app/src/main/java/com/bepinex/android/ui/screens/ModFileBrowserScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/ModFileBrowserScreen.kt index d361621f..cf240415 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/ModFileBrowserScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/ModFileBrowserScreen.kt @@ -24,6 +24,10 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bepinex.android.R +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import java.io.File private val EDITABLE_TEXT_EXTENSIONS = setOf( @@ -105,9 +109,10 @@ fun ModFileBrowserScreen( BackHandler { navigateUp() } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Column { Text( @@ -134,7 +139,8 @@ fun ModFileBrowserScreen( contentDescription = stringResource(R.string.mod_file_browser_title) ) } - } + }, + colors = glassTopBarColors() ) } ) { padding -> @@ -168,14 +174,17 @@ fun ModFileBrowserScreen( .fillMaxWidth() .semantics { contentDescription = accessibilityDescription - }, + } + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = if (isProtected) { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) - } else { - MaterialTheme.colorScheme.surfaceVariant - } + containerColor = glassContainerColor( + if (isProtected) { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) ), enabled = clickEnabled, onClick = { @@ -246,7 +255,7 @@ fun ModFileBrowserScreen( filePendingDeletion?.let { file -> val stillAllowed = isInsideRoot(file, browserRootDirectory) && file.isFile && !isProtectedModpackFile(file, browserRootDirectory) - AlertDialog( + GlassAlertDialog( onDismissRequest = { filePendingDeletion = null deleteFailed = false diff --git a/app/src/main/java/com/bepinex/android/ui/screens/ModpackDetailScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/ModpackDetailScreen.kt index 9a2acc5e..b6fcfa18 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/ModpackDetailScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/ModpackDetailScreen.kt @@ -60,7 +60,6 @@ import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.outlined.Category import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -77,6 +76,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -84,7 +84,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TriStateCheckbox import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -117,7 +116,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -128,6 +126,12 @@ import com.bepinex.android.R import com.bepinex.android.modpack.ModpackManager import com.bepinex.android.modpack.ModpackMeta import com.bepinex.android.modpack.ModpackMod +import com.bepinex.android.ui.theme.LocalLiquidGlass +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.GlassDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import java.io.File /** @@ -270,7 +274,7 @@ fun ModpackDetailScreen( } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( @@ -314,9 +318,10 @@ fun ModpackDetailScreen( windowInsets = WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + modifier = Modifier.glassSurface( + RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp) + ), + colors = glassTopBarColors() ) } ) { padding -> @@ -460,10 +465,12 @@ fun ModpackDetailScreen( if (mods.isEmpty()) { item { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ) ) { Column( @@ -485,7 +492,7 @@ fun ModpackDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(16.dp)) - Button(onClick = onAddMod) { + com.bepinex.android.ui.liquid.LiquidButton(onClick = onAddMod) { Icon( imageVector = Icons.Filled.Add, contentDescription = null @@ -499,10 +506,12 @@ fun ModpackDetailScreen( } else if (filteredMods.isEmpty() && filteredConfigs.isEmpty()) { item { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ) ) { Text( @@ -547,10 +556,12 @@ fun ModpackDetailScreen( contentType = { "config" } ) { cfg -> Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ), onClick = { onOpenConfig(cfg) } ) { @@ -617,8 +628,17 @@ fun ModpackDetailScreen( onClick = { scanScope.launch { listState.animateScrollToItem(0) } }, - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + modifier = Modifier.glassSurface(RoundedCornerShape(50)), + containerColor = glassContainerColor( + MaterialTheme.colorScheme.primaryContainer + ), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = if (LocalLiquidGlass.current) 0.dp else 6.dp, + pressedElevation = if (LocalLiquidGlass.current) 0.dp else 6.dp, + focusedElevation = if (LocalLiquidGlass.current) 0.dp else 6.dp, + hoveredElevation = if (LocalLiquidGlass.current) 0.dp else 8.dp + ) ) { Icon( imageVector = Icons.Filled.KeyboardArrowUp, @@ -631,7 +651,7 @@ fun ModpackDetailScreen( } modPendingDelete?.let { mod -> - AlertDialog( + GlassAlertDialog( onDismissRequest = { modPendingDelete = null }, title = { Text(stringResource(R.string.mod_file_browser_delete_title)) }, text = { @@ -957,7 +977,7 @@ private fun RenameDllDialog( var name by remember(mod.relativePath) { mutableStateOf(mod.displayName) } val trimmed = name.trim() - AlertDialog( + GlassAlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.modpack_mod_rename)) }, text = { @@ -1015,7 +1035,7 @@ private fun SetModCategoryDialog( existingCategories.filter { it != mod.category } } - AlertDialog( + GlassAlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.modpack_mod_category_set)) }, text = { @@ -1131,10 +1151,12 @@ private fun ModItemCard( } Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ) ) { Column(modifier = Modifier.fillMaxWidth()) { @@ -1192,7 +1214,7 @@ private fun ModItemCard( ) } Spacer(Modifier.width(8.dp)) - Switch( + com.bepinex.android.ui.liquid.LiquidToggle( checked = mod.enabled, onCheckedChange = onToggle, modifier = Modifier.semantics { @@ -1260,10 +1282,13 @@ private fun ModSearchBar( val keyboardController = LocalSoftwareKeyboardController.current var focused by remember { mutableStateOf(false) } + val searchShape = RoundedCornerShape(24.dp) Surface( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceVariant, + modifier = modifier + .fillMaxWidth() + .glassSurface(searchShape), + shape = searchShape, + color = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant), border = BorderStroke( width = 1.dp, color = if (focused) { @@ -1337,10 +1362,12 @@ private fun ModSearchBar( @Composable private fun BrowseModFilesCard(onClick: () -> Unit) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer + containerColor = glassContainerColor(MaterialTheme.colorScheme.primaryContainer) ), onClick = onClick ) { @@ -1467,7 +1494,7 @@ private fun DownloadDllScanDialog( onImport: () -> Unit, onSkip: () -> Unit ) { - AlertDialog( + GlassAlertDialog( onDismissRequest = onSkip, title = { Text(stringResource(R.string.modpack_scan_dlls_title)) }, text = { @@ -1534,7 +1561,7 @@ private fun ImportChooserFrame( footer: @Composable () -> Unit, content: @Composable ColumnScope.() -> Unit ) { - Dialog( + GlassDialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { @@ -1543,9 +1570,10 @@ private fun ImportChooserFrame( .fillMaxWidth() .padding(horizontal = 16.dp) .fillMaxHeight(0.88f) - .navigationBarsPadding(), + .navigationBarsPadding() + .glassSurface(RoundedCornerShape(28.dp)), shape = RoundedCornerShape(28.dp), - color = MaterialTheme.colorScheme.surface, + color = glassContainerColor(MaterialTheme.colorScheme.surface), tonalElevation = 3.dp ) { Column { @@ -1776,14 +1804,12 @@ private fun ImportModsFromModpackDialog( ) } Spacer(Modifier.width(4.dp)) - Button( + com.bepinex.android.ui.liquid.LiquidButton( onClick = onImport, enabled = totalSelected > 0, modifier = Modifier .width(96.dp) - .height(36.dp), - shape = RoundedCornerShape(12.dp), - contentPadding = PaddingValues(horizontal = 8.dp) + .height(36.dp) ) { Text( text = stringResource(R.string.modpack_import_confirm_count, totalSelected), diff --git a/app/src/main/java/com/bepinex/android/ui/screens/ModpackListScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/ModpackListScreen.kt index 6f0dddec..7cba538b 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/ModpackListScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/ModpackListScreen.kt @@ -40,6 +40,10 @@ import kotlinx.coroutines.withContext import com.bepinex.android.modpack.ModpackManager import com.bepinex.android.modpack.ModpackMeta import com.bepinex.android.shortcut.ModpackShortcutHelper +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import java.io.File @OptIn(ExperimentalMaterial3Api::class) @@ -127,7 +131,7 @@ fun ModpackListScreen( } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( @@ -149,9 +153,10 @@ fun ModpackListScreen( windowInsets = WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + modifier = Modifier.glassSurface( + RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp) + ), + colors = glassTopBarColors() ) }, floatingActionButton = { @@ -260,7 +265,7 @@ fun ModpackListScreen( // Delete dialog showDeleteDialog?.let { name -> - AlertDialog( + GlassAlertDialog( onDismissRequest = { showDeleteDialog = null }, title = { Text(stringResource(R.string.modpack_confirm_delete_title)) }, text = { Text(stringResource(R.string.modpack_confirm_delete_msg, name)) }, @@ -342,15 +347,19 @@ private fun VanillaCard( modifier: Modifier = Modifier ) { Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), onClick = onSelect, colors = CardDefaults.cardColors( - containerColor = if (isActive) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } + containerColor = glassContainerColor( + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) ) ) { Row( @@ -425,15 +434,19 @@ private fun ModpackCard( } Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), onClick = onOpen, colors = CardDefaults.cardColors( - containerColor = if (isActive) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } + containerColor = glassContainerColor( + if (isActive) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + ) ) ) { Column(modifier = Modifier.fillMaxWidth()) { @@ -608,7 +621,7 @@ private fun EditModpackDialog( } } - AlertDialog( + GlassAlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.modpack_edit)) }, text = { @@ -723,7 +736,7 @@ private fun EditModpackDialog( ) if (showPermissionDialog) { - AlertDialog( + GlassAlertDialog( onDismissRequest = { showPermissionDialog = false }, title = { Text(stringResource(R.string.shortcut_permission_title)) }, text = { Text(stringResource(R.string.shortcut_permission_guide)) }, @@ -821,10 +834,12 @@ private fun ModpackActionsFab( @Composable private fun EmptyModpacksCard() { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant) ) ) { Column( @@ -864,7 +879,7 @@ private fun DownloadModpackScanDialog( onImport: () -> Unit, onSkip: () -> Unit ) { - AlertDialog( + GlassAlertDialog( onDismissRequest = onSkip, title = { Text(stringResource(R.string.modpack_scan_downloads_title)) }, text = { @@ -956,7 +971,7 @@ fun CreateModpackDialog( } } - AlertDialog( + GlassAlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.modpack_create)) }, text = { @@ -1062,7 +1077,7 @@ fun CreateModpackDialog( ) if (showPermissionDialog) { - AlertDialog( + GlassAlertDialog( onDismissRequest = { showPermissionDialog = false }, title = { Text(stringResource(R.string.shortcut_permission_title)) }, text = { Text(stringResource(R.string.shortcut_permission_guide)) }, diff --git a/app/src/main/java/com/bepinex/android/ui/screens/SaveImportScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/SaveImportScreen.kt index 71191dd3..dbecf80c 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/SaveImportScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/SaveImportScreen.kt @@ -42,7 +42,6 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Restore -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -57,7 +56,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -75,6 +73,10 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.bepinex.android.R import com.bepinex.android.save.SaveDataManager +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -231,16 +233,17 @@ fun SaveImportScreen(packageName: String, onBack: () -> Unit) { BackHandler(enabled = busy) {} Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Text(stringResource(R.string.settings_section_saves)) }, navigationIcon = { IconButton(onClick = onBack, enabled = !busy) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) } }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface) + colors = glassTopBarColors() ) } ) { padding -> @@ -399,7 +402,7 @@ fun SaveImportScreen(packageName: String, onBack: () -> Unit) { SaveConfirmation.RESTORE_STAGED -> stringResource(R.string.save_restore_backup_confirm) else -> stringResource(R.string.mod_file_browser_delete_message, title) } - AlertDialog( + GlassAlertDialog( onDismissRequest = { confirmation = null }, title = { Text(title) }, text = { Text(message) }, @@ -481,9 +484,9 @@ private fun GameSaveStatusCard( val ready = status is SaveDataManager.SaveStatus.FOUND Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().glassSurface(RoundedCornerShape(20.dp)), shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant)) ) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { @@ -540,9 +543,9 @@ private fun SavePrerequisiteCard( Toast.makeText(context, R.string.save_permission_denied, Toast.LENGTH_SHORT).show() } } - }, + }.glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.primaryContainer)) ) { Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Outlined.Folder, null, Modifier.size(26.dp), tint = MaterialTheme.colorScheme.primary) @@ -567,11 +570,13 @@ private fun PrimarySaveActionCard( onClick: () -> Unit ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().glassSurface(RoundedCornerShape(20.dp)), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( - containerColor = if (enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f) - else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + containerColor = glassContainerColor( + if (enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + ) ) ) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { @@ -589,7 +594,7 @@ private fun PrimarySaveActionCard( Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - Button(onClick, Modifier.fillMaxWidth(), enabled = enabled) { + com.bepinex.android.ui.liquid.LiquidButton(onClick, Modifier.fillMaxWidth(), enabled = enabled) { if (operating) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary) Spacer(Modifier.width(8.dp)) @@ -603,8 +608,11 @@ private fun PrimarySaveActionCard( @Composable private fun ExpandableSectionHeader(title: String, expanded: Boolean, enabled: Boolean, onClick: () -> Unit) { Card( - modifier = Modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .glassSurface(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.surface)) ) { Row( Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), @@ -653,8 +661,9 @@ private fun SaveFileSummaryCard(backupContents: List, restoreContents: L Card( Modifier .fillMaxWidth() - .animateContentSize(animationSpec = tween(180)), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + .animateContentSize(animationSpec = tween(180)) + .glassSurface(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant)) ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { FileSummary(stringResource(R.string.save_g2l_contents), backupContents) @@ -690,8 +699,8 @@ private fun FileSummary(title: String, contents: List) { @Composable private fun SavePathCard(packageName: String) { Card( - Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + Modifier.fillMaxWidth().glassSurface(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant)) ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -719,8 +728,8 @@ private fun SavePathCard(packageName: String) { @Composable private fun ManualGuideCard(packageName: String, useSaf: Boolean, useShizuku: Boolean) { Card( - Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + Modifier.fillMaxWidth().glassSurface(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant)) ) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { when { @@ -764,8 +773,8 @@ private fun CommandText(text: String) { clipboard.setPrimaryClip(ClipData.newPlainText("ADB command", text)) Toast.makeText(context, R.string.save_command_copied, Toast.LENGTH_SHORT).show() } - }, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + }.glassSurface(RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = glassContainerColor(MaterialTheme.colorScheme.surface)) ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/bepinex/android/ui/screens/SettingsScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/SettingsScreen.kt index 6404577e..18493279 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/SettingsScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.BlurOn import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.ColorLens import androidx.compose.material.icons.outlined.ContentCopy @@ -31,13 +32,14 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Animation +import androidx.compose.material.icons.outlined.Memory import androidx.compose.material.icons.outlined.Terminal import androidx.compose.material.icons.outlined.Widgets -import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold @@ -47,7 +49,6 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -56,10 +57,18 @@ 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.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bepinex.android.R import com.bepinex.android.settings.AppSettings +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.LiquidGlassBarShape +import com.bepinex.android.ui.theme.LiquidGlassPanelShape +import com.bepinex.android.ui.theme.LocalLiquidGlass +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -77,18 +86,22 @@ fun SettingsScreen( themeMode: AppSettings.ThemeMode, language: AppSettings.Language, dynamicColor: Boolean, + liquidGlass: Boolean, animationDisabled: Boolean, floatingLogInGame: Boolean, floatingModMenu: Boolean, useUnstrippedLibUnity: Boolean, + disableTieredCompilation: Boolean, onNavigateToAbout: () -> Unit, onThemeChanged: (AppSettings.ThemeMode) -> Unit, onLanguageChanged: (AppSettings.Language) -> Unit, onDynamicColorChanged: (Boolean) -> Unit, + onLiquidGlassChanged: (Boolean) -> Unit, onAnimationDisabledChanged: (Boolean) -> Unit, onFloatingLogInGameChanged: (Boolean) -> Unit, onFloatingModMenuChanged: (Boolean) -> Unit, onUseUnstrippedLibUnityChanged: (Boolean) -> Unit, + onDisableTieredCompilationChanged: (Boolean) -> Unit, onClearBepInEx: () -> Unit, onClearDotnet: () -> Unit, onClearLibUnity: () -> Unit, @@ -120,23 +133,33 @@ fun SettingsScreen( } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), contentWindowInsets = WindowInsets(0, 0, 0, 0), snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { TopAppBar( + modifier = Modifier.glassSurface(LiquidGlassBarShape), title = { Text(stringResource(R.string.settings_title)) }, windowInsets = WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top ), - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding), + modifier = Modifier + .fillMaxSize() + .padding(padding) + .then( + if (LocalLiquidGlass.current) { + Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .glassSurface(LiquidGlassPanelShape) + } else { + Modifier + } + ), contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp) ) { item { SettingsSectionHeader(stringResource(R.string.settings_section_appearance)) } @@ -163,7 +186,7 @@ fun SettingsScreen( summary = stringResource(R.string.settings_dynamic_color_desc), icon = { Icon(Icons.Outlined.ColorLens, contentDescription = null) }, trailing = { - Switch( + com.bepinex.android.ui.liquid.LiquidToggle( checked = dynamicColor, onCheckedChange = { checked -> onDynamicColorChanged(checked) } ) @@ -172,13 +195,27 @@ fun SettingsScreen( ) } } + item { + SettingListItem( + title = stringResource(R.string.settings_liquid_glass), + summary = stringResource(R.string.settings_liquid_glass_desc), + icon = { Icon(Icons.Outlined.BlurOn, contentDescription = null) }, + trailing = { + com.bepinex.android.ui.liquid.LiquidToggle( + checked = liquidGlass, + onCheckedChange = onLiquidGlassChanged + ) + }, + onClick = { onLiquidGlassChanged(!liquidGlass) } + ) + } item { SettingListItem( title = stringResource(R.string.settings_animation_disabled), summary = stringResource(R.string.settings_animation_disabled_desc), icon = { Icon(Icons.Outlined.Animation, contentDescription = null) }, trailing = { - Switch( + com.bepinex.android.ui.liquid.LiquidToggle( checked = animationDisabled, onCheckedChange = { checked -> pendingAnimationValue = checked @@ -200,7 +237,7 @@ fun SettingsScreen( summary = stringResource(R.string.settings_floating_log_desc), icon = { Icon(Icons.Outlined.Terminal, contentDescription = null) }, trailing = { - Switch( + com.bepinex.android.ui.liquid.LiquidToggle( checked = floatingLogInGame, onCheckedChange = { checked -> onFloatingLogInGameChanged(checked) } ) @@ -214,7 +251,7 @@ fun SettingsScreen( summary = stringResource(R.string.settings_floating_mod_menu_desc), icon = { Icon(Icons.Outlined.Widgets, contentDescription = null) }, trailing = { - Switch( + com.bepinex.android.ui.liquid.LiquidToggle( checked = floatingModMenu, onCheckedChange = { checked -> onFloatingModMenuChanged(checked) } ) @@ -228,7 +265,7 @@ fun SettingsScreen( summary = stringResource(R.string.settings_unstripped_libunity_desc), icon = { Icon(Icons.Outlined.Code, contentDescription = null) }, trailing = { - Switch( + com.bepinex.android.ui.liquid.LiquidToggle( checked = useUnstrippedLibUnity, onCheckedChange = { checked -> onUseUnstrippedLibUnityChanged(checked) } ) @@ -236,6 +273,20 @@ fun SettingsScreen( onClick = { onUseUnstrippedLibUnityChanged(!useUnstrippedLibUnity) } ) } + item { + SettingListItem( + title = stringResource(R.string.settings_disable_tiered_jit), + summary = stringResource(R.string.settings_disable_tiered_jit_desc), + icon = { Icon(Icons.Outlined.Memory, contentDescription = null) }, + trailing = { + com.bepinex.android.ui.liquid.LiquidToggle( + checked = disableTieredCompilation, + onCheckedChange = { checked -> onDisableTieredCompilationChanged(checked) } + ) + }, + onClick = { onDisableTieredCompilationChanged(!disableTieredCompilation) } + ) + } item { SettingsSectionHeader(stringResource(R.string.settings_section_maintenance)) } item { @@ -345,7 +396,7 @@ fun SettingsScreen( } if (showAnimationRestartDialog) { - AlertDialog( + GlassAlertDialog( onDismissRequest = { showAnimationRestartDialog = false }, title = { Text(stringResource(R.string.animation_restart_title)) }, text = { Text(stringResource(R.string.animation_restart_message)) }, @@ -371,7 +422,7 @@ fun SettingsScreen( maintenanceAction?.let { action -> val dialog = maintenanceDialog(action) - AlertDialog( + GlassAlertDialog( onDismissRequest = { maintenanceAction = null }, title = { Text(stringResource(dialog.title)) }, text = { Text(stringResource(dialog.message)) }, @@ -469,6 +520,11 @@ private fun SettingListItem( leadingContent = icon, trailingContent = trailing, + colors = if (LocalLiquidGlass.current) { + ListItemDefaults.colors(containerColor = Color.Transparent) + } else { + ListItemDefaults.colors() + }, modifier = Modifier.fillMaxWidth().clickable(onClick = onClick) ) } @@ -496,7 +552,7 @@ private fun SelectionDialog( onDismiss: () -> Unit, onSelected: (T) -> Unit ) { - AlertDialog( + GlassAlertDialog( onDismissRequest = onDismiss, title = { Text(title) }, text = { diff --git a/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt b/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt index ad10a07c..dc193d41 100644 --- a/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt +++ b/app/src/main/java/com/bepinex/android/ui/screens/TextViewerScreen.kt @@ -2,6 +2,7 @@ package com.bepinex.android.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Save @@ -17,6 +18,10 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bepinex.android.R +import com.bepinex.android.ui.theme.GlassAlertDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface +import com.bepinex.android.ui.theme.glassTopBarColors import com.bepinex.android.ui.components.CodeEditor import com.bepinex.android.ui.components.SyntaxLanguage import com.bepinex.android.ui.components.syntaxLanguageFor @@ -64,9 +69,10 @@ fun TextViewerScreen( BackHandler(enabled = !showDiscardDialog, onBack = ::requestDismiss) Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Column { Text( @@ -123,9 +129,7 @@ fun TextViewerScreen( Icon(Icons.Filled.Settings, stringResource(R.string.viewer_settings)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -164,7 +168,7 @@ fun TextViewerScreen( } if (showDiscardDialog) { - AlertDialog( + GlassAlertDialog( onDismissRequest = { showDiscardDialog = false }, title = { Text(stringResource(R.string.config_editor_discard_title)) }, text = { Text(stringResource(R.string.config_editor_discard_msg)) }, @@ -199,18 +203,17 @@ fun ViewerSettingsScreen( val previewValue = remember { TextFieldValue(ViewerPreviewSource) } Scaffold( - containerColor = MaterialTheme.colorScheme.background, + containerColor = glassContainerColor(MaterialTheme.colorScheme.background), topBar = { TopAppBar( + modifier = Modifier.glassSurface(RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp)), title = { Text(stringResource(R.string.viewer_settings)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.back)) } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) + colors = glassTopBarColors() ) } ) { padding -> @@ -222,9 +225,9 @@ fun ViewerSettingsScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Surface( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().glassSurface(MaterialTheme.shapes.medium), shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + color = glassContainerColor(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) ) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -240,7 +243,7 @@ fun ViewerSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = wordWrap, onCheckedChange = onWordWrapChange) + com.bepinex.android.ui.liquid.LiquidToggle(checked = wordWrap, onCheckedChange = onWordWrapChange) } HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) @@ -258,7 +261,7 @@ fun ViewerSettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = showLineNumbers, onCheckedChange = onLineNumbersChange) + com.bepinex.android.ui.liquid.LiquidToggle(checked = showLineNumbers, onCheckedChange = onLineNumbersChange) } } } diff --git a/app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt b/app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt new file mode 100644 index 00000000..874d0c6a --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt @@ -0,0 +1,322 @@ +package com.bepinex.android.ui.theme + +import android.graphics.drawable.ColorDrawable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.Density +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy + +/** + * True when the root [LiquidGlassHost] is showing the liquid-glass wallpaper. + * Surfaces opt in with [glassSurface] and [glassContainerColor]. + */ +val LocalLiquidGlass = staticCompositionLocalOf { false } + +/** Backdrop captured by [LiquidGlassHost], or null when the effect is off. */ +val LocalBackdrop = staticCompositionLocalOf { null } + +/** + * When [enabled], draws the wallpaper into a layer backdrop and places [content] + * as a sibling above it. Content must not sit inside [layerBackdrop], or glass + * would sample itself. No full-screen glass sheet is drawn. + */ +@Composable +fun LiquidGlassHost( + enabled: Boolean, + content: @Composable () -> Unit +) { + if (!enabled) { + CompositionLocalProvider( + LocalLiquidGlass provides false, + LocalBackdrop provides null + ) { + content() + } + return + } + val backdrop = rememberLayerBackdrop() + val contentColor = MaterialTheme.colorScheme.onBackground + CompositionLocalProvider( + LocalLiquidGlass provides true, + LocalBackdrop provides backdrop, + LocalContentColor provides contentColor + ) { + Box(Modifier.fillMaxSize()) { + LiquidGlassWallpaper(Modifier.layerBackdrop(backdrop).fillMaxSize()) + content() + } + } +} + +/** + * Platform dialog windows cannot sample the activity backdrop, so when + * [LocalLiquidGlass] is on this records its own wallpaper into a layer backdrop + * and draws [content] as a sibling. When glass is off, this is a plain [Dialog]. + */ +@Composable +fun GlassDialog( + onDismissRequest: () -> Unit, + properties: DialogProperties = DialogProperties(), + content: @Composable () -> Unit +) { + if (!LocalLiquidGlass.current) { + Dialog(onDismissRequest, properties, content) + return + } + Dialog(onDismissRequest, properties) { + val wallpaper = rememberGlassWallpaper() + val backdrop = remember(wallpaper) { DialogWallpaperBackdrop(wallpaper) } + val view = LocalView.current + SideEffect { + val window = (view.parent as? DialogWindowProvider)?.window ?: return@SideEffect + window.setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT)) + window.decorView.setBackgroundColor(android.graphics.Color.TRANSPARENT) + window.setDimAmount(0.45f) + view.setBackgroundColor(android.graphics.Color.TRANSPARENT) + } + CompositionLocalProvider( + LocalBackdrop provides backdrop, + LocalLiquidGlass provides true + ) { + content() + } + } +} + +/** + * Alert panel on [GlassDialog]. Glass replaces [containerColor]; when glass is + * off, [containerColor] is forwarded only if the caller specified one. + */ +@Composable +fun GlassAlertDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + modifier: Modifier = Modifier, + dismissButton: @Composable (() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + title: @Composable (() -> Unit)? = null, + text: @Composable (() -> Unit)? = null, + shape: Shape = RoundedCornerShape(28.dp), + containerColor: Color = Color.Unspecified, + properties: DialogProperties = DialogProperties() +) { + if (!LocalLiquidGlass.current) { + if (containerColor.isSpecified) { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = confirmButton, + modifier = modifier, + dismissButton = dismissButton, + icon = icon, + title = title, + text = text, + shape = shape, + containerColor = containerColor, + properties = properties + ) + } else { + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = confirmButton, + modifier = modifier, + dismissButton = dismissButton, + icon = icon, + title = title, + text = text, + shape = shape, + properties = properties + ) + } + return + } + GlassDialog(onDismissRequest, properties) { + Column( + modifier = Modifier + .then(modifier) + .glassSurface(shape) + .widthIn(max = 560.dp) + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + icon?.invoke() + if (title != null) { + ProvideTextStyle(MaterialTheme.typography.titleLarge) { + title() + } + } + if (text != null) { + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + text() + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + dismissButton?.invoke() + confirmButton() + } + } + } +} + +@Composable +fun Modifier.glassSurface(shape: Shape = RoundedCornerShape(20.dp)): Modifier { + val backdrop = LocalBackdrop.current ?: return this + val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f + return liquidGlass(backdrop, shape, dark) +} + +@Composable +fun glassContainerColor(solid: Color): Color = + if (LocalLiquidGlass.current) Color.Transparent else solid + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun glassTopBarColors(): TopAppBarColors { + val scheme = MaterialTheme.colorScheme + return TopAppBarDefaults.topAppBarColors( + containerColor = glassContainerColor(scheme.surface), + scrolledContainerColor = glassContainerColor(scheme.surface), + navigationIconContentColor = scheme.onSurface, + titleContentColor = scheme.onSurface, + actionIconContentColor = scheme.onSurface + ) +} + +fun Modifier.liquidGlass(backdrop: Backdrop, shape: Shape, dark: Boolean): Modifier { + return this + .clip(shape) + .drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(16f.dp.toPx(), 32f.dp.toPx(), chromaticAberration = true) + }, + onDrawSurface = { + drawRect( + if (dark) Color.Black.copy(alpha = 0.34f) else Color.White.copy(alpha = 0.46f) + ) + } + ) +} + +@Immutable +private data class GlassWallpaper( + val background: Color, + val primary: Color, + val secondary: Color, + val tertiary: Color, +) + +@Composable +private fun rememberGlassWallpaper(): GlassWallpaper { + val scheme = MaterialTheme.colorScheme + return GlassWallpaper( + background = scheme.background, + primary = scheme.primary, + secondary = scheme.secondary, + tertiary = scheme.tertiary, + ) +} + +@Composable +private fun LiquidGlassWallpaper(modifier: Modifier = Modifier) { + val wallpaper = rememberGlassWallpaper() + Canvas(modifier) { drawThemedWallpaper(wallpaper) } +} + +private fun Color.tintOn(base: Color, alpha: Float): Color = copy(alpha = alpha).compositeOver(base) + +private fun DrawScope.drawThemedWallpaper(wallpaper: GlassWallpaper) { + val base = wallpaper.background + drawRect(base) + drawRect( + Brush.linearGradient( + listOf( + base, + wallpaper.primary.tintOn(base, 0.22f), + wallpaper.secondary.tintOn(base, 0.16f), + ) + ) + ) + drawCircle( + color = wallpaper.primary.tintOn(base, 0.28f), + radius = size.minDimension * 0.42f, + center = Offset(size.width * 0.12f, size.height * 0.18f) + ) + drawCircle( + color = wallpaper.secondary.tintOn(base, 0.22f), + radius = size.minDimension * 0.48f, + center = Offset(size.width * 0.92f, size.height * 0.34f) + ) + drawCircle( + color = wallpaper.tertiary.tintOn(base, 0.18f), + radius = size.minDimension * 0.32f, + center = Offset(size.width * 0.5f, size.height * 0.88f) + ) +} + +/** Draws the wallpaper inside the glass panel so dialog blur cannot spill past the window. */ +private class DialogWallpaperBackdrop(val wallpaper: GlassWallpaper) : Backdrop { + override val isCoordinatesDependent: Boolean = false + + override fun DrawScope.drawBackdrop( + density: Density, + coordinates: LayoutCoordinates?, + layerBlock: (GraphicsLayerScope.() -> Unit)? + ) { + drawThemedWallpaper(wallpaper) + } +} + +val LiquidGlassPanelShape = RoundedCornerShape(28.dp) +val LiquidGlassBarShape = RoundedCornerShape(bottomStart = 22.dp, bottomEnd = 22.dp) diff --git a/app/src/main/java/com/bepinex/android/update/UpdateDialogs.kt b/app/src/main/java/com/bepinex/android/update/UpdateDialogs.kt index cca31b61..c9adfbea 100644 --- a/app/src/main/java/com/bepinex/android/update/UpdateDialogs.kt +++ b/app/src/main/java/com/bepinex/android/update/UpdateDialogs.kt @@ -30,9 +30,11 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.bepinex.android.R +import com.bepinex.android.ui.theme.GlassDialog +import com.bepinex.android.ui.theme.glassContainerColor +import com.bepinex.android.ui.theme.glassSurface private const val LONG_TOKEN_BREAK = 24 @@ -118,15 +120,16 @@ private fun ScrollableNoticeDialog( val configuration = LocalConfiguration.current val maxHeight = (configuration.screenHeightDp * 0.85f).dp val maxWidth = (configuration.screenWidthDp - 48).dp - Dialog(onDismissRequest = onDismissRequest, properties = properties) { + GlassDialog(onDismissRequest = onDismissRequest, properties = properties) { Card( modifier = Modifier .widthIn(max = maxWidth) .fillMaxWidth() - .heightIn(max = maxHeight), + .heightIn(max = maxHeight) + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface) ) ) { Column( @@ -332,14 +335,15 @@ fun CrashDialog( val maxLogHeight = (LocalConfiguration.current.screenHeightDp * 0.42f).dp val logScroll = rememberScrollState() - Dialog(onDismissRequest = onDismiss) { + GlassDialog(onDismissRequest = onDismiss) { Card( modifier = Modifier .fillMaxWidth() - .heightIn(max = maxDialogHeight), + .heightIn(max = maxDialogHeight) + .glassSurface(RoundedCornerShape(16.dp)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface) ) ) { Column( diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9b05c87c..d813efec 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -292,6 +292,8 @@ 语言 莫奈取色 基于系统主题色的配色(该功能仅对 Android 12 以上的设备有效)。 + 液态玻璃 + 整个启动器界面使用液态玻璃主题。折射效果需要 Android 13 及以上。 关闭动画 关闭动画可以提升一部分性能。 重启请求 @@ -465,6 +467,8 @@ 最小化模组菜单 使用未裁剪 libunity 使用未裁切的 libunity.so 已启用完整调用。 + 关闭分层编译 + 仅在大整合包于 libclrjit 内崩溃时打开。打开后,已加载的模组可能不会在游戏里显示内容。 启动失败 请等待 — BepInEx 正在解压中 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f1d35e4..7b26c227 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,6 +307,8 @@ Language Monet Color Color scheme based on the system accent color (Android 12+ only). + Liquid glass + Use a liquid glass surface across the whole launcher UI. Refraction requires Android 13 or newer. Disable Animations Disabling animations can improve performance on some devices. Restart Required @@ -488,6 +490,8 @@ Minimize mod menu Use Unstripped libunity Use unstripped libunity.so with full icalls. + Disable tiered compilation + Turn this on only if a large modpack aborts inside libclrjit. While it is on, loaded mods may not show their in-game content. Launch failed diff --git a/build.gradle.kts b/build.gradle.kts index 1b4481ac..2213107c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("org.jetbrains.kotlin.android") version "2.2.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false }