From ee330dcdd271423bb99da49f300205c231cba015 Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Tue, 29 Sep 2026 15:21:11 +0800 Subject: [PATCH 1/3] Add an optional liquid glass theme for the launcher UI. The setting applies Kyant backdrop glass to screens, dialogs, and controls, and follows the current theme colors. --- app/build.gradle.kts | 6 +- .../java/com/bepinex/android/MainActivity.kt | 10 + .../bepinex/android/settings/AppSettings.kt | 8 + .../ui/components/ConfigEditorDialog.kt | 13 +- .../com/bepinex/android/ui/liquid/Capsule.kt | 6 + .../android/ui/liquid/DampedDragAnimation.kt | 138 ++++++++ .../android/ui/liquid/DragGestureInspector.kt | 86 +++++ .../android/ui/liquid/InteractiveHighlight.kt | 75 +++++ .../android/ui/liquid/LiquidBottomTab.kt | 50 +++ .../android/ui/liquid/LiquidBottomTabs.kt | 289 ++++++++++++++++ .../bepinex/android/ui/liquid/LiquidButton.kt | 143 ++++++++ .../bepinex/android/ui/liquid/LiquidSlider.kt | 243 ++++++++++++++ .../bepinex/android/ui/liquid/LiquidToggle.kt | 224 +++++++++++++ .../android/ui/navigation/BepInExNavHost.kt | 78 ++++- .../android/ui/onboarding/CoachMarkOverlay.kt | 7 +- .../android/ui/onboarding/OnboardingHost.kt | 15 +- .../bepinex/android/ui/screens/AboutScreen.kt | 29 +- .../android/ui/screens/CreditsScreen.kt | 13 +- .../bepinex/android/ui/screens/GameScreen.kt | 87 +++-- .../android/ui/screens/LogViewerScreen.kt | 32 +- .../ui/screens/MarketModDetailScreen.kt | 55 ++-- .../android/ui/screens/MarketScreen.kt | 159 ++++++--- .../ui/screens/ModFileBrowserScreen.kt | 27 +- .../android/ui/screens/ModpackDetailScreen.kt | 96 ++++-- .../android/ui/screens/ModpackListScreen.kt | 63 ++-- .../android/ui/screens/SaveImportScreen.kt | 55 ++-- .../android/ui/screens/SettingsScreen.kt | 69 +++- .../android/ui/screens/TextViewerScreen.kt | 29 +- .../bepinex/android/ui/theme/LiquidGlass.kt | 308 ++++++++++++++++++ .../bepinex/android/update/UpdateDialogs.kt | 18 +- app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + build.gradle.kts | 4 +- 33 files changed, 2151 insertions(+), 288 deletions(-) create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/Capsule.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/DampedDragAnimation.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/DragGestureInspector.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/InteractiveHighlight.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTab.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/LiquidButton.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/LiquidSlider.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/liquid/LiquidToggle.kt create mode 100644 app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49249e37..97ea7b85 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,7 +95,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 +122,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/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/settings/AppSettings.kt b/app/src/main/java/com/bepinex/android/settings/AppSettings.kt index 73db7557..078ae7c7 100644 --- a/app/src/main/java/com/bepinex/android/settings/AppSettings.kt +++ b/app/src/main/java/com/bepinex/android/settings/AppSettings.kt @@ -19,6 +19,7 @@ object AppSettings { private const val KEY_ACTIVE_MODPACK_PREFIX = "active_modpack_" private const val KEY_USE_UNSTRIPPED_LIBUNITY = "use_unstripped_libunity" 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" @@ -185,6 +186,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..cadd0c9a --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt @@ -0,0 +1,289 @@ +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.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +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.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 isLightTheme = !isSystemInDarkTheme() + val accentColor = + if (isLightTheme) Color(0xFF0088FF) + else Color(0xFF0091FF) + val containerColor = + if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) + else Color(0xFF121212).copy(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, + content = 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..07ebf4d2 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 = { @@ -795,6 +805,7 @@ fun BepInExNavHost( themeMode = themeMode, language = language, dynamicColor = dynamicColor, + liquidGlass = liquidGlass, animationDisabled = animationDisabledSetting, floatingLogInGame = floatingLogInGame, floatingModMenu = floatingModMenu, @@ -807,6 +818,7 @@ fun BepInExNavHost( dynamicColor = enabled onDynamicColorChanged(enabled) }, + onLiquidGlassChanged = onLiquidGlassChanged, onAnimationDisabledChanged = { disabled -> AppSettings.setAnimationDisabled(settingsContext, disabled) animationDisabledSetting = disabled @@ -1198,7 +1210,7 @@ fun BepInExNavHost( } } exportProgress?.let { progress -> - AlertDialog( + GlassAlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.modpack_export)) }, text = { @@ -1232,7 +1244,7 @@ fun BepInExNavHost( ) } if (importJob?.isActive == true) { - AlertDialog( + GlassAlertDialog( onDismissRequest = {}, title = { Text(stringResource(R.string.modpack_import)) }, text = { @@ -1249,7 +1261,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 +1287,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 +1330,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 +1386,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..27d618ca 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 @@ -33,11 +34,11 @@ import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Animation 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 +48,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 +56,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,6 +85,7 @@ fun SettingsScreen( themeMode: AppSettings.ThemeMode, language: AppSettings.Language, dynamicColor: Boolean, + liquidGlass: Boolean, animationDisabled: Boolean, floatingLogInGame: Boolean, floatingModMenu: Boolean, @@ -85,6 +94,7 @@ fun SettingsScreen( onThemeChanged: (AppSettings.ThemeMode) -> Unit, onLanguageChanged: (AppSettings.Language) -> Unit, onDynamicColorChanged: (Boolean) -> Unit, + onLiquidGlassChanged: (Boolean) -> Unit, onAnimationDisabledChanged: (Boolean) -> Unit, onFloatingLogInGameChanged: (Boolean) -> Unit, onFloatingModMenuChanged: (Boolean) -> Unit, @@ -120,23 +130,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 +183,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 +192,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 +234,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 +248,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 +262,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) } ) @@ -345,7 +379,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 +405,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 +503,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 +535,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..67acab56 --- /dev/null +++ b/app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt @@ -0,0 +1,308 @@ +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.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.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() + CompositionLocalProvider( + LocalLiquidGlass provides true, + LocalBackdrop provides backdrop + ) { + 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 = TopAppBarDefaults.topAppBarColors( + containerColor = glassContainerColor(MaterialTheme.colorScheme.surface), + scrolledContainerColor = glassContainerColor(MaterialTheme.colorScheme.surface) +) + +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 DrawScope.drawThemedWallpaper(wallpaper: GlassWallpaper) { + drawRect( + Brush.linearGradient( + listOf( + wallpaper.background, + wallpaper.primary.copy(alpha = 0.22f), + wallpaper.secondary.copy(alpha = 0.16f), + ) + ) + ) + drawCircle( + color = wallpaper.primary.copy(alpha = 0.28f), + radius = size.minDimension * 0.42f, + center = Offset(size.width * 0.12f, size.height * 0.18f) + ) + drawCircle( + color = wallpaper.secondary.copy(alpha = 0.22f), + radius = size.minDimension * 0.48f, + center = Offset(size.width * 0.92f, size.height * 0.34f) + ) + drawCircle( + color = wallpaper.tertiary.copy(alpha = 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..3743f126 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 及以上。 关闭动画 关闭动画可以提升一部分性能。 重启请求 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f1d35e4..3ddec15c 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 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 } From 56a023b13e07f320abad4919e3faa195e7d99129 Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Tue, 29 Sep 2026 16:04:02 +0800 Subject: [PATCH 2/3] Keep liquid glass colors in sync with the active theme. Dark mode paints an opaque themed wallpaper, text and the bottom bar follow the color scheme, and the broken lifecycle lint check is disabled so release builds can finish. --- app/build.gradle.kts | 8 +++++ .../android/ui/liquid/LiquidBottomTabs.kt | 24 +++++++------ .../bepinex/android/ui/theme/LiquidGlass.kt | 36 +++++++++++++------ 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97ea7b85..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 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 index cadd0c9a..c494c954 100644 --- a/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt +++ b/app/src/main/java/com/bepinex/android/ui/liquid/LiquidBottomTabs.kt @@ -3,8 +3,9 @@ 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.isSystemInDarkTheme 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 @@ -28,6 +29,7 @@ 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 @@ -65,13 +67,10 @@ fun LiquidBottomTabs( modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit ) { - val isLightTheme = !isSystemInDarkTheme() - val accentColor = - if (isLightTheme) Color(0xFF0088FF) - else Color(0xFF0091FF) - val containerColor = - if (isLightTheme) Color(0xFFFAFAFA).copy(0.4f) - else Color(0xFF121212).copy(0.4f) + 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() @@ -183,9 +182,12 @@ fun LiquidBottomTabs( .height(64f.dp) .fillMaxWidth() .padding(4f.dp), - verticalAlignment = Alignment.CenterVertically, - content = content - ) + verticalAlignment = Alignment.CenterVertically + ) { + CompositionLocalProvider(LocalContentColor provides scheme.onSurface) { + content() + } + } CompositionLocalProvider( LocalLiquidBottomTabScale provides { 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 index 67acab56..874d0c6a 100644 --- a/app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt +++ b/app/src/main/java/com/bepinex/android/ui/theme/LiquidGlass.kt @@ -13,6 +13,7 @@ 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 @@ -29,6 +30,7 @@ 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 @@ -78,9 +80,11 @@ fun LiquidGlassHost( return } val backdrop = rememberLayerBackdrop() + val contentColor = MaterialTheme.colorScheme.onBackground CompositionLocalProvider( LocalLiquidGlass provides true, - LocalBackdrop provides backdrop + LocalBackdrop provides backdrop, + LocalContentColor provides contentColor ) { Box(Modifier.fillMaxSize()) { LiquidGlassWallpaper(Modifier.layerBackdrop(backdrop).fillMaxSize()) @@ -215,10 +219,16 @@ fun glassContainerColor(solid: Color): Color = @OptIn(ExperimentalMaterial3Api::class) @Composable -fun glassTopBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors( - containerColor = glassContainerColor(MaterialTheme.colorScheme.surface), - scrolledContainerColor = glassContainerColor(MaterialTheme.colorScheme.surface) -) +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 @@ -264,28 +274,32 @@ private fun LiquidGlassWallpaper(modifier: Modifier = Modifier) { 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( - wallpaper.background, - wallpaper.primary.copy(alpha = 0.22f), - wallpaper.secondary.copy(alpha = 0.16f), + base, + wallpaper.primary.tintOn(base, 0.22f), + wallpaper.secondary.tintOn(base, 0.16f), ) ) ) drawCircle( - color = wallpaper.primary.copy(alpha = 0.28f), + 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.copy(alpha = 0.22f), + 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.copy(alpha = 0.18f), + color = wallpaper.tertiary.tintOn(base, 0.18f), radius = size.minDimension * 0.32f, center = Offset(size.width * 0.5f, size.height * 0.88f) ) From 84dd1af45db1c49988fe51aee25e3184a28ae3ed Mon Sep 17 00:00:00 2001 From: LibraHp_0928 <1941163264@qq.com> Date: Wed, 30 Sep 2026 14:21:02 +0800 Subject: [PATCH 3/3] Add a setting to disable tiered compilation. Keep DOTNET_TieredCompilation enabled by default so mod content still loads, and let users opt into the full JIT path when libclrjit aborts. --- app/src/main/cpp/fusion/include/fusion.h | 1 + app/src/main/cpp/fusion/src/dotnet.cpp | 19 +++++++++++-------- app/src/main/cpp/fusion/src/fusion.cpp | 2 ++ .../com/bepinex/android/BootstrapActivity.kt | 10 +++++++--- .../bepinex/android/fusion/FusionConfig.kt | 6 +++++- .../bepinex/android/settings/AppSettings.kt | 9 +++++++++ .../android/ui/navigation/BepInExNavHost.kt | 8 ++++++++ .../android/ui/screens/SettingsScreen.kt | 17 +++++++++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values/strings.xml | 2 ++ 10 files changed, 64 insertions(+), 12 deletions(-) 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/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 078ae7c7..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,6 +18,7 @@ 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" @@ -177,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 = 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 07ebf4d2..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 @@ -795,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)) } @@ -810,6 +813,7 @@ fun BepInExNavHost( floatingLogInGame = floatingLogInGame, floatingModMenu = floatingModMenu, useUnstrippedLibUnity = useUnstrippedLibUnity, + disableTieredCompilation = disableTieredCompilation, onNavigateToAbout = { navController.navigate(NavRoutes.ABOUT) }, onThemeChanged = onThemeChanged, onLanguageChanged = onLanguageChanged, @@ -835,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) }, 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 27d618ca..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 @@ -32,6 +32,7 @@ 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.ExperimentalMaterial3Api @@ -90,6 +91,7 @@ fun SettingsScreen( floatingLogInGame: Boolean, floatingModMenu: Boolean, useUnstrippedLibUnity: Boolean, + disableTieredCompilation: Boolean, onNavigateToAbout: () -> Unit, onThemeChanged: (AppSettings.ThemeMode) -> Unit, onLanguageChanged: (AppSettings.Language) -> Unit, @@ -99,6 +101,7 @@ fun SettingsScreen( onFloatingLogInGameChanged: (Boolean) -> Unit, onFloatingModMenuChanged: (Boolean) -> Unit, onUseUnstrippedLibUnityChanged: (Boolean) -> Unit, + onDisableTieredCompilationChanged: (Boolean) -> Unit, onClearBepInEx: () -> Unit, onClearDotnet: () -> Unit, onClearLibUnity: () -> Unit, @@ -270,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 { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3743f126..d813efec 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -467,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 3ddec15c..7b26c227 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -490,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