From c3852a1c32da2d544da2ed10577640dc08027183 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 20 Jul 2026 15:19:52 +0600 Subject: [PATCH] feat: add adaptive dialog bottom sheet component and enhance plugin installation UI --- AGENTS.md | 5 +- .../composeResources/values/strings.xml | 1 + .../ui/component/AdaptiveDialogBottomSheet.kt | 86 ++++++++++ .../spotube/modules/plugin/PluginScreen.kt | 132 +++++++++++++-- .../plugin/components/InstallSection.kt | 151 ------------------ .../spotube/resources/iconsax/IconsaxAdd.kt | 61 +++++++ 6 files changed, 274 insertions(+), 162 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt delete mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt diff --git a/AGENTS.md b/AGENTS.md index f9339626..3f6ee2d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,10 @@ - `plugin_interfaces` exports `zipline.core` and `semver` as API. `composeApp` depends on it for the plugin system. - `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system. -## Desktop JVM specifics +## UI component patterns +- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. +- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. +- Use `expect`/`actual` only when the component MUST use platform-specific APIs (e.g. `WindowState` for desktop window controls, native scrollbars). Pure Compose/Material3 adaptivity stays in commonMain. - JavaFX is required; `--add-opens` flags in `compose.desktop.application.jvmArgs` must be preserved: `javafx.graphics/javafx.scene`, `javafx.graphics/com.sun.javafx.sg.prism`, `javafx.graphics/com.sun.javafx.scene`, `javafx.web/com.sun.webkit`, `javafx.media/com.sun.media.jfxmedia`, `javafx.media/com.sun.media.jfxmedia.events`. - JavaFX dependencies are loaded from OpenJFX with platform classifiers (win/mac/linux) resolved at configuration time via `System.getProperty("os.name")`. diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 73baf533..e582d8a0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -126,6 +126,7 @@ %1$s plugin Clear + Configure Plugin Manager Please enter a URL URL must start with http:// or https:// diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt new file mode 100644 index 00000000..33d0ca4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/AdaptiveDialogBottomSheet.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package dev.krtirtho.spotube.core.ui.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.krtirtho.spotube.core.ui.base.ThemedDialog + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdaptiveDialogBottomSheet( + onDismiss: () -> Unit, + title: @Composable (() -> Unit)? = null, + modifier: Modifier = Modifier, + breakpointDp: Float = 600f, + actions: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val adaptiveInfo = currentWindowAdaptiveInfo() + val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp + + if (isLargeScreen) { + ThemedDialog( + onDismissRequest = onDismiss, + title = title, + actions = actions, + modifier = modifier, + ) { + content() + } + } else { + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = null, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.Start, + ) { + title?.let { + it() + Spacer(Modifier.height(12.dp)) + } + content() + actions?.let { + Spacer(Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Spacer(Modifier.height(8.dp)) + it() + } + } + Spacer(Modifier.height(24.dp)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 5de3aa80..51670a4a 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -26,9 +26,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -55,22 +57,30 @@ import dev.krtirtho.spotube.core.ui.base.Card import dev.krtirtho.spotube.core.ui.base.PrimaryButton import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton +import dev.krtirtho.spotube.core.ui.base.TextField import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.plugin.components.InstallSection + import dev.krtirtho.spotube.modules.plugin.components.PluginCard import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 import dev.krtirtho.spotube.resources.iconsax.IconsaxBox import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckSquare import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit +import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk +import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk +import dev.krtirtho.spotube.resources.iconsax.IconsaxLink import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic import dev.krtirtho.spotube.resources.iconsax.IconsaxSound import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft @@ -83,6 +93,9 @@ import org.koin.compose.koinInject import kotlinx.coroutines.flow.StateFlow import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.plugin_empty_subtitle +import spotube.composeapp.generated.resources.plugin_action_download +import spotube.composeapp.generated.resources.plugin_action_install_from_file +import spotube.composeapp.generated.resources.plugin_configure_title import spotube.composeapp.generated.resources.plugin_empty_title import spotube.composeapp.generated.resources.plugin_error_download_failed import spotube.composeapp.generated.resources.plugin_error_enter_url @@ -90,7 +103,9 @@ import spotube.composeapp.generated.resources.plugin_error_url_scheme import spotube.composeapp.generated.resources.plugin_installed_count import spotube.composeapp.generated.resources.plugin_installed_plural import spotube.composeapp.generated.resources.plugin_installed_singular +import spotube.composeapp.generated.resources.plugin_install_section_title import spotube.composeapp.generated.resources.plugin_screen_title +import spotube.composeapp.generated.resources.plugin_url_placeholder import spotube.composeapp.generated.resources.settings_plugins_action_change import spotube.composeapp.generated.resources.settings_plugins_action_select import spotube.composeapp.generated.resources.settings_plugins_ability_audio @@ -120,6 +135,7 @@ fun PluginScreen( var urlInput by remember { mutableStateOf("") } var urlError by remember { mutableStateOf(null) } var isLoadingUrl by remember { mutableStateOf(false) } + var showInstallSheet by remember { mutableStateOf(false) } val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url) val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme) @@ -175,6 +191,90 @@ fun PluginScreen( ) } + if (showInstallSheet) { + AdaptiveDialogBottomSheet( + onDismiss = { showInstallSheet = false }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + stringResource(Res.string.plugin_install_section_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = urlInput, + onValueChange = { urlInput = it; urlError = null }, + modifier = Modifier.weight(1f), + placeholder = { + Text( + stringResource(Res.string.plugin_url_placeholder), + style = MaterialTheme.typography.bodySmall + ) + }, + leadingIcon = { + Icon( + Iconsax.IconsaxLink, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + isError = urlError != null, + singleLine = true, + ) + SecondaryIconButton( + onClick = { submitUrl() }, + enabled = !isLoadingUrl, + ) { + if (isLoadingUrl) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = stringResource(Res.string.plugin_action_download), + ) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + OutlineButton( + onClick = { launcher.launch() }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Iconsax.IconsaxExportArrowBulk, + contentDescription = stringResource(Res.string.plugin_action_install_from_file) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.plugin_action_install_from_file)) + } + } + } + } + Scaffold( topBar = { ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) @@ -205,16 +305,28 @@ fun PluginScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { - // ── Install section ─────────────────────────────────── + // ── Configure header ────────────────────────────── item { - InstallSection( - urlInput = urlInput, - onUrlChange = { urlInput = it; urlError = null }, - urlError = urlError, - isLoadingUrl = isLoadingUrl, - onSubmitUrl = { submitUrl() }, - onPickFile = { launcher.launch() } - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(Res.string.plugin_configure_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + PrimaryButton(onClick = { showInstallSheet = true }) { + Icon( + Iconsax.IconsaxAdd, + contentDescription = "Install a plugin", + ) + Text(stringResource(Res.string.plugin_install_section_title)) + } + } } // ── Default ability plugin selectors ───────────────── diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt deleted file mode 100644 index 0f97928b..00000000 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/components/InstallSection.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) 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 Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package dev.krtirtho.spotube.modules.plugin.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxLink -import org.jetbrains.compose.resources.stringResource -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.plugin_action_download -import spotube.composeapp.generated.resources.plugin_action_install_from_file -import spotube.composeapp.generated.resources.plugin_install_section_title -import spotube.composeapp.generated.resources.plugin_url_placeholder - -@Composable -internal fun InstallSection( - urlInput: String, - onUrlChange: (String) -> Unit, - urlError: String?, - isLoadingUrl: Boolean, - onSubmitUrl: () -> Unit, - onPickFile: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - stringResource(Res.string.plugin_install_section_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - } - - // URL row - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextField( - value = urlInput, - onValueChange = onUrlChange, - modifier = Modifier.weight(1f), - placeholder = { - Text( - stringResource(Res.string.plugin_url_placeholder), - style = MaterialTheme.typography.bodySmall - ) - }, - leadingIcon = { - Icon( - Iconsax.IconsaxLink, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - isError = urlError != null, -// supportingText = urlError?.let { { Text(it) } }, -// shape = RoundedCornerShape(10.dp), - singleLine = true, - ) - SecondaryIconButton( - onClick = onSubmitUrl, - enabled = !isLoadingUrl, - ) { - if (isLoadingUrl) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Icon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = stringResource(Res.string.plugin_action_download), - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - - // File picker - OutlineButton( - onClick = onPickFile, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Iconsax.IconsaxExportArrowBulk, - contentDescription = stringResource(Res.string.plugin_action_install_from_file) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.plugin_action_install_from_file)) - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt new file mode 100644 index 00000000..520ebcc4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/resources/iconsax/IconsaxAdd.kt @@ -0,0 +1,61 @@ +package dev.krtirtho.spotube.resources.iconsax + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val Iconsax.IconsaxAdd: ImageVector + get() { + if (_IncosaxAdd != null) { + return _IncosaxAdd!! + } + _IncosaxAdd = ImageVector.Builder( + name = "IncosaxAdd", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + horizontalLineToRelative(24f) + verticalLineToRelative(24f) + horizontalLineToRelative(-24f) + close() + } + ) { + path( + fillAlpha = 0.4f, + stroke = SolidColor(Color.White), + strokeAlpha = 0.4f, + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(6f, 12f) + horizontalLineTo(18f) + } + path( + stroke = SolidColor(Color.White), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round + ) { + moveTo(12f, 18f) + verticalLineTo(6f) + } + } + }.build() + + return _IncosaxAdd!! + } + +@Suppress("ObjectPropertyName") +private var _IncosaxAdd: ImageVector? = null