diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0e077a758..b63935a3f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -185,6 +185,9 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigationevent) + implementation(libs.miuix.ui) + implementation(libs.miuix.preference) + implementation(libs.miuix.icons) implementation(libs.androidx.room3.runtime) ksp(libs.androidx.room3.compiler) implementation(libs.kotlinx.serialization.cbor) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt index 0d6a728cf..2a47c8e32 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt @@ -24,7 +24,7 @@ val update1_0_0 = listOf( DesignSystem.Apple -> { EqualizerScreenPreviewApple() } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { EqualizerScreenPreviewMaterial() } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/BatteryView.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/BatteryView.kt index d57b14753..283c12ae8 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/BatteryView.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/BatteryView.kt @@ -43,6 +43,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.miuix.MiuixBatteryCard +import me.kavishdevar.librepods.presentation.components.miuix.MiuixDeviceImage import me.kavishdevar.librepods.devices.Battery import me.kavishdevar.librepods.devices.BatteryComponent import me.kavishdevar.librepods.devices.BatteryStatus @@ -54,6 +58,17 @@ fun BatteryView( primaryImageRes: Int, caseImageRes: Int ) { + if (LocalDesignSystem.current == DesignSystem.Miuix) { + // HyperOS shows the hardware above a three-column battery card rather than a + // battery reading per image, so the Miuix header is a different composition + // rather than a restyling of the one below. + Column { + MiuixDeviceImage(budsRes = primaryImageRes, caseRes = caseImageRes) + MiuixBatteryCard(batteryList = batteryList) + } + return + } + Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt index 865e6aad4..9336e3004 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt @@ -79,6 +79,7 @@ import me.kavishdevar.librepods.devices.NoiseControlMode import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.miuix.MiuixNoiseControlCard import me.kavishdevar.librepods.presentation.theme.sectionHeader import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt @@ -93,6 +94,19 @@ fun NoiseControlSettings( showLabels: Boolean = true ) { when (LocalDesignSystem.current) { + DesignSystem.Miuix -> { + // The value on the wire is 1-based and follows the declaration order of the enum. + val currentMode = NoiseControlMode.entries[ + (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex) + ] + MiuixNoiseControlCard( + showOffOption = showOffListeningMode, + currentMode = currentMode, + onModeChange = { mode -> + onNoiseControlModeChanged(NoiseControlMode.entries.indexOf(mode) + 1) + } + ) + } DesignSystem.Material -> { val options = buildList { if (showOffListeningMode) { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/miuix/MiuixHomeCards.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/miuix/MiuixHomeCards.kt new file mode 100644 index 000000000..c5c39d427 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/miuix/MiuixHomeCards.kt @@ -0,0 +1,292 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.presentation.components.miuix + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.devices.Battery +import me.kavishdevar.librepods.devices.BatteryComponent +import me.kavishdevar.librepods.devices.BatteryStatus +import me.kavishdevar.librepods.devices.NoiseControlMode +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.VerticalDivider +import androidx.compose.ui.graphics.Color +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * The unselected circle, measured off the headset page HyperOS ships. The Miuix palette + * has no equivalent token, so the values live here: #454545 dark, #E8E8E8 light. + */ +private val UnselectedCircleDark = Color(0xFF454545) +private val UnselectedCircleLight = Color(0xFFE8E8E8) + +/** Icon colour inside an unselected circle: onPrimary in dark, a dark grey in light. */ +private val UnselectedIconLight = Color(0xFF636363) + +/** The system page draws these at 160px on a 480dpi screen; Miuix has no token for it. */ +private val CircleSize = 53.dp + +/** Battery outline plus terminal, filled to the level. There is no icon resource for it. */ +@Composable +private fun BatteryGlyph(level: Int) { + val outline = MiuixTheme.colorScheme.onSurfaceVariantSummary + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(width = 24.dp, height = 13.dp) + .border(1.5.dp, outline, RoundedCornerShape(4.dp)) + .padding(2.dp) + ) { + if (level > 0) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(level.coerceIn(0, 100) / 100f) + .background(outline, RoundedCornerShape(1.5.dp)) + ) + } + } + Spacer(modifier = Modifier.width(1.5.dp)) + Box( + modifier = Modifier + .size(width = 2.dp, height = 5.dp) + .background(outline, RoundedCornerShape(1.dp)) + ) + } +} + +/** + * The header image. The HyperOS page puts the case under the title and above the battery + * card rather than mixing it into the battery row. This only displays; it has no interaction. + */ +@Composable +fun MiuixDeviceImage( + budsRes: Int, + caseRes: Int, + modifier: Modifier = Modifier +) { + // The system page uses a single composed "buds in the case" image; LibrePods only has + // the two separately, so they sit side by side: buds on the left, case on the right. + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(budsRes), + contentDescription = null, + modifier = Modifier.size(150.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Image( + painter = painterResource(caseRes), + contentDescription = null, + modifier = Modifier.size(150.dp) + ) + } +} + +/** + * Battery card: left / right / case in equal thirds with a divider between them, after the + * headset settings page HyperOS ships - label on top, value centred, glyph underneath. + * A component that is not connected shows a dash rather than 0%, as the system does. + */ +@Composable +fun MiuixBatteryCard( + batteryList: Collection, + modifier: Modifier = Modifier +) { + val entries = listOf( + R.string.left to batteryList.find { it.component == BatteryComponent.LEFT }, + R.string.right to batteryList.find { it.component == BatteryComponent.RIGHT }, + R.string.case_alt to batteryList.find { it.component == BatteryComponent.CASE } + ) + + Card( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + insideMargin = PaddingValues(vertical = 20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + entries.forEachIndexed { index, (labelRes, battery) -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(labelRes), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MiuixTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + // The level means nothing while the component is disconnected, and + // the system shows a dash there. + text = if (battery == null || battery.status == BatteryStatus.DISCONNECTED) { + "-" + } else { + "${battery.level}%" + }, + fontSize = 14.sp, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary + ) + Spacer(modifier = Modifier.height(6.dp)) + BatteryGlyph( + level = if (battery == null || + battery.status == BatteryStatus.DISCONNECTED + ) 0 else battery.level + ) + } + if (index < entries.lastIndex) { + VerticalDivider( + modifier = Modifier.height(56.dp), + color = MiuixTheme.colorScheme.dividerLine + ) + } + } + } + } +} + +/** + * Listening mode picker: a row of circular icon buttons with the selected one filled in the + * accent. This is the shape of the control on the HyperOS headset page, not a Miuix TabRow. + */ +@Composable +fun MiuixNoiseControlCard( + showOffOption: Boolean, + currentMode: NoiseControlMode, + onModeChange: (NoiseControlMode) -> Unit, + modifier: Modifier = Modifier +) { + val isDark = isSystemInDarkTheme() + val unselectedCircle = if (isDark) UnselectedCircleDark else UnselectedCircleLight + val unselectedIcon = if (isDark) MiuixTheme.colorScheme.onPrimary else UnselectedIconLight + val modes = buildList { + add(Triple(NoiseControlMode.TRANSPARENCY, R.string.transparency, R.drawable.ic_transparency)) + add(Triple(NoiseControlMode.ADAPTIVE, R.string.adaptive, R.drawable.ic_adaptive)) + add( + Triple( + NoiseControlMode.NOISE_CANCELLATION, + R.string.noise_cancellation, + R.drawable.ic_noise_cancellation + ) + ) + if (showOffOption) { + add(Triple(NoiseControlMode.OFF, R.string.off, R.drawable.ic_noise_cancellation)) + } + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + insideMargin = PaddingValues(vertical = 20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + modes.forEach { (mode, labelRes, iconRes) -> + val selected = mode == currentMode + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + // No ripple: the Column is an unclipped rectangle, so the default + // indication paints a square over the round button. The system page + // has none here either - the feedback is the selection itself. + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onModeChange(mode) } + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(CircleSize) + .background( + // Measured off the system page: unselected is a constant + // grey, and selecting swaps the whole circle to the accent + // rather than lightening that grey. + if (selected) MiuixTheme.colorScheme.primary else unselectedCircle, + CircleShape + ) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = if (selected) MiuixTheme.colorScheme.onPrimary else unselectedIcon, + modifier = Modifier.size(27.dp) + ) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = stringResource(labelRes), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = if (selected) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.onSurface + } + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledBottomSheet.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledBottomSheet.kt index 386d8044a..52a1aa0a7 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledBottomSheet.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledBottomSheet.kt @@ -20,6 +20,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.window.WindowBottomSheet as MiuixWindowBottomSheet import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens @@ -36,6 +39,20 @@ fun StyledBottomSheet( ) { if (!visible) return + if (LocalDesignSystem.current == DesignSystem.Miuix) { + // The Miuix sheet brings its own handle, corners and window scrim, so none of the + // hand-drawn backdrop below applies. The content callback still wants a backdrop and + // a progress value: it gets a local backdrop and a constant, fully-expanded progress. + MiuixWindowBottomSheet( + show = true, + onDismissRequest = onDismiss + ) { + val innerBackdrop = rememberLayerBackdrop() + content(innerBackdrop, 1f) + } + return + } + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val sheetState = rememberModalBottomSheetState(false) // move this to parent composable diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledButton.kt index 584f855e3..c437edfa2 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledButton.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledButton.kt @@ -70,6 +70,8 @@ import com.kyant.backdrop.highlight.Highlight import kotlinx.coroutines.launch import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.basic.Button as MiuixButton +import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.atan2 @@ -137,6 +139,19 @@ fun StyledButton( } } } + DesignSystem.Miuix -> { + MiuixButton( + onClick = onClick, + modifier = modifier.height(48.dp), + enabled = enabled, + colors = if (materialButtonStyle == MaterialButtonStyle.Filled) { + MiuixButtonDefaults.buttonColorsPrimary() + } else { + MiuixButtonDefaults.buttonColors() + }, + content = content + ) + } DesignSystem.Apple -> { val defaultButtonColor = MaterialTheme.colorScheme.surfaceContainerHigh val isInteractive = enabled && isInteractive diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledConfirmationDialog.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledConfirmationDialog.kt index 4f3c9c957..cd2f9777c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledConfirmationDialog.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledConfirmationDialog.kt @@ -59,6 +59,9 @@ import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.basic.ButtonDefaults as MiuixButtonDefaults +import top.yukonga.miuix.kmp.basic.TextButton as MiuixTextButton +import top.yukonga.miuix.kmp.window.WindowDialog @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -121,6 +124,28 @@ fun StyledConfirmationDialog( } } } + DesignSystem.Miuix -> { + WindowDialog( + show = showDialog.value, + title = title, + summary = message, + onDismissRequest = onDismiss + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MiuixTextButton( + text = dismissText, + onClick = onDismiss, + modifier = Modifier.weight(1f) + ) + MiuixTextButton( + text = confirmText, + onClick = onConfirm, + modifier = Modifier.weight(1f), + colors = MiuixButtonDefaults.textButtonColorsPrimary() + ) + } + } + } DesignSystem.Apple -> { Box( modifier = Modifier.fillMaxSize(), diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledIconButton.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledIconButton.kt index 5ca3a2694..a8ecdf6c2 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledIconButton.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledIconButton.kt @@ -83,6 +83,7 @@ import kotlinx.coroutines.launch import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.atan2 @@ -141,6 +142,15 @@ fun StyledIconButton( } } } + DesignSystem.Miuix -> { + MiuixIconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(52.dp) + ) { + content() + } + } DesignSystem.Apple -> { val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledInputField.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledInputField.kt index 8c7bd152d..d5cbdd019 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledInputField.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledInputField.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField @Composable @@ -55,6 +56,27 @@ fun StyledInputField( selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) ) { + if (LocalDesignSystem.current == DesignSystem.Miuix && !forceApple) { + MiuixTextField( + state = inputState, + label = placeholder, + // The Miuix label floats above the text by default; what is wanted here is a + // placeholder, so it has to be told that explicitly. + useLabelAsPlaceholder = true, + lineLimits = if (singleLine) { + TextFieldLineLimits.SingleLine + } else { + TextFieldLineLimits.Default + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) + return + } + + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple if(m3eEnabled) { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledList.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledList.kt index 67033908b..16dbed3e5 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledList.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledList.kt @@ -28,6 +28,11 @@ import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import androidx.compose.foundation.layout.PaddingValues +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle +import top.yukonga.miuix.kmp.basic.Text as MiuixText +import top.yukonga.miuix.kmp.theme.MiuixTheme import me.kavishdevar.librepods.presentation.theme.sectionHeader @Composable @@ -53,6 +58,35 @@ fun StyledList( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + if (LocalDesignSystem.current == DesignSystem.Miuix) { + // MIUI groups are a SmallTitle over a single card, and the card is a squircle drawn + // by Miuix rather than a RoundedCornerShape. The title inset is 16 instead of the + // component default of 28: the secondary pages already pad their column by 12, and + // 12 + 16 is what lines the title up with the card's own row content. + Column(modifier = modifier) { + title?.let { + MiuixSmallTitle( + it, + insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) + } + MiuixCard(insideMargin = PaddingValues(0.dp)) { + scope.items.forEachIndexed { index, item -> + item(index, scope.items.size) + } + } + description?.let { + MiuixText( + text = it, + color = MiuixTheme.colorScheme.onBackgroundVariant, + fontSize = MiuixTheme.textStyles.footnote1.fontSize, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + return + } + Column (modifier = modifier) { title?.let { Box( diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledListItem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledListItem.kt index b402653de..b391aea86 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledListItem.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledListItem.kt @@ -22,11 +22,13 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures 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.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -66,6 +68,9 @@ import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.richText import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.RadioButton as MiuixRadioButton +import top.yukonga.miuix.kmp.preference.ArrowPreference import me.kavishdevar.librepods.presentation.theme.sectionHeader @Composable @@ -121,10 +126,18 @@ fun StyledListItem( .fillMaxWidth() .heightIn(min = 48.dp) .background( - if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, - RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + when (LocalDesignSystem.current) { + DesignSystem.Material -> Color.Transparent + // Under Miuix, Material's surface role carries the Miuix card fill and + // surfaceContainer the page canvas - the two are crossed in + // LibrePodsTheme, so a card painted with surfaceContainer would vanish + // into the page. + DesignSystem.Miuix -> MaterialTheme.colorScheme.surface + DesignSystem.Apple -> MaterialTheme.colorScheme.surfaceContainer + }, + RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp) ) - .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + .clip(RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp)) ) { StyledListItemContent( onClick = onClick, @@ -195,13 +208,22 @@ fun StyledListItem( .fillMaxWidth() .heightIn(min = 48.dp) .background( - if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, - RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + when (LocalDesignSystem.current) { + DesignSystem.Material -> Color.Transparent + // Under Miuix, Material's surface role carries the Miuix card fill and + // surfaceContainer the page canvas - the two are crossed in + // LibrePodsTheme, so a card painted with surfaceContainer would vanish + // into the page. + DesignSystem.Miuix -> MaterialTheme.colorScheme.surface + DesignSystem.Apple -> MaterialTheme.colorScheme.surfaceContainer + }, + RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp) ) - .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + .clip(RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp)) ) { StyledListItemContent( onClick = onClick, + contentText = contentText, content = { when (LocalDesignSystem.current) { DesignSystem.Apple -> { @@ -212,7 +234,7 @@ fun StyledListItem( ) } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { Text( text = contentText, style = MaterialTheme.typography.labelMediumEmphasized, @@ -287,13 +309,22 @@ fun StyledListItem( .fillMaxWidth() .heightIn(min = 48.dp) .background( - if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, - RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + when (LocalDesignSystem.current) { + DesignSystem.Material -> Color.Transparent + // Under Miuix, Material's surface role carries the Miuix card fill and + // surfaceContainer the page canvas - the two are crossed in + // LibrePodsTheme, so a card painted with surfaceContainer would vanish + // into the page. + DesignSystem.Miuix -> MaterialTheme.colorScheme.surface + DesignSystem.Apple -> MaterialTheme.colorScheme.surfaceContainer + }, + RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp) ) - .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + .clip(RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp)) ) { StyledListItemContent( onClick = onClick, + contentText = contentText, content = { when (LocalDesignSystem.current) { DesignSystem.Apple -> { @@ -304,7 +335,7 @@ fun StyledListItem( ) } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { Text( text = contentText, style = MaterialTheme.typography.labelMediumEmphasized, @@ -379,13 +410,22 @@ fun StyledListItem( .fillMaxWidth() .heightIn(min = 48.dp) .background( - if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer, - RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + when (LocalDesignSystem.current) { + DesignSystem.Material -> Color.Transparent + // Under Miuix, Material's surface role carries the Miuix card fill and + // surfaceContainer the page canvas - the two are crossed in + // LibrePodsTheme, so a card painted with surfaceContainer would vanish + // into the page. + DesignSystem.Miuix -> MaterialTheme.colorScheme.surface + DesignSystem.Apple -> MaterialTheme.colorScheme.surfaceContainer + }, + RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp) ) - .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + .clip(RoundedCornerShape(if (LocalDesignSystem.current == DesignSystem.Apple) 28.dp else 16.dp)) ) { StyledListItemContent( onClick = onClick, + contentText = contentText, content = { when (LocalDesignSystem.current) { DesignSystem.Apple -> { @@ -396,7 +436,7 @@ fun StyledListItem( ) } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { Text( text = contentText, style = MaterialTheme.typography.labelMediumEmphasized, @@ -404,6 +444,7 @@ fun StyledListItem( } } }, + supportingText = supportingText, supportingContent = if (supportingText != null && (LocalDesignSystem.current == DesignSystem.Material || orientation == StyledListItemOrientation.Horizontal)) { @Composable { Text( @@ -471,6 +512,7 @@ fun StyledListScope.StyledListItem( item { index, count -> StyledListItemContent( onClick = onClick, + contentText = contentText, content = { when (LocalDesignSystem.current) { DesignSystem.Apple -> { @@ -481,7 +523,7 @@ fun StyledListScope.StyledListItem( ) } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { Text( text = contentText, style = MaterialTheme.typography.labelMediumEmphasized, @@ -581,6 +623,7 @@ fun StyledListScope.StyledListItem( item { index, count -> StyledListItemContent( onClick = onClick, + contentText = contentText, content = { when (LocalDesignSystem.current) { DesignSystem.Apple -> { @@ -591,7 +634,7 @@ fun StyledListScope.StyledListItem( ) } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { Text( text = contentText, style = MaterialTheme.typography.labelMediumEmphasized, @@ -645,6 +688,7 @@ fun StyledListScope.StyledListItem( item { index, count -> StyledListItemContent( onClick = onClick, + contentText = contentText, content = { when (LocalDesignSystem.current) { DesignSystem.Apple -> { @@ -655,7 +699,7 @@ fun StyledListScope.StyledListItem( ) } - DesignSystem.Material -> { + DesignSystem.Material, DesignSystem.Miuix -> { Text( text = contentText, style = MaterialTheme.typography.labelMediumEmphasized, @@ -663,6 +707,7 @@ fun StyledListScope.StyledListItem( } } }, + supportingText = supportingText, supportingContent = if (supportingText != null) { @Composable { Text( @@ -696,6 +741,12 @@ private fun StyledListItemContent( onClick: (() -> Unit)?, content: @Composable () -> Unit, supportingContent: (@Composable () -> Unit)? = null, + // Plain-text forms of content / supportingContent. Miuix's own row components take + // strings rather than slots, and they carry MIUI's metrics — row height, insets, the + // chevron — that a hand-rolled row does not match, so the text is passed alongside the + // composables and the Miuix branch below prefers it when the caller had it to give. + contentText: String? = null, + supportingText: String? = null, height: Dp = 58.dp, enabled: Boolean = true, index: Int, @@ -726,6 +777,61 @@ private fun StyledListItemContent( val scope = rememberCoroutineScope() when (LocalDesignSystem.current) { + DesignSystem.Miuix -> { + val endActions: @Composable RowScope.() -> Unit = { + when { + trailingContent != null -> trailingContent() + selected != null -> MiuixRadioButton( + selected = selected, + onClick = onClick, + enabled = enabled + ) + } + } + when { + // A row that navigates gets ArrowPreference, so the chevron is drawn by Miuix + // to MIUI's spec. Rows that carry a control of their own, or none at all, get + // the plain component: a chevron there would promise a screen that isn't there. + contentText != null && onClick != null && selected == null && trailingContent == null -> + ArrowPreference( + modifier = modifier, + title = contentText, + summary = supportingText, + enabled = enabled, + onClick = onClick, + // holdDownState stays false: it is a permanently pressed background, + // meant for a row that keeps its press while its dialog is open. + startAction = leadingContent + ) + + contentText != null -> BasicComponent( + modifier = modifier, + title = contentText, + summary = supportingText, + enabled = enabled, + onClick = onClick, + startAction = leadingContent, + endActions = endActions + ) + + // Slot-only callers (the debug packet list, the heart-rate readout) have no + // text to hand over, so the row is laid out here with Miuix's own insets. + else -> Row( + modifier = modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingContent?.invoke() + Column(modifier = Modifier.weight(1f)) { + content() + supportingContent?.invoke() + } + endActions() + } + } + } DesignSystem.Apple -> { val surfaceColor = colors.containerColor val pressedColor = MaterialTheme.colorScheme.surfaceContainerLow diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt index f1e460b5a..5bf4cf1c4 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -87,6 +88,14 @@ import me.kavishdevar.librepods.presentation.navigation.LocalSharedTransitionSco import me.kavishdevar.librepods.presentation.navigation.LocalTransitionProgress import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.R +import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon +import top.yukonga.miuix.kmp.basic.IconButton as MiuixIconButton +import top.yukonga.miuix.kmp.basic.Scaffold as MiuixScaffold +import top.yukonga.miuix.kmp.basic.SmallTopAppBar as MiuixSmallTopAppBar +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Back +import top.yukonga.miuix.kmp.theme.MiuixTheme @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -176,6 +185,72 @@ fun StyledScaffold( } } } + DesignSystem.Miuix -> { + val miuixSurface = MiuixTheme.colorScheme.surface + MiuixScaffold( + // In Miuix it is surface that is the page canvas (pure black in dark mode); + // background is the grey a card is filled with. This layer stays behind even + // when the top bar is hidden, so the wrong one greys out the whole page. + containerColor = MiuixTheme.colorScheme.surface, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + // Deliberately not wrapped in AnimatedVisibility: a top bar sliding in + // from above makes a pushed screen look assembled in pieces, where MIUI + // brings the whole page in with the navigation transition and the bar is + // already complete on the first frame. + if (visible) { + // This has to be SmallTopAppBar. The Miuix TopAppBar carries a large + // collapsing title driven by a scrollBehavior, and content here is a + // generic lambda with nothing to hang nestedScroll on, so the large + // title would stay permanently expanded and leave a gap on every page. + MiuixSmallTopAppBar( + title = title, + // Transparent plus a haze effect makes the bar frosted glass over + // the content scrolling under it, the way MIUI's own bars read. + color = Color.Transparent, + modifier = Modifier.hazeEffect(state = hazeState) { + backgroundColor = miuixSurface + tints = listOf(HazeTint(miuixSurface.copy(alpha = 0.8f))) + blurRadius = 6.dp + }, + navigationIcon = { + if (navigateBack != null) { + MiuixIconButton(onClick = navigateBack) { + MiuixIcon( + imageVector = MiuixIcons.Back, + contentDescription = stringResource(R.string.back), + tint = MiuixTheme.colorScheme.onBackground + ) + } + } + }, + actions = { + actionButtons.forEach { actionButton -> + actionButton(rememberLayerBackdrop()) + } + } + ) + } + } + ) { paddingValues -> + Box( + modifier = modifier + .then( + if (visible) Modifier.padding( + start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), + end = paddingValues.calculateEndPadding(LocalLayoutDirection.current) + ) else Modifier + ) + .fillMaxSize() + .hazeSource(hazeState) + ) { + content( + paddingValues.calculateTopPadding(), + paddingValues.calculateBottomPadding() + ) + } + } + } DesignSystem.Apple -> { Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledSlider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledSlider.kt index 0c038f0f3..e623f94f6 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledSlider.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledSlider.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -98,6 +99,11 @@ import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider +import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle +import top.yukonga.miuix.kmp.basic.Text as MiuixText +import top.yukonga.miuix.kmp.theme.MiuixTheme import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.roundToInt @@ -376,6 +382,41 @@ fun StyledSlider( } } + DesignSystem.Miuix -> { + // Same shape as StyledList under Miuix: the whole block sits in a Miuix Card and + // the label is a SmallTitle rather than a hand-drawn one. + Column { + label?.let { + MiuixSmallTitle( + it, + insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) + } + MiuixCard(insideMargin = PaddingValues(0.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + MiuixSlider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + enabled = enabled, + modifier = Modifier.fillMaxWidth() + ) + } + } + description?.let { + MiuixText( + text = it, + color = MiuixTheme.colorScheme.onBackgroundVariant, + fontSize = MiuixTheme.textStyles.footnote1.fontSize, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + } DesignSystem.Apple -> { val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val trackColor = diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledToggle.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledToggle.kt index 81becdebd..7af704a05 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledToggle.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledToggle.kt @@ -60,6 +60,11 @@ import kotlinx.coroutines.launch import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import androidx.compose.foundation.layout.PaddingValues +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.Card as MiuixCard +import top.yukonga.miuix.kmp.basic.SmallTitle as MiuixSmallTitle +import top.yukonga.miuix.kmp.basic.Switch as MiuixSwitch import me.kavishdevar.librepods.presentation.theme.sectionHeader import kotlin.io.encoding.ExperimentalEncodingApi @@ -81,6 +86,29 @@ fun StyledToggle( selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) ) { + if (LocalDesignSystem.current == DesignSystem.Miuix) { + Column { + title?.let { + MiuixSmallTitle( + it, + insideMargin = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) + } + MiuixCard(insideMargin = PaddingValues(0.dp)) { + StyledToggleContent( + label = label, + description = description, + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + index = 0, + count = 1 + ) + } + } + return + } + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material Column(modifier = Modifier.padding(vertical = 12.dp)) { title?.let { @@ -190,6 +218,23 @@ private fun StyledToggleContent( val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() + if (LocalDesignSystem.current == DesignSystem.Miuix) { + BasicComponent( + title = label, + summary = description, + enabled = enabled, + onClick = { onCheckedChange(!currentChecked) }, + endActions = { + MiuixSwitch( + checked = currentChecked, + onCheckedChange = onCheckedChange, + enabled = enabled + ) + } + ) + return + } + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material if (m3eEnabled) { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 1437a4c72..eece0eebb 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -202,6 +202,13 @@ fun AppSettingsScreen( onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Material) } }, enabled = state.isPremium ) + + StyledListItem( + contentText = stringResource(R.string.miuix), + selected = state.settings.designSystem == DesignSystem.Miuix, + onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Miuix) } }, + enabled = state.isPremium + ) } Spacer(modifier = Modifier.height(16.dp)) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt index 1411e26ed..7406d3c8a 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -71,6 +72,7 @@ import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @@ -175,6 +177,78 @@ fun AppleSettingsScreen( val spec = AirPodsSpecs.getSpec(metadata.model) val baseCapabilities = spec.baseCapabilities + val designSystem = LocalDesignSystem.current + + // HyperOS puts the listening-mode control directly under the battery card, before + // the device name, so under Miuix this block moves up there. Everywhere else it + // keeps its place between the hearing-health and the recording groups. + val listeningModeItems: LazyListScope.() -> Unit = { + if (baseCapabilities.contains(BaseCapability.LISTENING_MODE)) { + if (designSystem != DesignSystem.Miuix) { + item(key = "spacer_noise") { + Spacer(modifier = Modifier.height(16.dp)) + } + } + item(key = "noise_control") { + NoiseControlSettings( + showOffListeningMode = state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.getOrNull(0)?.toInt() == 1, + noiseControlModeValue = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() ?: 3, + onNoiseControlModeChanged = { + setControlCommandInt( + ControlCommandIdentifier.LISTENING_MODE, it + ) + }, + ) + } + + if (baseCapabilities.contains(BaseCapability.ADAPTIVE_AUDIO)) { + item(key = "adaptive_strength") { + AnimatedVisibility( + visible = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() == 4, + enter = remember { + fadeIn() + slideInVertically() + }, + exit = remember { + fadeOut() + slideOutVertically() + } + ) { + val sliderValue = remember { + mutableFloatStateOf( + 100f - (state.controlStates[ControlCommandIdentifier.AUTO_ANC_STRENGTH]?.getOrNull( + 0 + )?.toFloat() ?: 50f) + ) + } + + LaunchedEffect(sliderValue) { + snapshotFlow { sliderValue.floatValue } + .debounce(100.milliseconds) + .collect { value -> + setControlCommandInt( + ControlCommandIdentifier.AUTO_ANC_STRENGTH, + (100 - value).toInt() + ) + } + } + + StyledSlider( + value = sliderValue.floatValue, + onValueChange = { sliderValue.floatValue = it }, + valueRange = 0f..100f, + snapPoints = listOf(0f, 50f, 100f), + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, + independent = true, + description = stringResource(R.string.adaptive_audio_description), + enabled = uiState.isPremium + ) + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } + } StyledScaffold( title = uiState.metadata.name, @@ -192,6 +266,7 @@ fun AppleSettingsScreen( caseImageRes = spec.caseImageRes ?: R.drawable.img_airpods_pro_2_case // TODO ) } + if (designSystem == DesignSystem.Miuix) listeningModeItems() item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) } @@ -223,70 +298,7 @@ fun AppleSettingsScreen( ) } } - - if (baseCapabilities.contains(BaseCapability.LISTENING_MODE)) { - item(key = "spacer_noise") { - Spacer(modifier = Modifier.height(16.dp)) - } - item(key = "noise_control") { - NoiseControlSettings( - showOffListeningMode = state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.getOrNull(0)?.toInt() == 1, - noiseControlModeValue = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() ?: 3, - onNoiseControlModeChanged = { - setControlCommandInt( - ControlCommandIdentifier.LISTENING_MODE, it - ) - }, - ) - } - - if (baseCapabilities.contains(BaseCapability.ADAPTIVE_AUDIO)) { - item(key = "adaptive_strength") { - AnimatedVisibility( - visible = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() == 4, - enter = remember { - fadeIn() + slideInVertically() - }, - exit = remember { - fadeOut() + slideOutVertically() - } - ) { - val sliderValue = remember { - mutableFloatStateOf( - 100f - (state.controlStates[ControlCommandIdentifier.AUTO_ANC_STRENGTH]?.getOrNull( - 0 - )?.toFloat() ?: 50f) - ) - } - - LaunchedEffect(sliderValue) { - snapshotFlow { sliderValue.floatValue } - .debounce(100.milliseconds) - .collect { value -> - setControlCommandInt( - ControlCommandIdentifier.AUTO_ANC_STRENGTH, - (100 - value).toInt() - ) - } - } - - StyledSlider( - value = sliderValue.floatValue, - onValueChange = { sliderValue.floatValue = it }, - valueRange = 0f..100f, - snapPoints = listOf(0f, 50f, 100f), - startImageVector = LocalIcons.current.SpeakerMin, - endImageVector = LocalIcons.current.SpeakerMax, - independent = true, - description = stringResource(R.string.adaptive_audio_description), - enabled = uiState.isPremium - ) - - Spacer(modifier = Modifier.height(16.dp)) - } - } - } - } + if (designSystem != DesignSystem.Miuix) listeningModeItems() if (metadata.version3.isNotBlank() && metadata.version3.first().digitToInt() >= 8) { item(key = "spacer_recording") { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt index 72dbd8964..fbf2081dd 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt @@ -2,5 +2,6 @@ package me.kavishdevar.librepods.presentation.theme enum class DesignSystem { Apple, - Material + Material, + Miuix } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt index f17edd562..600f88ab2 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt @@ -33,6 +33,9 @@ import androidx.compose.ui.platform.LocalContext import me.kavishdevar.librepods.presentation.icons.AppleIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.MaterialIcons +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.darkColorScheme as miuixDarkColorScheme +import top.yukonga.miuix.kmp.theme.lightColorScheme as miuixLightColorScheme val ColorScheme.sectionHeader: Color get() = onBackground.copy(alpha = 0.6f) @@ -80,16 +83,54 @@ fun LibrePodsTheme( overrideMaterialColor: Color? = null, content: @Composable () -> Unit ) { + val miuixColors = if (darkTheme) miuixDarkColorScheme() else miuixLightColorScheme() + val colorScheme = when(designSystem) { DesignSystem.Material -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } + // The Material scheme has to be derived from the Miuix palette here rather than + // from dynamic color: the screens paint their canvas with + // MaterialTheme.colorScheme.surfaceContainer, so two unrelated palettes leave the + // page background and the Miuix cards on top of it in different colours. + DesignSystem.Miuix -> { + val base = if (darkTheme) darkColorScheme() else lightColorScheme() + // Miuix and Material use the surface roles in opposite senses: + // miuix surface = the page canvas (pure black in dark mode) + // miuix surfaceContainer = the card fill (#242424 in dark mode) + // while the screens here paint the page with Material's surfaceContainer and + // the cards with surface. The mapping is crossed for that reason; matching the + // roles by name would give the page and its cards the same colour. + base.copy( + background = miuixColors.surface, + onBackground = miuixColors.onSurface, + surface = miuixColors.surfaceContainer, + onSurface = miuixColors.onSurfaceContainer, + surfaceContainer = miuixColors.surface, + surfaceContainerHigh = miuixColors.surfaceContainerHigh, + surfaceContainerHighest = miuixColors.surfaceContainerHighest, + surfaceVariant = miuixColors.surfaceVariant, + surfaceDim = miuixColors.windowDimming, + primary = miuixColors.primary, + onPrimary = miuixColors.onPrimary, + primaryContainer = miuixColors.primaryContainer, + onPrimaryContainer = miuixColors.onPrimaryContainer, + secondary = miuixColors.secondary, + onSecondary = miuixColors.onSecondary, + secondaryContainer = miuixColors.secondaryContainer, + onSecondaryContainer = miuixColors.onSecondaryContainer, + outline = miuixColors.outline, + error = miuixColors.error, + onError = miuixColors.onError + ) + } DesignSystem.Apple -> if (darkTheme) AppleDarkColorScheme else AppleLightColorScheme } val typography = when(designSystem) { DesignSystem.Material -> MaterialTypography + DesignSystem.Miuix -> MiuixTypography DesignSystem.Apple -> AppleTypography } @@ -97,6 +138,9 @@ fun LibrePodsTheme( LocalDesignSystem provides designSystem, LocalIcons provides when (designSystem) { DesignSystem.Material -> MaterialIcons + // Miuix draws its own glyphs where it needs them; everything routed through + // LocalIcons is shared UI, so it keeps the Material set. + DesignSystem.Miuix -> MaterialIcons DesignSystem.Apple -> AppleIcons } ) { @@ -109,8 +153,15 @@ fun LibrePodsTheme( MaterialExpressiveTheme( colorScheme = colorScheme, motionScheme = MotionScheme.expressive(), - typography = typography, - content = content - ) + typography = typography + ) { + // MiuixTheme has to sit inside the Material one: the Miuix widgets read their + // colours from it, while everything shared still reads MaterialTheme. + if (designSystem == DesignSystem.Miuix) { + MiuixTheme(colors = miuixColors, content = content) + } else { + content() + } + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt index cf8085368..00ebe092d 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt @@ -168,6 +168,10 @@ val labelEmphasized = robotoFlex( ) +// Miuix uses the platform default family: on HyperOS that is MiSans, and bundling +// another face would make the app look wrong rather than native. +val MiuixTypography = Typography() + val MaterialTypography = Typography().run { copy( titleSmall = titleSmall.copy( diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 387a99630..c9a06bc33 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -281,6 +281,7 @@ Recommended Appearance Material 3 Expressive + Miuix LibrePods now supports a whole new look based on Material 3 Expressive including updated typography and adaptive color schemes.\nYou can switch back to the Apple look from the app\'s settings. Available only on the latest AirPods Beta firmware. An Apple device running OS version 27 is required to install the beta firmware. What\'s New diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 6df538a52..b8e747176 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -30,6 +30,7 @@ protobufPlugin = "0.10.0" protobuf = "4.35.1" healthConnect = "1.2.0-alpha05" foundationLayout = "1.12.0" +miuix = "0.9.3" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -74,6 +75,10 @@ protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref androidx-healthconnect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +miuix-ui = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" } +miuix-preference = { module = "top.yukonga.miuix.kmp:miuix-preference-android", version.ref = "miuix" } +miuix-icons = { module = "top.yukonga.miuix.kmp:miuix-icons-android", version.ref = "miuix" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }