Compare commits
6 Commits
9c9f1a0a9d
...
5e099c029e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e099c029e | ||
|
|
9f96b07ebd | ||
|
|
4da5b25a5c | ||
|
|
fa9ad9981c | ||
|
|
5c0652b395 | ||
|
|
b0327fd224 |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 47 KiB |
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="app_name">Spotube</string>
|
||||
<string name="now_playing">Spelas nu</string>
|
||||
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
|
||||
<g id="Alapkör">
|
||||
<circle id="XMLID_23_" fill="#CD201F" cx="50" cy="50" r="50"/>
|
||||
</g>
|
||||
<g id="Elemek">
|
||||
<path id="XMLID_19_" fill="#FFFFFF" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
|
||||
</g>
|
||||
<g id="Fedő">
|
||||
<path id="XMLID_5_" fill="#CD201F" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
|
||||
<rect id="XMLID_4_" x="41.4" y="55.6" fill="#CD201F" width="6.2" height="21"/>
|
||||
</g>
|
||||
<g id="Vonalak">
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 787 B |
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal file
@ -0,0 +1,169 @@
|
||||
<!--
|
||||
~ 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 <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="settings_screen_title">Inställningar</string>
|
||||
|
||||
<string name="settings_section_plugins">Tillägg</string>
|
||||
<string name="settings_section_language_region">Språk och region</string>
|
||||
<string name="settings_section_appearance">Utseende</string>
|
||||
<string name="settings_section_playback">Uppspelning</string>
|
||||
<string name="settings_section_downloads">Nedladdningar</string>
|
||||
<string name="settings_section_caching">Cachelagring</string>
|
||||
<string name="settings_section_desktop">Dator</string>
|
||||
<string name="settings_section_updates">Uppdateringar</string>
|
||||
|
||||
<string name="settings_action_close">Stäng</string>
|
||||
<string name="settings_action_save">Spara</string>
|
||||
<string name="settings_action_cancel">Avbryt</string>
|
||||
|
||||
<string name="settings_language_title">Språk</string>
|
||||
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
|
||||
<string name="settings_country_title">Land</string>
|
||||
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
|
||||
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
|
||||
|
||||
<string name="settings_theme_title">Tema</string>
|
||||
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
|
||||
<string name="settings_theme_light">Ljust</string>
|
||||
<string name="settings_theme_dark">Mörkt</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
|
||||
<string name="settings_accent_title">Accentfärg</string>
|
||||
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
|
||||
<string name="settings_accent_dialog_title">Accentfärg</string>
|
||||
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
|
||||
<string name="settings_preview_light">Ljust</string>
|
||||
<string name="settings_preview_dark">Mörkt</string>
|
||||
<string name="settings_preview_label">Förhandsgranskning</string>
|
||||
<string name="settings_preview_button">Knapp</string>
|
||||
<string name="settings_accent_green_goblin">Gröna trollet</string>
|
||||
<string name="settings_accent_electric_violet">Elektriskt violett</string>
|
||||
<string name="settings_accent_oceanic_cyan">Havscyan</string>
|
||||
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
|
||||
<string name="settings_accent_rose_garden">Rosenträdgård</string>
|
||||
<string name="settings_accent_midnight_blue">Midnattsblått</string>
|
||||
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
|
||||
|
||||
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
|
||||
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
|
||||
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
|
||||
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
|
||||
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
|
||||
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
|
||||
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
|
||||
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
|
||||
<string name="settings_local_media_folders_manage">Hantera</string>
|
||||
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
|
||||
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
|
||||
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
|
||||
<string name="settings_download_format_title">Musikformat för nedladdning</string>
|
||||
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
|
||||
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
|
||||
|
||||
<string name="settings_streaming_format_title">Musikformat för strömning</string>
|
||||
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
|
||||
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
|
||||
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
|
||||
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
|
||||
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
|
||||
<string name="settings_cache_folder_title">Cachemapp</string>
|
||||
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
|
||||
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
|
||||
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
|
||||
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
|
||||
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
|
||||
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
|
||||
<string name="settings_cache_size_limit_placeholder">500</string>
|
||||
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
|
||||
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
|
||||
<string name="settings_enable_connect_title">Aktivera Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
|
||||
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
|
||||
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
|
||||
<string name="settings_playback_port_placeholder">14769</string>
|
||||
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
|
||||
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
|
||||
|
||||
<string name="settings_blacklist_title">Spärrlista</string>
|
||||
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
|
||||
|
||||
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
|
||||
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
|
||||
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
|
||||
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
|
||||
|
||||
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
|
||||
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
|
||||
|
||||
<string name="settings_plugins_manage_title">Hantera tillägg</string>
|
||||
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
|
||||
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
|
||||
<string name="settings_plugins_ability_metadata">Metadata</string>
|
||||
<string name="settings_plugins_ability_audio">Ljud</string>
|
||||
<string name="settings_plugins_ability_lyrics">Låttexter</string>
|
||||
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
|
||||
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
|
||||
<string name="settings_plugins_action_change">Ändra</string>
|
||||
<string name="settings_plugins_action_select">Välj</string>
|
||||
<string name="settings_plugins_selected">Valt</string>
|
||||
<string name="settings_plugins_clear">Rensa val</string>
|
||||
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
|
||||
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
|
||||
<string name="settings_plugins_clear_content_description">Rensa</string>
|
||||
|
||||
<string name="plugin_configure_title">Konfigurera</string>
|
||||
<string name="plugin_screen_title">Tilläggshanterare</string>
|
||||
<string name="plugin_error_enter_url">Ange en URL</string>
|
||||
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
|
||||
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
|
||||
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
|
||||
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
|
||||
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
|
||||
<string name="plugin_installed_singular">tillägg</string>
|
||||
<string name="plugin_installed_plural">tillägg</string>
|
||||
<string name="plugin_install_section_title">Installera ett tillägg</string>
|
||||
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
|
||||
<string name="plugin_action_download">Ladda ner</string>
|
||||
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
|
||||
<string name="plugin_state_active">Aktivt</string>
|
||||
<string name="plugin_version_label">v%1$s</string>
|
||||
<string name="plugin_state_builtin">Inbyggt</string>
|
||||
<string name="plugin_action_remove">Ta bort tillägg</string>
|
||||
<string name="plugin_action_login">Logga in</string>
|
||||
<string name="plugin_action_logout">Logga ut</string>
|
||||
<string name="plugin_section_url_title">Ladda ner från URL</string>
|
||||
<string name="plugin_section_file_title">Installera från fil</string>
|
||||
<string name="plugin_section_install">Installera</string>
|
||||
|
||||
<string name="plugin_permissions_author_version">• %1$s</string>
|
||||
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
|
||||
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
|
||||
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
|
||||
<string name="plugin_permissions_installed_label">Installerat</string>
|
||||
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
|
||||
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
|
||||
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
|
||||
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
|
||||
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
|
||||
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
|
||||
</resources>
|
||||
@ -26,6 +26,7 @@
|
||||
<string name="settings_section_caching">Caching</string>
|
||||
<string name="settings_section_desktop">Desktop</string>
|
||||
<string name="settings_section_updates">Updates</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
|
||||
<string name="settings_action_close">Close</string>
|
||||
<string name="settings_action_save">Save</string>
|
||||
@ -43,6 +44,14 @@
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
|
||||
<string name="settings_surface_theme_title">Surface</string>
|
||||
<string name="settings_surface_theme_subtitle_current">Background material of the app. Current: %1$s</string>
|
||||
<string name="settings_surface_watery">Watery</string>
|
||||
<string name="settings_surface_icy">Icy</string>
|
||||
<string name="settings_surface_chrome">Chrome</string>
|
||||
<string name="settings_surface_acrylic">Acrylic</string>
|
||||
<string name="settings_surface_glass">Glass</string>
|
||||
|
||||
<string name="settings_accent_title">Accent color</string>
|
||||
<string name="settings_accent_subtitle_current">Current palette: %1$s</string>
|
||||
<string name="settings_accent_dialog_title">Accent color</string>
|
||||
|
||||
@ -35,6 +35,7 @@ import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.toEntries
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||
@ -156,8 +157,11 @@ fun App(
|
||||
}
|
||||
|
||||
SpotubeTheme(settings = userSettings) {
|
||||
val baseUITheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||
val baseUITheme = rememberBaseUITheme(surfaceTheme = userSettings.surfaceTheme)
|
||||
CompositionLocalProvider(
|
||||
LocalBaseUITheme provides baseUITheme,
|
||||
LocalBaseUIColors provides baseUITheme.colors,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -43,6 +44,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@ -74,7 +76,6 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
@ -83,6 +84,7 @@ import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
|
||||
private val AutocompleteTextFieldMinHeight = 44.dp
|
||||
private val AutocompleteMenuDefaultMaxHeight = 300.dp
|
||||
@ -373,13 +375,11 @@ fun <T> AutocompleteTextField(
|
||||
isMenuOpen = false
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
AutocompleteMenuContainer(
|
||||
theme = resolvedMenuTheme,
|
||||
modifier = Modifier
|
||||
.width(textFieldWidthDp)
|
||||
.heightIn(max = menuMaxHeight)
|
||||
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
||||
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
||||
.clip(resolvedMenuTheme.shape),
|
||||
.heightIn(max = menuMaxHeight),
|
||||
) {
|
||||
LazyColumn(
|
||||
state = menuListState,
|
||||
@ -407,77 +407,163 @@ fun <T> AutocompleteTextField(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
/**
|
||||
* The visual container of the autocomplete suggestion menu, without the Popup window.
|
||||
*
|
||||
* Extracted so the menu can be rendered in previews, since popups are hosted in a
|
||||
* separate window the preview canvas cannot show.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutocompleteMenuContainer(
|
||||
theme: BaseUITheme.AutoCompleteMenuTheme,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.shadow(theme.shadowElevation, theme.shape)
|
||||
.background(theme.background, theme.shape)
|
||||
.clip(theme.shape),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private val AutocompletePreviewArtists = listOf(
|
||||
"Twenty One Pilots",
|
||||
"The Beatles",
|
||||
"Adele",
|
||||
"Drake",
|
||||
"Taylor Swift",
|
||||
"Arctic Monkeys",
|
||||
"Billie Eilish",
|
||||
)
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun AutocompleteTextFieldPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
val artists = remember {
|
||||
listOf(
|
||||
"Twenty One Pilots",
|
||||
"The Beatles",
|
||||
"Adele",
|
||||
"Drake",
|
||||
"Taylor Swift",
|
||||
"Arctic Monkeys",
|
||||
"Billie Eilish",
|
||||
)
|
||||
}
|
||||
var value by remember { mutableStateOf("") }
|
||||
BaseUIPreview {
|
||||
var value by remember { mutableStateOf("") }
|
||||
|
||||
AutocompleteTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
},
|
||||
items = artists.filter {
|
||||
it.contains(value, ignoreCase = true) && value.isNotEmpty()
|
||||
},
|
||||
onItemSelected = { selected ->
|
||||
value = selected
|
||||
},
|
||||
placeholder = { Text("Search artists...") },
|
||||
leadingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
itemContent = { item, isSelected ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = item,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Field states",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AutocompleteTextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
items = emptyList<String>(),
|
||||
itemContent = { _, _ -> },
|
||||
placeholder = { Text("Search artists...") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
AutocompleteTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
items = AutocompletePreviewArtists.filter {
|
||||
it.contains(value, ignoreCase = true)
|
||||
},
|
||||
onItemSelected = { value = it },
|
||||
placeholder = { Text("Search artists...") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = "Clear",
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
itemContent = { item, isSelected ->
|
||||
AutocompletePreviewItem(item, isSelected)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun AutocompleteMenuPreview() {
|
||||
BaseUIPreview {
|
||||
var selectedIndex by remember { mutableStateOf(1) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Suggestion menu",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
AutocompleteMenuContainer(
|
||||
theme = LocalBaseUITheme.current.autoCompleteMenuTheme,
|
||||
modifier = Modifier.width(320.dp),
|
||||
) {
|
||||
Column {
|
||||
AutocompletePreviewArtists.take(5).forEachIndexed { index, artist ->
|
||||
Box(
|
||||
modifier = Modifier.clickable { selectedIndex = index },
|
||||
) {
|
||||
AutocompletePreviewItem(artist, isSelected = selectedIndex == index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Selected: ${AutocompletePreviewArtists[selectedIndex]}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutocompletePreviewItem(text: String, isSelected: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,302 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
|
||||
/**
|
||||
* The app's own colour system, independent of [MaterialTheme].
|
||||
*
|
||||
* Each [SurfaceStyle] is a complete visual theme with its own palette (backgrounds,
|
||||
* surfaces, text and outlines) rather than Material's role colours. The user's chosen
|
||||
* accent is threaded through as `primary`, but everything else comes from the surface
|
||||
* theme, so the app never has to rely on Material's colour scheme.
|
||||
*/
|
||||
@Immutable
|
||||
data class BaseUIColorScheme(
|
||||
val surfaceTheme: SurfaceStyle,
|
||||
val isLight: Boolean,
|
||||
/** Full-bleed page background for the current surface theme. */
|
||||
val background: Brush,
|
||||
val surface: Color,
|
||||
val surfaceVariant: Color,
|
||||
val surfaceContainer: Color,
|
||||
val surfaceContainerHigh: Color,
|
||||
val surfaceContainerHighest: Color,
|
||||
val onBackground: Color,
|
||||
val onSurface: Color,
|
||||
val onSurfaceVariant: Color,
|
||||
val primary: Color,
|
||||
val onPrimary: Color,
|
||||
val primaryContainer: Color,
|
||||
val onPrimaryContainer: Color,
|
||||
val secondaryContainer: Color,
|
||||
val onSecondaryContainer: Color,
|
||||
val outline: Color,
|
||||
val outlineVariant: Color,
|
||||
val error: Color,
|
||||
val onError: Color,
|
||||
val scrim: Color,
|
||||
)
|
||||
|
||||
val LocalBaseUIColors = staticCompositionLocalOf<BaseUIColorScheme> {
|
||||
error("No BaseUIColorScheme provided. Wrap your UI in a BaseUITheme provider.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the [BaseUIColorScheme] for the selected [surfaceTheme] and accent.
|
||||
*
|
||||
* Light/dark is read from the surrounding [MaterialTheme] (which the app already drives
|
||||
* from the user's theme setting), and the accent comes from the user's chosen accent
|
||||
* colour. Everything else is derived from the surface theme palette.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberBaseUIColorScheme(
|
||||
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
|
||||
accentColor: Color? = null,
|
||||
): BaseUIColorScheme {
|
||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||
val accent = accentColor ?: MaterialTheme.colorScheme.primary
|
||||
return remember(surfaceTheme, isLight, accent) {
|
||||
baseUIColorScheme(surfaceTheme, isLight, accent)
|
||||
}
|
||||
}
|
||||
|
||||
fun baseUIColorScheme(
|
||||
surfaceTheme: SurfaceStyle,
|
||||
isLight: Boolean,
|
||||
accent: Color,
|
||||
): BaseUIColorScheme {
|
||||
val palette = surfacePalette(surfaceTheme, isLight)
|
||||
val onPrimary = if (accent.luminance() > 0.5f) Color(0xFF10161C) else Color.White
|
||||
val primaryContainer = lerp(palette.surface, accent, if (isLight) 0.16f else 0.32f)
|
||||
val onPrimaryContainer = if (isLight) {
|
||||
lerp(accent, Color.Black, 0.55f)
|
||||
} else {
|
||||
lerp(accent, Color.White, 0.7f)
|
||||
}
|
||||
val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f)
|
||||
|
||||
return BaseUIColorScheme(
|
||||
surfaceTheme = surfaceTheme,
|
||||
isLight = isLight,
|
||||
background = Brush.verticalGradient(
|
||||
listOf(palette.backgroundTop, palette.backgroundBottom),
|
||||
),
|
||||
surface = palette.surface,
|
||||
surfaceVariant = palette.surfaceVariant,
|
||||
surfaceContainer = palette.surfaceContainer,
|
||||
surfaceContainerHigh = palette.surfaceContainerHigh,
|
||||
surfaceContainerHighest = palette.surfaceContainerHighest,
|
||||
onBackground = palette.onBackground,
|
||||
onSurface = palette.onSurface,
|
||||
onSurfaceVariant = palette.onSurfaceVariant,
|
||||
primary = accent,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = palette.onSurface,
|
||||
outline = palette.outline,
|
||||
outlineVariant = palette.outlineVariant,
|
||||
error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373),
|
||||
onError = Color.White,
|
||||
scrim = Color.Black.copy(alpha = 0.42f),
|
||||
)
|
||||
}
|
||||
|
||||
private class SurfacePalette(
|
||||
val backgroundTop: Color,
|
||||
val backgroundBottom: Color,
|
||||
val surface: Color,
|
||||
val surfaceVariant: Color,
|
||||
val surfaceContainer: Color,
|
||||
val surfaceContainerHigh: Color,
|
||||
val surfaceContainerHighest: Color,
|
||||
val onBackground: Color,
|
||||
val onSurface: Color,
|
||||
val onSurfaceVariant: Color,
|
||||
val outline: Color,
|
||||
val outlineVariant: Color,
|
||||
)
|
||||
|
||||
private fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
|
||||
return if (isLight) {
|
||||
when (style) {
|
||||
SurfaceStyle.Glass -> SurfacePalette(
|
||||
backgroundTop = Color(0xFFEAF0F7),
|
||||
backgroundBottom = Color(0xFFD8E3F1),
|
||||
surface = Color(0xFFF8FBFF),
|
||||
surfaceVariant = Color(0xFFE4ECF5),
|
||||
surfaceContainer = Color(0xFFEEF3F9),
|
||||
surfaceContainerHigh = Color(0xFFF3F7FC),
|
||||
surfaceContainerHighest = Color(0xFFE1E9F3),
|
||||
onBackground = Color(0xFF1B2733),
|
||||
onSurface = Color(0xFF1B2733),
|
||||
onSurfaceVariant = Color(0xFF56677A),
|
||||
outline = Color(0xFFA9B8C8),
|
||||
outlineVariant = Color(0xFFD3DDE8),
|
||||
)
|
||||
|
||||
SurfaceStyle.Acrylic -> SurfacePalette(
|
||||
backgroundTop = Color(0xFFE6EAF0),
|
||||
backgroundBottom = Color(0xFFD6DCE5),
|
||||
surface = Color(0xFFF4F7FA),
|
||||
surfaceVariant = Color(0xFFE1E7EE),
|
||||
surfaceContainer = Color(0xFFEAEEF4),
|
||||
surfaceContainerHigh = Color(0xFFF1F4F8),
|
||||
surfaceContainerHighest = Color(0xFFDCE2E9),
|
||||
onBackground = Color(0xFF1C222B),
|
||||
onSurface = Color(0xFF1C222B),
|
||||
onSurfaceVariant = Color(0xFF545E6C),
|
||||
outline = Color(0xFFA6AEBC),
|
||||
outlineVariant = Color(0xFFD2D8E0),
|
||||
)
|
||||
|
||||
SurfaceStyle.Watery -> SurfacePalette(
|
||||
backgroundTop = Color(0xFFD6F0FF),
|
||||
backgroundBottom = Color(0xFFB4DFFA),
|
||||
surface = Color(0xFFF0FAFF),
|
||||
surfaceVariant = Color(0xFFDCF0FC),
|
||||
surfaceContainer = Color(0xFFE7F5FE),
|
||||
surfaceContainerHigh = Color(0xFFEFF9FF),
|
||||
surfaceContainerHighest = Color(0xFFCFE9F8),
|
||||
onBackground = Color(0xFF08324A),
|
||||
onSurface = Color(0xFF08324A),
|
||||
onSurfaceVariant = Color(0xFF3A6884),
|
||||
outline = Color(0xFF8FBBD4),
|
||||
outlineVariant = Color(0xFFC6E2F2),
|
||||
)
|
||||
|
||||
SurfaceStyle.Icy -> SurfacePalette(
|
||||
backgroundTop = Color(0xFFE8F7FF),
|
||||
backgroundBottom = Color(0xFFC9E8FB),
|
||||
surface = Color(0xFFF6FCFF),
|
||||
surfaceVariant = Color(0xFFE0F1FC),
|
||||
surfaceContainer = Color(0xFFEBF7FE),
|
||||
surfaceContainerHigh = Color(0xFFF3FBFF),
|
||||
surfaceContainerHighest = Color(0xFFD4EBFA),
|
||||
onBackground = Color(0xFF0B3A55),
|
||||
onSurface = Color(0xFF0B3A55),
|
||||
onSurfaceVariant = Color(0xFF3D6C88),
|
||||
outline = Color(0xFF93C0D9),
|
||||
outlineVariant = Color(0xFFC9E5F4),
|
||||
)
|
||||
|
||||
SurfaceStyle.Chrome -> SurfacePalette(
|
||||
backgroundTop = Color(0xFFE4E8ED),
|
||||
backgroundBottom = Color(0xFFD0D6DE),
|
||||
surface = Color(0xFFF4F7FA),
|
||||
surfaceVariant = Color(0xFFE0E5EB),
|
||||
surfaceContainer = Color(0xFFE9EDF2),
|
||||
surfaceContainerHigh = Color(0xFFF1F4F7),
|
||||
surfaceContainerHighest = Color(0xFFDBE0E7),
|
||||
onBackground = Color(0xFF1B2733),
|
||||
onSurface = Color(0xFF1B2733),
|
||||
onSurfaceVariant = Color(0xFF525C6A),
|
||||
outline = Color(0xFFA2AAB6),
|
||||
outlineVariant = Color(0xFFD0D5DC),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
when (style) {
|
||||
SurfaceStyle.Glass -> SurfacePalette(
|
||||
backgroundTop = Color(0xFF0E1217),
|
||||
backgroundBottom = Color(0xFF161C24),
|
||||
surface = Color(0xFF1E242C),
|
||||
surfaceVariant = Color(0xFF262E38),
|
||||
surfaceContainer = Color(0xFF1A2027),
|
||||
surfaceContainerHigh = Color(0xFF232A33),
|
||||
surfaceContainerHighest = Color(0xFF2C343E),
|
||||
onBackground = Color(0xFFE6EDF5),
|
||||
onSurface = Color(0xFFE6EDF5),
|
||||
onSurfaceVariant = Color(0xFF9AA7B6),
|
||||
outline = Color(0xFF46525F),
|
||||
outlineVariant = Color(0xFF2C343E),
|
||||
)
|
||||
|
||||
SurfaceStyle.Acrylic -> SurfacePalette(
|
||||
backgroundTop = Color(0xFF101317),
|
||||
backgroundBottom = Color(0xFF181C22),
|
||||
surface = Color(0xFF20262E),
|
||||
surfaceVariant = Color(0xFF282F39),
|
||||
surfaceContainer = Color(0xFF1C2229),
|
||||
surfaceContainerHigh = Color(0xFF252C35),
|
||||
surfaceContainerHighest = Color(0xFF2E3640),
|
||||
onBackground = Color(0xFFE4EAF1),
|
||||
onSurface = Color(0xFFE4EAF1),
|
||||
onSurfaceVariant = Color(0xFF98A2B0),
|
||||
outline = Color(0xFF48525E),
|
||||
outlineVariant = Color(0xFF2E3640),
|
||||
)
|
||||
|
||||
SurfaceStyle.Watery -> SurfacePalette(
|
||||
backgroundTop = Color(0xFF06121E),
|
||||
backgroundBottom = Color(0xFF0B1F31),
|
||||
surface = Color(0xFF0E2233),
|
||||
surfaceVariant = Color(0xFF153148),
|
||||
surfaceContainer = Color(0xFF0B1D2C),
|
||||
surfaceContainerHigh = Color(0xFF12293C),
|
||||
surfaceContainerHighest = Color(0xFF183449),
|
||||
onBackground = Color(0xFFD6EAF7),
|
||||
onSurface = Color(0xFFD6EAF7),
|
||||
onSurfaceVariant = Color(0xFF8FB0C6),
|
||||
outline = Color(0xFF3C5A72),
|
||||
outlineVariant = Color(0xFF183449),
|
||||
)
|
||||
|
||||
SurfaceStyle.Icy -> SurfacePalette(
|
||||
backgroundTop = Color(0xFF0C1620),
|
||||
backgroundBottom = Color(0xFF132331),
|
||||
surface = Color(0xFF16283A),
|
||||
surfaceVariant = Color(0xFF1E3448),
|
||||
surfaceContainer = Color(0xFF122130),
|
||||
surfaceContainerHigh = Color(0xFF1A2E42),
|
||||
surfaceContainerHighest = Color(0xFF213A50),
|
||||
onBackground = Color(0xFFDCEBF7),
|
||||
onSurface = Color(0xFFDCEBF7),
|
||||
onSurfaceVariant = Color(0xFF92B3CA),
|
||||
outline = Color(0xFF40607A),
|
||||
outlineVariant = Color(0xFF213A50),
|
||||
)
|
||||
|
||||
SurfaceStyle.Chrome -> SurfacePalette(
|
||||
backgroundTop = Color(0xFF101317),
|
||||
backgroundBottom = Color(0xFF181C22),
|
||||
surface = Color(0xFF232830),
|
||||
surfaceVariant = Color(0xFF2B313A),
|
||||
surfaceContainer = Color(0xFF1E232A),
|
||||
surfaceContainerHigh = Color(0xFF272D36),
|
||||
surfaceContainerHighest = Color(0xFF303742),
|
||||
onBackground = Color(0xFFE7ECF2),
|
||||
onSurface = Color(0xFFE7ECF2),
|
||||
onSurfaceVariant = Color(0xFF9BA5B2),
|
||||
outline = Color(0xFF4B5561),
|
||||
outlineVariant = Color(0xFF303742),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
/**
|
||||
* Shared scaffold for every preview of a base UI component.
|
||||
*
|
||||
* It mirrors the runtime environment the components expect:
|
||||
* - a [MaterialTheme] whose light/dark mode follows the preview's `uiMode`, so the
|
||||
* `@PreviewLightDark`-style annotations actually change the rendered colors,
|
||||
* - a [LocalBaseUITheme] built from that color scheme via [rememberBaseUITheme],
|
||||
* - a [Surface] matching the real app background, so layered highlights and shadows
|
||||
* render against the color they will actually sit on.
|
||||
*
|
||||
* Always render base components inside [BaseUIPreview]; several of them read
|
||||
* [LocalBaseUITheme] directly and would otherwise throw at composition time.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BaseUIPreview(
|
||||
modifier: Modifier = Modifier,
|
||||
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(),
|
||||
) {
|
||||
val theme = rememberBaseUITheme(surfaceTheme = surfaceTheme)
|
||||
CompositionLocalProvider(
|
||||
LocalBaseUITheme provides theme,
|
||||
LocalBaseUIColors provides theme.colors,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
color = theme.colors.surface,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a base UI component on a phone-sized canvas in both light and dark themes.
|
||||
*/
|
||||
@Preview(name = "Phone · Light", group = "Base UI devices", device = Devices.PHONE)
|
||||
@Preview(
|
||||
name = "Phone · Dark",
|
||||
group = "Base UI devices",
|
||||
device = Devices.PHONE,
|
||||
uiMode = UI_MODE_NIGHT_YES,
|
||||
)
|
||||
internal annotation class BaseUIPhonePreview
|
||||
|
||||
/**
|
||||
* Renders a base UI component on a desktop-sized canvas in both light and dark themes.
|
||||
*/
|
||||
@Preview(name = "Desktop · Light", group = "Base UI devices", device = Devices.DESKTOP)
|
||||
@Preview(
|
||||
name = "Desktop · Dark",
|
||||
group = "Base UI devices",
|
||||
device = Devices.DESKTOP,
|
||||
uiMode = UI_MODE_NIGHT_YES,
|
||||
)
|
||||
internal annotation class BaseUIDesktopPreview
|
||||
@ -27,6 +27,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -46,7 +47,10 @@ import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@ -57,7 +61,6 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
@ -549,244 +552,281 @@ fun ButtonGroupDivider(
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@BaseUIPhonePreview
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun ButtonsRow1Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
private fun ButtonsPreview() {
|
||||
BaseUIPreview {
|
||||
var liked by remember { mutableStateOf(false) }
|
||||
var likeCount by remember { mutableIntStateOf(128) }
|
||||
var following by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
OutlineButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Share", maxLines = 1, softWrap = false)
|
||||
}
|
||||
SecondaryButton(onClick = {}) {
|
||||
Text("Secondary", maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PrimaryButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
"Sign up",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
GhostButton(onClick = {}) {
|
||||
Text("Ghost", maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(onClick = {}, enabled = false) {
|
||||
Text("Disabled", maxLines = 1, softWrap = false)
|
||||
}
|
||||
SecondaryButton(onClick = {}, enabled = false) {
|
||||
Text("Disabled", maxLines = 1, softWrap = false)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PrimaryButton(onClick = {}, enabled = false) {
|
||||
Text("Disabled", maxLines = 1, softWrap = false)
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Forward", maxLines = 1, softWrap = false)
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxArrowSquareUp,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(
|
||||
onClick = {
|
||||
liked = !liked
|
||||
likeCount += if (liked) 1 else -1
|
||||
},
|
||||
) {
|
||||
OutlineButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(if (liked) "Liked" else "Like", maxLines = 1, softWrap = false)
|
||||
ButtonBadge(count = likeCount)
|
||||
}
|
||||
PrimaryButton(onClick = { following = !following }) {
|
||||
Text(
|
||||
if (following) "Following" else "Follow",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun IconButtonsPreview() {
|
||||
BaseUIPreview {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Icon buttons",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
IconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMagic,
|
||||
contentDescription = "Magic",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
GhostIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = "Favorite",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
PrimaryIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
SecondaryIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShare,
|
||||
contentDescription = "Share",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Variable icon button",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
VariableIconButton(
|
||||
variant = VariableIconButtonVariant.Outline,
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxDocumentText,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
VariableIconButton(
|
||||
variant = VariableIconButtonVariant.Ghost,
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
VariableIconButton(
|
||||
variant = VariableIconButtonVariant.Primary,
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMagic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
VariableIconButton(
|
||||
variant = VariableIconButtonVariant.Secondary,
|
||||
onClick = {},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun ButtonGroupPreview() {
|
||||
BaseUIPreview {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Button groups",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ButtonGroup {
|
||||
GroupButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxDocumentText,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Documents", maxLines = 1, softWrap = false)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
"Copy link",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Text("Export", maxLines = 1, softWrap = false)
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Login", maxLines = 1, softWrap = false)
|
||||
}
|
||||
PrimaryButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text(
|
||||
"Sign Up",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow2Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ButtonGroup {
|
||||
GroupButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxDocumentText,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Documents", maxLines = 1, softWrap = false)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxShare,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Export", maxLines = 1, softWrap = false)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow3Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Cancel", maxLines = 1, softWrap = false)
|
||||
}
|
||||
PrimaryButton(onClick = {}) {
|
||||
Text(
|
||||
"Done",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMagic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Text("Like", maxLines = 1, softWrap = false)
|
||||
ButtonBadge(count = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonsRow4Preview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Forward", maxLines = 1, softWrap = false)
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxArrowSquareUp,
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun ButtonStylesPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlineButton(onClick = {}) {
|
||||
Text("Outline", maxLines = 1, softWrap = false)
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
SecondaryButton(onClick = {}) {
|
||||
Text("Secondary", maxLines = 1, softWrap = false)
|
||||
}
|
||||
PrimaryButton(onClick = {}) {
|
||||
Text(
|
||||
"Primary",
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = {}) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxNext,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
ButtonBadge(count = 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,18 +20,38 @@ package dev.krtirtho.spotube.core.ui.base
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.fillMaxSize
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
|
||||
@Composable
|
||||
fun Card(
|
||||
@ -62,18 +82,90 @@ fun Card(
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
@androidx.compose.ui.tooling.preview.Preview
|
||||
private fun CardPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Card {
|
||||
Box(modifier = Modifier.padding(16.dp))
|
||||
BaseUIPreview {
|
||||
var connected by remember { mutableStateOf(false) }
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Card {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = if (connected) "Spotify connected" else "Connect a music source",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = if (connected) {
|
||||
"Streaming, downloads and scrobbling are ready."
|
||||
} else {
|
||||
"Sign in to stream, download and scrobble from your account."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlineButton(onClick = { connected = false }) {
|
||||
Text("Disconnect")
|
||||
}
|
||||
PrimaryButton(onClick = { connected = !connected }) {
|
||||
Text(if (connected) "Connected" else "Connect")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Midnight City",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = "M83 · Hurry Up, We're Dreaming",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
PrimaryIconButton(onClick = { playing = !playing }) {
|
||||
Icon(
|
||||
imageVector = if (playing) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = if (playing) "Pause" else "Play",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,17 +31,20 @@ 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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -56,7 +59,6 @@ import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class CheckBoxState {
|
||||
@ -207,47 +209,72 @@ fun CheckBox(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
fun CheckBoxPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.SELECTED, onClick = {})
|
||||
Text("Selected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.UNSELECTED, onClick = {})
|
||||
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {})
|
||||
Text("Indeterminate", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false)
|
||||
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun CheckBoxPreview() {
|
||||
BaseUIPreview {
|
||||
var wifi by remember { mutableStateOf(true) }
|
||||
var bluetooth by remember { mutableStateOf(false) }
|
||||
var downloads by remember { mutableStateOf(true) }
|
||||
|
||||
val allEnabled = wifi && bluetooth && downloads
|
||||
val anyEnabled = wifi || bluetooth || downloads
|
||||
val allState = when {
|
||||
allEnabled -> CheckBoxState.SELECTED
|
||||
anyEnabled -> CheckBoxState.INDETERMINATE
|
||||
else -> CheckBoxState.UNSELECTED
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CheckBoxPreviewRow(
|
||||
state = allState,
|
||||
label = "All playback sources",
|
||||
onClick = {
|
||||
val target = !allEnabled
|
||||
wifi = target
|
||||
bluetooth = target
|
||||
downloads = target
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
CheckBoxPreviewRow(
|
||||
state = if (wifi) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
label = "Wi-Fi",
|
||||
onClick = { wifi = !wifi },
|
||||
)
|
||||
CheckBoxPreviewRow(
|
||||
state = if (bluetooth) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
label = "Bluetooth",
|
||||
onClick = { bluetooth = !bluetooth },
|
||||
)
|
||||
CheckBoxPreviewRow(
|
||||
state = if (downloads) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||
label = "Download over cellular",
|
||||
onClick = { downloads = !downloads },
|
||||
)
|
||||
CheckBoxPreviewRow(CheckBoxState.SELECTED, "Disabled selected", enabled = false)
|
||||
CheckBoxPreviewRow(CheckBoxState.UNSELECTED, "Disabled unselected", enabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckBoxPreviewRow(
|
||||
state: CheckBoxState,
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CheckBox(state = state, onClick = onClick, enabled = enabled)
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,10 +27,14 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
@ -38,7 +42,9 @@ import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -50,6 +56,9 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
||||
|
||||
private val ChipTabMinHeight = 36.dp
|
||||
|
||||
@ -169,3 +178,62 @@ fun ChipTab(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun ChipTabsPreview() {
|
||||
BaseUIPreview {
|
||||
val tabs = listOf("All", "Songs", "Albums", "Playlists")
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
ChipTab(
|
||||
text = tab,
|
||||
selected = selected == index,
|
||||
onClick = { selected = index },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipTab(
|
||||
text = "Library",
|
||||
selected = true,
|
||||
onClick = {},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusicLibrary,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
ChipTab(
|
||||
text = "Downloads",
|
||||
selected = false,
|
||||
onClick = {},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxDocumentDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
ChipTab(
|
||||
text = "Disabled",
|
||||
selected = false,
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,11 +26,14 @@ import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -42,7 +45,6 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -52,7 +54,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@ -70,8 +72,6 @@ fun ThemedDialog(
|
||||
properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = properties,
|
||||
@ -93,90 +93,141 @@ fun ThemedDialog(
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = DefaultDialogMaxWidth)
|
||||
.shadow(
|
||||
elevation = dialogTheme.shadow.elevation,
|
||||
shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) },
|
||||
ambientColor = dialogTheme.shadow.ambientColor,
|
||||
spotColor = dialogTheme.shadow.spotColor,
|
||||
)
|
||||
.clip(dialogTheme.shape)
|
||||
.background(dialogTheme.background, dialogTheme.shape)
|
||||
.border(
|
||||
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
|
||||
dialogTheme.shape
|
||||
)
|
||||
.highlight(Color.White.copy(alpha = 0.08f)),
|
||||
) {
|
||||
Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) {
|
||||
if (title != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, top = 24.dp),
|
||||
) {
|
||||
title()
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
start = 24.dp,
|
||||
end = 24.dp,
|
||||
top = if (title == null) 24.dp else 0.dp,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (actions != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
ThemedDialogSurface(
|
||||
theme = theme,
|
||||
title = title,
|
||||
actions = actions,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
/**
|
||||
* The visual chrome of [ThemedDialog] without the platform [Dialog] window.
|
||||
*
|
||||
* Extracted so the dialog can be rendered inside previews (and reused elsewhere),
|
||||
* since the platform dialog lives in a separate window the preview canvas cannot show.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ThemedDialogSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
theme: BaseUITheme.DialogTheme? = null,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
actions: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(max = DefaultDialogMaxWidth)
|
||||
.shadow(
|
||||
elevation = dialogTheme.shadow.elevation,
|
||||
shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) },
|
||||
ambientColor = dialogTheme.shadow.ambientColor,
|
||||
spotColor = dialogTheme.shadow.spotColor,
|
||||
)
|
||||
.clip(dialogTheme.shape)
|
||||
.background(dialogTheme.background, dialogTheme.shape)
|
||||
.border(
|
||||
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
|
||||
dialogTheme.shape
|
||||
)
|
||||
.highlight(Color.White.copy(alpha = 0.08f)),
|
||||
) {
|
||||
Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) {
|
||||
if (title != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, top = 24.dp),
|
||||
) {
|
||||
title()
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
start = 24.dp,
|
||||
end = 24.dp,
|
||||
top = if (title == null) 24.dp else 0.dp,
|
||||
),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (actions != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun ThemedDialogPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
var show by remember { mutableStateOf(true) }
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
BaseUIPreview {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
PrimaryButton(
|
||||
onClick = { visible = true },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
PrimaryButton(onClick = { show = true }) {
|
||||
Text("Open Dialog")
|
||||
}
|
||||
if (show) {
|
||||
ThemedDialog(onDismissRequest = { show = false }) {
|
||||
Text("Open dialog")
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(LocalBaseUITheme.current.dialog.scrim)
|
||||
.clickable { visible = false },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ThemedDialogSurface(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "Sign out of Spotube?",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
OutlineButton(onClick = { visible = false }) { Text("Cancel") }
|
||||
PrimaryButton(onClick = { visible = false }) { Text("Sign out") }
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
"This is a themed dialog with glass-like gradient, shadow, and scale animation.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
text = "You can sign back in at any time. Your downloaded tracks stay on this device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,8 +26,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -38,7 +40,9 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -47,6 +51,12 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
|
||||
@Composable
|
||||
fun DropdownMenu(
|
||||
@ -63,25 +73,32 @@ fun DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
offset = offset,
|
||||
modifier = modifier
|
||||
.widthIn(min = 180.dp)
|
||||
.shadow(
|
||||
elevation = menuTheme.shadow.elevation,
|
||||
shape = menuTheme.shape,
|
||||
ambientColor = menuTheme.shadow.ambientColor,
|
||||
spotColor = menuTheme.shadow.spotColor,
|
||||
)
|
||||
.clip(menuTheme.shape)
|
||||
.background(menuTheme.background, menuTheme.shape)
|
||||
.border(
|
||||
BorderStroke(menuTheme.border.width, menuTheme.border.color),
|
||||
menuTheme.shape,
|
||||
),
|
||||
modifier = modifier.dropdownMenuContainer(menuTheme),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The container styling of [DropdownMenu], split out so it can be reused by the
|
||||
* material3 popup and by previews, which cannot render the popup window itself.
|
||||
*/
|
||||
internal fun Modifier.dropdownMenuContainer(theme: BaseUITheme.DropdownMenuTheme): Modifier =
|
||||
this
|
||||
.widthIn(min = 180.dp)
|
||||
.shadow(
|
||||
elevation = theme.shadow.elevation,
|
||||
shape = theme.shape,
|
||||
ambientColor = theme.shadow.ambientColor,
|
||||
spotColor = theme.shadow.spotColor,
|
||||
)
|
||||
.clip(theme.shape)
|
||||
.background(theme.background, theme.shape)
|
||||
.border(
|
||||
BorderStroke(theme.border.width, theme.border.color),
|
||||
theme.shape,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DropdownMenuItem(
|
||||
text: String,
|
||||
@ -89,6 +106,8 @@ fun DropdownMenuItem(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
subtitle: String? = null,
|
||||
selected: Boolean = false,
|
||||
theme: BaseUITheme.DropdownMenuTheme? = null,
|
||||
) {
|
||||
@ -125,8 +144,9 @@ fun DropdownMenuItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (leadingIcon != null) {
|
||||
Icon(
|
||||
when {
|
||||
leadingContent != null -> leadingContent()
|
||||
leadingIcon != null -> Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
@ -138,18 +158,28 @@ fun DropdownMenuItem(
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (enabled) {
|
||||
menuTheme.itemForeground
|
||||
} else {
|
||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (enabled) {
|
||||
menuTheme.itemForeground
|
||||
} else {
|
||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||
},
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f),
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,3 +200,78 @@ fun DropdownMenuDivider(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun DropdownMenuPreview() {
|
||||
BaseUIPreview {
|
||||
var expanded by remember { mutableStateOf(true) }
|
||||
var lastAction by remember { mutableStateOf("None") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlineButton(onClick = { expanded = !expanded }) {
|
||||
Text(if (expanded) "Hide menu" else "Show menu")
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Column(
|
||||
modifier = Modifier.dropdownMenuContainer(
|
||||
LocalBaseUITheme.current.dropdownMenu,
|
||||
),
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = "Play next",
|
||||
leadingIcon = Iconsax.IconsaxPlay,
|
||||
onClick = {
|
||||
lastAction = "Play next"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = "Add to queue",
|
||||
leadingIcon = Iconsax.IconsaxAddSquare,
|
||||
onClick = {
|
||||
lastAction = "Add to queue"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = "Add to favorites",
|
||||
leadingIcon = Iconsax.IconsaxHeart,
|
||||
selected = lastAction == "Add to favorites",
|
||||
onClick = {
|
||||
lastAction = "Add to favorites"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuDivider()
|
||||
DropdownMenuItem(
|
||||
text = "Share",
|
||||
leadingIcon = Iconsax.IconsaxShare,
|
||||
onClick = {
|
||||
lastAction = "Share"
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = "Remove from playlist",
|
||||
leadingIcon = Iconsax.IconsaxTrash,
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Last action: $lastAction",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,20 +25,31 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.Spacer
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
|
||||
@Composable
|
||||
fun ListRowTile(
|
||||
@ -88,7 +99,13 @@ fun ListRowTile(
|
||||
|
||||
val mod = modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(shadowDef.elevation, shape, shadowDef.clip, shadowDef.ambientColor, shadowDef.spotColor)
|
||||
.shadow(
|
||||
shadowDef.elevation,
|
||||
shape,
|
||||
shadowDef.clip,
|
||||
shadowDef.ambientColor,
|
||||
shadowDef.spotColor
|
||||
)
|
||||
.clip(shape)
|
||||
.border(borderDef.width, borderDef.color, shape)
|
||||
.background(background, shape)
|
||||
@ -130,3 +147,94 @@ fun ListRowTile(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class ListRowPreviewTrack(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val duration: String,
|
||||
)
|
||||
|
||||
private val ListRowPreviewTracks = listOf(
|
||||
ListRowPreviewTrack("Midnight City", "M83 · Hurry Up, We're Dreaming", "4:03"),
|
||||
ListRowPreviewTrack("Odoriko", "Vaundy · strobo", "3:37"),
|
||||
ListRowPreviewTrack("Instant Crush", "Daft Punk · Random Access Memories", "5:37"),
|
||||
)
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun ListRowTilePreview() {
|
||||
BaseUIPreview {
|
||||
var selectedIndex by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
ListRowPreviewTracks.forEachIndexed { index, track ->
|
||||
ListRowTile(
|
||||
selected = selectedIndex == index,
|
||||
onClick = { selectedIndex = index },
|
||||
leading = { ListRowTilePreviewArtwork() },
|
||||
title = {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = track.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Text(
|
||||
text = track.duration,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ListRowTile(
|
||||
enabled = false,
|
||||
title = {
|
||||
Text(
|
||||
text = "Add to queue",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
)
|
||||
},
|
||||
subtitle = {
|
||||
Text(
|
||||
text = "Unavailable offline",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListRowTilePreviewArtwork() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxMusic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,7 @@ 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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@ -38,9 +39,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -48,7 +50,6 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val RadioSize = 22.dp
|
||||
@ -145,33 +146,38 @@ fun Radio(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun RadioPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Radio(selected = true, onClick = {})
|
||||
Text("Selected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Radio(selected = false, onClick = {})
|
||||
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
BaseUIPreview {
|
||||
val options = listOf("Automatic", "High", "Lossless", "Data saver")
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
options.forEachIndexed { index, label ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Radio(
|
||||
selected = selected == index,
|
||||
onClick = { selected = index },
|
||||
)
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Radio(selected = true, onClick = {}, enabled = false)
|
||||
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A theme-aware replacement for [Scaffold].
|
||||
*
|
||||
* Material's [Scaffold] paints Material's own surface colour, which hides the selected
|
||||
* [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass). [BaseScaffold] instead:
|
||||
*
|
||||
* - is **transparent by default**, so the app-wide themed backdrop shows through, or
|
||||
* - paints a chosen [surface] material locally, or
|
||||
* - uses an explicit [containerColor] when one is given.
|
||||
*
|
||||
* It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host,
|
||||
* floating action button) and hands the resolved [PaddingValues] to [content].
|
||||
*/
|
||||
@Composable
|
||||
fun BaseScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
topBar: @Composable () -> Unit = {},
|
||||
bottomBar: @Composable () -> Unit = {},
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
floatingActionButton: @Composable () -> Unit = {},
|
||||
surface: SurfaceStyle? = null,
|
||||
containerColor: Color? = null,
|
||||
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = topBar,
|
||||
bottomBar = bottomBar,
|
||||
snackbarHost = snackbarHost,
|
||||
floatingActionButton = floatingActionButton,
|
||||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = contentWindowInsets,
|
||||
) { innerPadding ->
|
||||
val backgroundModifier = when {
|
||||
containerColor != null -> Modifier.background(containerColor)
|
||||
surface != null -> {
|
||||
val isLight = LocalBaseUIColors.current.isLight
|
||||
Modifier.baseSurfaceBackdrop(surface, isLight)
|
||||
}
|
||||
|
||||
else -> Modifier
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) {
|
||||
content(innerPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun BaseScaffoldPreview() {
|
||||
BaseUIPreview {
|
||||
val style = LocalBaseUITheme.current.surfaceTheme
|
||||
BaseScaffold(
|
||||
surface = style,
|
||||
topBar = {
|
||||
Text(
|
||||
text = "Title",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Text(
|
||||
text = "Bottom bar",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Content")
|
||||
Text("Sits on the selected surface theme.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,8 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@ -40,10 +42,8 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@ -61,7 +61,6 @@ import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
@ -311,3 +310,60 @@ fun Slider(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun SliderPreview() {
|
||||
BaseUIPreview {
|
||||
var volume by remember { mutableFloatStateOf(0.65f) }
|
||||
var progress by remember { mutableFloatStateOf(0.3f) }
|
||||
var quality by remember { mutableFloatStateOf(2f) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
||||
) {
|
||||
SliderPreviewField(title = "Volume", value = "${(volume * 100).toInt()}%") {
|
||||
Slider(value = volume, onValueChange = { volume = it })
|
||||
}
|
||||
SliderPreviewField(title = "Progress", value = "${(progress * 100).toInt()}%") {
|
||||
Slider(value = progress, onValueChange = { progress = it })
|
||||
}
|
||||
SliderPreviewField(title = "Quality", value = "${quality.toInt()} / 4") {
|
||||
Slider(
|
||||
value = quality,
|
||||
onValueChange = { quality = it },
|
||||
valueRange = 0f..4f,
|
||||
steps = 3,
|
||||
)
|
||||
}
|
||||
SliderPreviewField(title = "Disabled", value = "40%") {
|
||||
Slider(value = 0.4f, onValueChange = {}, enabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SliderPreviewField(
|
||||
title: String,
|
||||
value: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,536 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.random.Random
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The Frutiger-Aero material families available to [BaseSurface].
|
||||
*
|
||||
* - [Watery]: deep aqua water with caustic light and bubbles.
|
||||
* - [Icy]: pale frosted ice with crystalline facets and frost.
|
||||
* - [Chrome]: brushed metal with a hard specular reflection.
|
||||
* - [Acrylic]: translucent tinted panel with fine grain.
|
||||
* - [Glass]: clear, glossy glass with a strong sheen.
|
||||
*
|
||||
* Each style doubles as a full app theme (see [BaseUIColorScheme]).
|
||||
*/
|
||||
@Serializable
|
||||
enum class SurfaceStyle {
|
||||
Watery,
|
||||
Icy,
|
||||
Chrome,
|
||||
Acrylic,
|
||||
Glass,
|
||||
}
|
||||
|
||||
/**
|
||||
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials.
|
||||
*
|
||||
* Distinct from [androidx.compose.material3.Surface] — this draws decorative material
|
||||
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
|
||||
* [LocalContentColor] so text/content inside stays legible.
|
||||
*/
|
||||
@Composable
|
||||
fun BaseSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
style: SurfaceStyle = SurfaceStyle.Glass,
|
||||
shape: Shape = RectangleShape,
|
||||
elevation: Dp? = null,
|
||||
contentColor: Color? = null,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
contentAlignment: Alignment = Alignment.TopStart,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val resolvedElevation = elevation ?: when (style) {
|
||||
SurfaceStyle.Watery -> 14.dp
|
||||
SurfaceStyle.Icy -> 12.dp
|
||||
SurfaceStyle.Chrome -> 10.dp
|
||||
SurfaceStyle.Acrylic -> 16.dp
|
||||
SurfaceStyle.Glass -> 12.dp
|
||||
}
|
||||
val resolvedContentColor = contentColor ?: when (style) {
|
||||
SurfaceStyle.Watery -> Color.White
|
||||
SurfaceStyle.Icy -> if (isLight) Color(0xFF0B3A55) else Color(0xFFDCEBF7)
|
||||
SurfaceStyle.Chrome -> if (isLight) Color(0xFF1B2733) else Color(0xFFE7ECF2)
|
||||
SurfaceStyle.Acrylic, SurfaceStyle.Glass -> onSurface
|
||||
}
|
||||
val ambient = when (style) {
|
||||
SurfaceStyle.Watery -> Color(0xFF0E6FB0).copy(alpha = 0.38f)
|
||||
SurfaceStyle.Icy -> Color(0xFF3FA9E0).copy(alpha = 0.3f)
|
||||
SurfaceStyle.Chrome -> Color.Black.copy(alpha = 0.3f)
|
||||
SurfaceStyle.Acrylic -> onSurface.copy(alpha = 0.22f)
|
||||
SurfaceStyle.Glass -> onSurface.copy(alpha = 0.18f)
|
||||
}
|
||||
val spot = ambient.copy(alpha = (ambient.alpha + 0.08f).coerceAtMost(1f))
|
||||
|
||||
val borderBrush = when (style) {
|
||||
SurfaceStyle.Watery -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Icy -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Chrome -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Acrylic -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
|
||||
SurfaceStyle.Glass -> Brush.verticalGradient(
|
||||
listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)),
|
||||
)
|
||||
}
|
||||
|
||||
// Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn
|
||||
// behind the node and, because the panel is see-through, shows up as a dark inset
|
||||
// shape. Their depth comes from the edge, sheen and bevel instead.
|
||||
val castsShadow = style != SurfaceStyle.Glass && style != SurfaceStyle.Acrylic
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (castsShadow) {
|
||||
Modifier.shadow(
|
||||
elevation = resolvedElevation,
|
||||
shape = shape,
|
||||
ambientColor = ambient,
|
||||
spotColor = spot,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clip(shape)
|
||||
.drawWithCache {
|
||||
onDrawBehind { drawSurfaceMaterial(style, isLight) }
|
||||
}
|
||||
.border(BorderStroke(1.dp, borderBrush), shape)
|
||||
.padding(contentPadding),
|
||||
contentAlignment = contentAlignment,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the full-bleed material of [style] as a page background: the material body plus
|
||||
* its own sheen and reflections, but no panel bevel (a backdrop has no edges).
|
||||
*/
|
||||
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
|
||||
drawWithCache {
|
||||
onDrawBehind { drawSurfaceBase(style, isLight) }
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSurfaceMaterial(style: SurfaceStyle, isLight: Boolean) {
|
||||
drawSurfaceBase(style, isLight)
|
||||
drawInnerBevel(isLight)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawSurfaceBase(style: SurfaceStyle, isLight: Boolean) {
|
||||
when (style) {
|
||||
SurfaceStyle.Watery -> drawWatery(isLight)
|
||||
SurfaceStyle.Icy -> drawIcy(isLight)
|
||||
SurfaceStyle.Chrome -> drawChrome(isLight)
|
||||
SurfaceStyle.Acrylic -> drawAcrylic(isLight)
|
||||
SurfaceStyle.Glass -> drawGlass(isLight)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGlass(isLight: Boolean) {
|
||||
// See-through glass: a faint blue-tinted fill so whatever is behind shows through,
|
||||
// defined by its bright edge, sheen and shadow rather than an opaque body.
|
||||
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
listOf(
|
||||
tint.copy(alpha = if (isLight) 0.4f else 0.2f),
|
||||
tint.copy(alpha = if (isLight) 0.24f else 0.1f),
|
||||
),
|
||||
),
|
||||
)
|
||||
drawGloss(if (isLight) 0.55f else 0.16f)
|
||||
drawBottomReflection(if (isLight) 0.4f else 0.1f)
|
||||
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawAcrylic(isLight: Boolean) {
|
||||
// See-through acrylic: a translucent tinted sheet with a fine grain.
|
||||
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
listOf(
|
||||
tint.copy(alpha = if (isLight) 0.45f else 0.22f),
|
||||
tint.copy(alpha = if (isLight) 0.28f else 0.12f),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// Fine grain so the panel reads as a translucent acrylic sheet.
|
||||
val random = Random(23)
|
||||
repeat(180) {
|
||||
val light = random.nextBoolean()
|
||||
drawCircle(
|
||||
color = if (light) {
|
||||
Color.White.copy(alpha = if (isLight) 0.16f else 0.06f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f)
|
||||
},
|
||||
radius = 0.7f,
|
||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
||||
)
|
||||
}
|
||||
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawChrome(isLight: Boolean) {
|
||||
if (isLight) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color(0xFFF8FBFF),
|
||||
0.16f to Color(0xFFD6DEE7),
|
||||
0.4f to Color(0xFF8E99A8),
|
||||
0.5f to Color(0xFFEDF2F7),
|
||||
0.62f to Color(0xFF9AA5B4),
|
||||
1f to Color(0xFF6C7787),
|
||||
),
|
||||
)
|
||||
// Hard specular reflection near the top.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.85f), Color.Transparent),
|
||||
startY = size.height * 0.05f,
|
||||
endY = size.height * 0.22f,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Dark gunmetal for the night variant.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
0f to Color(0xFF3B424C),
|
||||
0.16f to Color(0xFF2A303A),
|
||||
0.4f to Color(0xFF1B2028),
|
||||
0.5f to Color(0xFF343B45),
|
||||
0.62f to Color(0xFF232830),
|
||||
1f to Color(0xFF141820),
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.18f), Color.Transparent),
|
||||
startY = size.height * 0.05f,
|
||||
endY = size.height * 0.22f,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Darkened lower edge for the metal bevel.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)),
|
||||
startY = size.height * 0.78f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawIcy(isLight: Boolean) {
|
||||
val colors = if (isLight) {
|
||||
listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
|
||||
} else {
|
||||
// Snowy-night ice: dim slate-blue with faint frost.
|
||||
listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
|
||||
}
|
||||
drawRect(brush = Brush.verticalGradient(colors))
|
||||
|
||||
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
|
||||
|
||||
// Crystalline facets (kept inset so they are never clipped by the shape).
|
||||
val random = Random(37)
|
||||
repeat(4) {
|
||||
val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f)
|
||||
val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f)
|
||||
val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f)
|
||||
val path = Path().apply {
|
||||
moveTo(x, y)
|
||||
lineTo(x + s, y + s * 0.4f)
|
||||
lineTo(x + s * 0.4f, y + s)
|
||||
close()
|
||||
}
|
||||
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
|
||||
}
|
||||
|
||||
// Frost speckles.
|
||||
repeat(90) {
|
||||
drawCircle(
|
||||
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f),
|
||||
radius = 0.8f,
|
||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
||||
)
|
||||
}
|
||||
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawWatery(isLight: Boolean) {
|
||||
val colors = if (isLight) {
|
||||
listOf(Color(0xFFA6E3FF), Color(0xFF3FB6EA), Color(0xFF0E6FB0))
|
||||
} else {
|
||||
// Moonlit night water: deep blue with a pale silver sheen.
|
||||
listOf(Color(0xFF1C3A5E), Color(0xFF102942), Color(0xFF071827))
|
||||
}
|
||||
drawRect(brush = Brush.verticalGradient(colors))
|
||||
|
||||
val sheen = if (isLight) Color.White else Color(0xFFB9D2F0)
|
||||
|
||||
// Caustic / moonlight pooling from the surface.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(sheen.copy(alpha = if (isLight) 0.35f else 0.22f), Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.5f,
|
||||
),
|
||||
)
|
||||
|
||||
val random = Random(53)
|
||||
repeat(5) {
|
||||
val rx = size.width * (0.12f + random.nextFloat() * 0.2f)
|
||||
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
|
||||
val cy = rx * 0.5f + random.nextFloat() * (size.height * 0.6f - rx).coerceAtLeast(0f)
|
||||
drawOval(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(sheen.copy(alpha = if (isLight) 0.3f else 0.18f), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = rx,
|
||||
),
|
||||
topLeft = Offset(cx - rx, cy - rx * 0.5f),
|
||||
size = Size(rx * 2f, rx),
|
||||
)
|
||||
}
|
||||
|
||||
drawBubbles(seed = 71, count = 6, alpha = if (isLight) 0.4f else 0.22f)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * heightFraction,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBottomReflection(alpha: Float) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.White.copy(alpha = alpha)),
|
||||
startY = size.height * 0.7f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A bright inner top edge plus a shaded inner bottom edge, giving the pane a glass bevel. */
|
||||
private fun DrawScope.drawInnerBevel(isLight: Boolean) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.1f,
|
||||
),
|
||||
)
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = if (isLight) 0.08f else 0.28f)),
|
||||
startY = size.height * 0.9f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float) {
|
||||
val random = Random(seed)
|
||||
repeat(count) {
|
||||
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f)
|
||||
// Keep bubbles inset so they are never clipped into hard arcs at the edges.
|
||||
val cx = radius + random.nextFloat() * (size.width - 2 * radius).coerceAtLeast(0f)
|
||||
val cy = radius + random.nextFloat() * (size.height - 2 * radius).coerceAtLeast(0f)
|
||||
val center = Offset(cx, cy)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color.White.copy(alpha = alpha), Color.White.copy(alpha = 0f)),
|
||||
center = center,
|
||||
radius = radius,
|
||||
),
|
||||
radius = radius,
|
||||
center = center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun BaseSurfacePreview() {
|
||||
BaseUIPreview {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
BaseSurface(
|
||||
style = style,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(84.dp),
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun BaseSurfaceGridPreview() {
|
||||
BaseUIPreview {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.chunked(2).forEach { rowStyles ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
rowStyles.forEach { style ->
|
||||
BaseSurface(
|
||||
style = style,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(140.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = "Frutiger Aero",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(2 - rowStyles.size) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@BaseUIDesktopPreview
|
||||
@Composable
|
||||
private fun SurfaceThemeBackdropPreview() {
|
||||
val isLight = LocalBaseUIColors.current.isLight
|
||||
val onSurface = LocalBaseUIColors.current.onSurface
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
SurfaceStyle.entries.forEach { style ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(66.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.baseSurfaceBackdrop(style, isLight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = style.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (style == SurfaceStyle.Watery) Color.White else onSurface,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -31,19 +31,26 @@ 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.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -55,11 +62,11 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
import dev.krtirtho.spotube.resources.iconsax.User
|
||||
|
||||
private val TextFieldMinHeight = 44.dp
|
||||
|
||||
@ -201,43 +208,84 @@ fun TextField(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
fun TextFieldPreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
TextField(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
value = "Twenty One Pilots",
|
||||
onValueChange = {},
|
||||
placeholder = { Text("Placeholder") },
|
||||
label = {
|
||||
Text(
|
||||
"Search Field",
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search Icon",
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = "Clear Icon",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
private fun TextFieldPreview() {
|
||||
BaseUIPreview {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var query by remember { mutableStateOf("Twenty One Pilots") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("you@example.com") },
|
||||
label = { TextFieldPreviewLabel("Email address") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Search songs, artists, albums...") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxSearchBroken,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxTrash,
|
||||
contentDescription = "Clear",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = "not-an-email",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { TextFieldPreviewLabel("Error state") },
|
||||
isError = true,
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = "Read only value",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { TextFieldPreviewLabel("Disabled") },
|
||||
enabled = false,
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextFieldPreviewLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
@ -31,6 +31,9 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -38,11 +41,12 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -53,7 +57,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
@ -239,26 +242,56 @@ fun Toggle(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun TogglePreview() {
|
||||
MaterialTheme {
|
||||
val theme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Toggle(checked = true, onCheckedChange = {})
|
||||
Toggle(checked = false, onCheckedChange = {})
|
||||
Toggle(checked = true, onCheckedChange = null)
|
||||
Toggle(checked = false, onCheckedChange = null, enabled = false)
|
||||
}
|
||||
}
|
||||
BaseUIPreview {
|
||||
var scrobbling by remember { mutableStateOf(true) }
|
||||
var offlineMode by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
TogglePreviewRow(
|
||||
label = "Scrobble listened tracks",
|
||||
checked = scrobbling,
|
||||
onCheckedChange = { scrobbling = it },
|
||||
)
|
||||
TogglePreviewRow(
|
||||
label = "Offline mode",
|
||||
checked = offlineMode,
|
||||
onCheckedChange = { offlineMode = it },
|
||||
)
|
||||
TogglePreviewRow(
|
||||
label = "Discord rich presence",
|
||||
checked = true,
|
||||
enabled = false,
|
||||
onCheckedChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TogglePreviewRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Toggle(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,88 @@
|
||||
package dev.krtirtho.spotube.core.ui.base
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
fun Modifier.highlight(color: Color): Modifier =
|
||||
drawWithCache {
|
||||
val highlightBrush = Brush.verticalGradient(
|
||||
colors = listOf(color, Color.Transparent),
|
||||
// Frutiger-Aero glass: a bright specular sheen across the top half plus a
|
||||
// faint reflection rising from the bottom edge.
|
||||
val topGloss = Brush.verticalGradient(
|
||||
colors = listOf(color, color.copy(alpha = color.alpha * 0.25f), Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.5f,
|
||||
endY = size.height * 0.55f,
|
||||
)
|
||||
val bottomReflection = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, color.copy(alpha = color.alpha * 0.3f)),
|
||||
startY = size.height * 0.72f,
|
||||
endY = size.height,
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(
|
||||
brush = highlightBrush,
|
||||
brush = topGloss,
|
||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
||||
size = size,
|
||||
)
|
||||
drawRect(
|
||||
brush = bottomReflection,
|
||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
||||
size = size,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@BaseUIPhonePreview
|
||||
@Composable
|
||||
private fun HighlightModifierPreview() {
|
||||
BaseUIPreview {
|
||||
var highlighted by remember { mutableStateOf(true) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.then(
|
||||
if (highlighted) {
|
||||
Modifier.highlight(Color.White.copy(alpha = 0.45f))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.clickable { highlighted = !highlighted },
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -82,6 +82,8 @@ data class AdaptiveMenuItem(
|
||||
val enabled: Boolean = true,
|
||||
val selected: Boolean = false,
|
||||
val dividerBefore: Boolean = false,
|
||||
val subtitle: String? = null,
|
||||
val leadingContent: (@Composable () -> Unit)? = null,
|
||||
)
|
||||
|
||||
enum class HeaderDisplayMode {
|
||||
@ -156,6 +158,8 @@ fun AdaptiveDropdownBottomSheet(
|
||||
enabled = item.enabled,
|
||||
selected = item.selected,
|
||||
leadingIcon = item.icon,
|
||||
leadingContent = item.leadingContent,
|
||||
subtitle = item.subtitle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -261,33 +265,32 @@ private fun AdaptiveBottomSheetContent(
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (item.icon != null) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
} else if (item.selected) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else if (hasSelection) {
|
||||
Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
item.icon != null -> Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
|
||||
item.selected -> Icon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
@ -297,9 +300,16 @@ private fun AdaptiveBottomSheetContent(
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.selected && item.icon != null) {
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
@ -317,7 +327,7 @@ private fun AdaptiveBottomSheetContent(
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||
Scaffold {
|
||||
BaseScaffold {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = listOf(
|
||||
AdaptiveMenuItem(
|
||||
|
||||
@ -27,7 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -103,7 +103,7 @@ fun CollectionView(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
|
||||
@ -37,7 +37,7 @@ import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@ -278,7 +278,7 @@ private fun ErrorDisplayPreview() {
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorLogDialogPreview() {
|
||||
Scaffold {
|
||||
BaseScaffold {
|
||||
ErrorLogDialog(
|
||||
errorMessage = "This is an error message",
|
||||
onDismiss = {},
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||
|
||||
/**
|
||||
* The shared navigation item used by every sidebar in the app (the main app sidebar
|
||||
* and the settings sidebar), so they stay visually identical.
|
||||
*
|
||||
* @param icon leading icon of the item.
|
||||
* @param badge optional overlay drawn on the icon (for example a download indicator).
|
||||
*/
|
||||
@Composable
|
||||
fun SidebarItem(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
expanded: Boolean = true,
|
||||
badge: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
val itemContent: @Composable RowScope.() -> Unit = {
|
||||
Box(modifier = Modifier.size(16.dp)) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
badge?.invoke(this)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
style = LocalTextStyle.current.copy(
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
||||
}
|
||||
|
||||
val buttonModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
val contentPadding = PaddingValues(horizontal = 5.dp)
|
||||
|
||||
if (selected) {
|
||||
SecondaryButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.secondary
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
content = itemContent,
|
||||
)
|
||||
} else {
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.outline
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
hoverOnly = true,
|
||||
content = itemContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared section heading used above groups of [SidebarItem]s.
|
||||
*/
|
||||
@Composable
|
||||
fun SidebarSectionLabel(
|
||||
text: String,
|
||||
expanded: Boolean = true,
|
||||
) {
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -48,7 +48,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -1035,7 +1035,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
@Composable
|
||||
@Preview
|
||||
private fun TrackListPreview() {
|
||||
Scaffold {
|
||||
BaseScaffold {
|
||||
TrackList(
|
||||
tracks = previewTracks(),
|
||||
hasMore = true,
|
||||
|
||||
@ -38,7 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@ -189,7 +189,7 @@ fun PlayableCard(
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun PlayableCardPreview() {
|
||||
Scaffold {
|
||||
BaseScaffold {
|
||||
PlayableCard(
|
||||
title = "Sample Title",
|
||||
subtitle = "Sample Subtitle",
|
||||
|
||||
@ -34,7 +34,7 @@ import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -148,7 +148,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
|
||||
@ -42,7 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
@ -118,7 +118,7 @@ fun ArtistScreen(
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = { ApplicationMainBar() }
|
||||
) { innerPadding ->
|
||||
when (val currentState = state) {
|
||||
|
||||
@ -33,7 +33,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -51,6 +50,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
@ -67,6 +67,30 @@ import org.koin.compose.viewmodel.koinViewModel
|
||||
@Composable
|
||||
fun BlacklistScreen() {
|
||||
val viewModel = koinViewModel<BlacklistViewModel>()
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Blacklist") },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
BlacklistContent(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blacklist UI without its own [BaseScaffold] / app bar, so it can be embedded
|
||||
* as a tab in the large-screen settings layout as well as hosted by [BlacklistScreen].
|
||||
*/
|
||||
@Composable
|
||||
internal fun BlacklistContent(
|
||||
viewModel: BlacklistViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blacklistedTracks by viewModel.blacklistedTracks.collectAsStateWithLifecycle()
|
||||
val blacklistedArtists by viewModel.blacklistedArtists.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -102,92 +126,82 @@ fun BlacklistScreen() {
|
||||
val isEmpty = filteredTracks.isEmpty() && filteredArtists.isEmpty()
|
||||
val hasActiveFilter = normalizedQuery.isNotBlank()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text("Blacklist") },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = filterQuery,
|
||||
onValueChange = { filterQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Filter blacklist...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
TextField(
|
||||
value = filterQuery,
|
||||
onValueChange = { filterQuery = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Filter blacklist...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if (isEmpty) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (hasActiveFilter) {
|
||||
"No blacklisted items match '$filterQuery'"
|
||||
} else {
|
||||
"No blacklisted items"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
bottom = 16.dp + shellBottomInset,
|
||||
),
|
||||
) {
|
||||
if (filteredTracks.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Tracks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredTracks, key = { "track_${it.id}" }) { track ->
|
||||
BlacklistedTrackRow(
|
||||
track = track,
|
||||
onRemove = { viewModel.toggleTrack(track) },
|
||||
)
|
||||
}
|
||||
if (isEmpty) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (hasActiveFilter) {
|
||||
"No blacklisted items match '$filterQuery'"
|
||||
} else {
|
||||
"No blacklisted items"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
bottom = 16.dp + shellBottomInset,
|
||||
),
|
||||
) {
|
||||
if (filteredTracks.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Tracks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredTracks, key = { "track_${it.id}" }) { track ->
|
||||
BlacklistedTrackRow(
|
||||
track = track,
|
||||
onRemove = { viewModel.toggleTrack(track) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredArtists.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Artists",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredArtists, key = { "artist_${it.id}" }) { artist ->
|
||||
BlacklistedArtistRow(
|
||||
artist = artist,
|
||||
onRemove = { viewModel.toggleArtist(artist) },
|
||||
)
|
||||
}
|
||||
if (filteredArtists.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "Artists",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(filteredArtists, key = { "artist_${it.id}" }) { artist ->
|
||||
BlacklistedArtistRow(
|
||||
artist = artist,
|
||||
onRemove = { viewModel.toggleArtist(artist) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@ -77,7 +77,7 @@ fun DevicesScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
|
||||
@ -45,7 +45,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -107,7 +107,7 @@ fun RemoteControlScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight(),
|
||||
topBar = {
|
||||
|
||||
@ -35,7 +35,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -120,7 +120,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = false,
|
||||
|
||||
@ -32,7 +32,7 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
@ -71,7 +71,7 @@ fun JamScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
|
||||
@ -27,7 +27,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@ -57,7 +57,7 @@ fun LibraryScreen(
|
||||
val searchMode by libraryState.searchMode.collectAsState()
|
||||
val isLargeScreen = appShellViewModel.useSidebar()
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
ApplicationMainBar(
|
||||
|
||||
@ -36,7 +36,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -71,7 +71,7 @@ fun LyricsScreen(
|
||||
val positionMillis by viewModel.positionMillisFlow.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = onClose == null,
|
||||
|
||||
@ -17,6 +17,11 @@
|
||||
|
||||
package dev.krtirtho.spotube.modules.plugin
|
||||
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.plugin_logo_lrclib
|
||||
import spotube.composeapp.generated.resources.plugin_logo_newpipe
|
||||
|
||||
val NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN = PluginEntry(
|
||||
name = "NewPipe YouTube",
|
||||
version = "0.1.0",
|
||||
@ -52,3 +57,15 @@ val BUILT_IN_PLUGINS = listOf(
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN,
|
||||
LRCLIB_BUILT_IN_PLUGIN,
|
||||
)
|
||||
|
||||
/**
|
||||
* The bundled placeholder logo for a built-in plugin, or `null` if it has none.
|
||||
*
|
||||
* Built-in plugins are not installed to disk, so they have no `logo.png`; these
|
||||
* resources (in `composeResources/drawable`) stand in as their logos.
|
||||
*/
|
||||
fun builtInPluginLogo(plugin: PluginEntry): DrawableResource? = when (plugin.id) {
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_newpipe
|
||||
LRCLIB_BUILT_IN_PLUGIN.id -> Res.drawable.plugin_logo_lrclib
|
||||
else -> null
|
||||
}
|
||||
|
||||
@ -39,7 +39,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -62,6 +61,7 @@ import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.core.extras.kebabToTitleCase
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
@ -75,9 +75,14 @@ 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.getPlatform
|
||||
import dev.krtirtho.spotube.modules.plugin.components.DefaultAbilityPluginGrid
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginCard
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginLogo
|
||||
import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog
|
||||
import dev.krtirtho.spotube.modules.plugin.components.abilityIcon
|
||||
import dev.krtirtho.spotube.modules.plugin.components.accentColor
|
||||
import dev.krtirtho.spotube.modules.plugin.components.displayLabel
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.openUrlInBrowser
|
||||
import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo
|
||||
@ -86,16 +91,12 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxBox
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle
|
||||
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.IconsaxGlobe
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
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
|
||||
import io.github.vinceglb.filekit.dialogs.FileKitType
|
||||
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
|
||||
import okio.Path
|
||||
@ -127,10 +128,6 @@ import spotube.composeapp.generated.resources.plugin_section_file_title
|
||||
import spotube.composeapp.generated.resources.plugin_section_install
|
||||
import spotube.composeapp.generated.resources.plugin_section_url_title
|
||||
import spotube.composeapp.generated.resources.plugin_url_placeholder
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
||||
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_default_ability_title
|
||||
@ -147,6 +144,31 @@ private val VERIFIED_PLUGIN_OWNERS = setOf<String>()
|
||||
fun PluginScreen(
|
||||
viewModel: PluginViewModel = koinViewModel(),
|
||||
onboarding: Boolean = false,
|
||||
) {
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
if (!onboarding) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
PluginScreenContent(
|
||||
viewModel = viewModel,
|
||||
onboarding = onboarding,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin management UI without its own [BaseScaffold] / app bar, so it can be embedded
|
||||
* as a tab in the large-screen settings layout as well as hosted by [PluginScreen].
|
||||
*/
|
||||
@Composable
|
||||
internal fun PluginScreenContent(
|
||||
viewModel: PluginViewModel,
|
||||
onboarding: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
@ -222,28 +244,17 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (!onboarding) {
|
||||
ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) })
|
||||
}
|
||||
},
|
||||
containerColor = if (onboarding) Color.Transparent else MaterialTheme.colorScheme.surface,
|
||||
) { innerPadding ->
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (val data = uiState) {
|
||||
is PluginUiState.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) { CircularProgressIndicator() }
|
||||
}
|
||||
|
||||
is PluginUiState.Data -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val discoverListState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
state = discoverListState,
|
||||
@ -302,6 +313,19 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
if (!onboarding || !data.onboardingDiscover) {
|
||||
|
||||
item {
|
||||
DefaultAbilityPluginGrid(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
selections = data.abilitySelections,
|
||||
onSelected = { ability, plugin ->
|
||||
viewModel.selectPlugin(ability, plugin)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -325,39 +349,6 @@ fun PluginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
data.abilitySelections.forEachIndexed { index, selection ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(
|
||||
alpha = 0.5f
|
||||
),
|
||||
)
|
||||
}
|
||||
DefaultAbilityPluginSelector(
|
||||
selection = selection,
|
||||
onSelected = { plugin ->
|
||||
viewModel.selectPlugin(
|
||||
selection.ability,
|
||||
plugin
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.plugins.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
@ -721,26 +712,22 @@ private fun PluginInfoDialog(
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
) {
|
||||
if (logoPath != null) {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
PluginLogo(
|
||||
plugin = plugin,
|
||||
logoPath = logoPath,
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
plugin.name,
|
||||
@ -1135,31 +1122,16 @@ fun DefaultAbilityPluginSelector(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
color = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
||||
}
|
||||
color = selection.ability.accentColor().copy(alpha = 0.1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
|
||||
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
|
||||
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
|
||||
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
|
||||
},
|
||||
imageVector = selection.ability.abilityIcon(),
|
||||
contentDescription = stringResource(
|
||||
Res.string.settings_plugins_plugin_content_description,
|
||||
selection.ability.displayLabel()
|
||||
),
|
||||
modifier = Modifier.padding(8.dp),
|
||||
tint = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
||||
}
|
||||
tint = selection.ability.accentColor()
|
||||
)
|
||||
}
|
||||
|
||||
@ -1203,28 +1175,13 @@ fun DefaultAbilityPluginSelector(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
color = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f)
|
||||
}
|
||||
color = selection.ability.accentColor().copy(alpha = 0.1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
|
||||
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
|
||||
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
|
||||
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
|
||||
},
|
||||
imageVector = selection.ability.abilityIcon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
tint = when (selection.ability) {
|
||||
PluginAbility.METADATA -> Color(0xFF4CAF50)
|
||||
PluginAbility.AUDIO -> Color(0xFF2196F3)
|
||||
PluginAbility.LYRICS -> Color(0xFFFFC107)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF9C27B0)
|
||||
}
|
||||
tint = selection.ability.accentColor()
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@ -1265,12 +1222,3 @@ fun DefaultAbilityPluginSelector(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginAbility.displayLabel(): String {
|
||||
return when (this) {
|
||||
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
||||
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
||||
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +63,7 @@ data class AbilitySelection(
|
||||
val ability: PluginAbility,
|
||||
val plugins: List<PluginEntry>,
|
||||
val selectedPlugin: PluginEntry?,
|
||||
val pluginLogoPaths: Map<String, Path?> = emptyMap(),
|
||||
)
|
||||
|
||||
data class PluginDiscoverState(
|
||||
@ -254,22 +255,26 @@ class PluginViewModel(
|
||||
AbilitySelection(
|
||||
PluginAbility.METADATA,
|
||||
snapshot.metadataPlugins,
|
||||
state.selectedPlugins[PluginAbility.METADATA]
|
||||
state.selectedPlugins[PluginAbility.METADATA],
|
||||
logoPaths,
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.AUDIO,
|
||||
snapshot.audioPlugins,
|
||||
state.selectedPlugins[PluginAbility.AUDIO]
|
||||
state.selectedPlugins[PluginAbility.AUDIO],
|
||||
logoPaths,
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.LYRICS,
|
||||
snapshot.lyricsPlugins,
|
||||
state.selectedPlugins[PluginAbility.LYRICS]
|
||||
state.selectedPlugins[PluginAbility.LYRICS],
|
||||
logoPaths,
|
||||
),
|
||||
AbilitySelection(
|
||||
PluginAbility.SCROBBLE,
|
||||
snapshot.scrobblePlugins,
|
||||
state.selectedPlugins[PluginAbility.SCROBBLE]
|
||||
state.selectedPlugins[PluginAbility.SCROBBLE],
|
||||
logoPaths,
|
||||
),
|
||||
),
|
||||
pendingPlugin = snapshot.pendingPlugin,
|
||||
|
||||
@ -0,0 +1,420 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.plugin.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet
|
||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem
|
||||
import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode
|
||||
import dev.krtirtho.spotube.modules.plugin.AbilitySelection
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSound
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft
|
||||
import okio.Path
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
||||
import spotube.composeapp.generated.resources.settings_plugins_default_ability_title
|
||||
import spotube.composeapp.generated.resources.settings_plugins_no_plugins
|
||||
import spotube.composeapp.generated.resources.settings_plugins_no_selection
|
||||
import kotlin.random.Random
|
||||
|
||||
/** The accent color used for a plugin ability across the whole plugin UI. */
|
||||
internal fun PluginAbility.accentColor(): Color = when (this) {
|
||||
PluginAbility.METADATA -> Color(0xFF34A853)
|
||||
PluginAbility.AUDIO -> Color(0xFF1E88E5)
|
||||
PluginAbility.LYRICS -> Color(0xFFF9A825)
|
||||
PluginAbility.SCROBBLE -> Color(0xFF8E24AA)
|
||||
}
|
||||
|
||||
/** The icon used to represent a plugin ability. */
|
||||
internal fun PluginAbility.abilityIcon(): ImageVector = when (this) {
|
||||
PluginAbility.METADATA -> Iconsax.IconsaxDocumentText
|
||||
PluginAbility.AUDIO -> Iconsax.IconsaxMusic
|
||||
PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft
|
||||
PluginAbility.SCROBBLE -> Iconsax.IconsaxSound
|
||||
}
|
||||
|
||||
/**
|
||||
* A vibrant, multi-hue gradient per ability, used by the Frutiger-Aero plugin tiles.
|
||||
*/
|
||||
internal fun PluginAbility.aeroGradient(): List<Color> = when (this) {
|
||||
PluginAbility.METADATA -> listOf(Color(0xFFA8E063), Color(0xFF56AB2F), Color(0xFF11998E))
|
||||
PluginAbility.AUDIO -> listOf(Color(0xFF56CCF2), Color(0xFF2F80ED), Color(0xFF6A11CB))
|
||||
PluginAbility.LYRICS -> listOf(Color(0xFFFFE259), Color(0xFFFFA751), Color(0xFFFF5E62))
|
||||
PluginAbility.SCROBBLE -> listOf(Color(0xFFFF6FD8), Color(0xFF9D50BB), Color(0xFF3F0D7A))
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PluginAbility.displayLabel(): String = when (this) {
|
||||
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
||||
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
||||
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||
}
|
||||
|
||||
/** A short "v1.2.3 · Author" summary shown under a plugin in the picker. */
|
||||
private fun PluginEntry.metadataLabel(): String = buildString {
|
||||
append("v").append(version)
|
||||
if (author.isNotBlank()) {
|
||||
append(" · ").append(author)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2x2 grid of default-plugin selectors shown on large screens. Each tile is a
|
||||
* Frutiger-Aero style card representing one ability and its currently selected plugin.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DefaultAbilityPluginGrid(
|
||||
selections: List<AbilitySelection>,
|
||||
onSelected: (PluginAbility, PluginEntry?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
selections.chunked(2).forEach { rowSelections ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
rowSelections.forEach { selection ->
|
||||
DefaultAbilityPluginCard(
|
||||
selection = selection,
|
||||
onSelected = { onSelected(selection.ability, it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
repeat(2 - rowSelections.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultAbilityPluginCard(
|
||||
selection: AbilitySelection,
|
||||
onSelected: (PluginEntry?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val ability = selection.ability
|
||||
val selected = selection.selectedPlugin
|
||||
val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins)
|
||||
val noSelectionText = stringResource(Res.string.settings_plugins_no_selection)
|
||||
|
||||
val menuItems = buildList {
|
||||
if (selection.plugins.isNotEmpty()) {
|
||||
selection.plugins.forEach { plugin ->
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
label = plugin.name,
|
||||
subtitle = plugin.metadataLabel(),
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PluginLogo(
|
||||
plugin = plugin,
|
||||
logoPath = selection.pluginLogoPaths[plugin.id],
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Icon(
|
||||
imageVector = ability.abilityIcon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = ability.accentColor(),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { onSelected(plugin) },
|
||||
selected = selected?.name == plugin.name,
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
label = noPluginsText,
|
||||
onClick = { },
|
||||
enabled = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = menuItems,
|
||||
modifier = modifier,
|
||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||
header = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
AbilityBadge(ability)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(
|
||||
Res.string.settings_plugins_default_ability_title,
|
||||
ability.displayLabel(),
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
selected?.let {
|
||||
Text(
|
||||
it.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
trigger = { onClick ->
|
||||
AbilityPluginTile(
|
||||
ability = ability,
|
||||
selectedPlugin = selected,
|
||||
logoPath = selection.pluginLogoPaths[selected?.id],
|
||||
pluginName = selected?.name ?: noSelectionText,
|
||||
version = selected?.version,
|
||||
onClick = onClick,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbilityPluginTile(
|
||||
ability: PluginAbility,
|
||||
selectedPlugin: PluginEntry?,
|
||||
logoPath: Path?,
|
||||
pluginName: String,
|
||||
version: String?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val accent = ability.accentColor()
|
||||
val gradient = ability.aeroGradient()
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
val seed = ability.ordinal + 1
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.clickable(onClick = onClick)
|
||||
.heightIn(min = 148.dp)
|
||||
.drawWithCache {
|
||||
val background = Brush.linearGradient(
|
||||
colors = gradient,
|
||||
start = Offset.Zero,
|
||||
end = Offset(size.width, size.height),
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(background)
|
||||
drawAeroDecorations(seed)
|
||||
// Bottom scrim so the white text stays readable on light gradients.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.32f)),
|
||||
startY = size.height * 0.3f,
|
||||
endY = size.height,
|
||||
),
|
||||
)
|
||||
// Glossy top highlight.
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.35f), Color.Transparent),
|
||||
startY = 0f,
|
||||
endY = size.height * 0.55f,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.35f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.6f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PluginLogo(
|
||||
plugin = selectedPlugin,
|
||||
logoPath = logoPath,
|
||||
contentDescription = pluginName,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Icon(
|
||||
imageVector = ability.abilityIcon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = accent,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxArrowDown4,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.White.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = pluginName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = buildString {
|
||||
append(ability.displayLabel())
|
||||
if (!version.isNullOrBlank()) {
|
||||
append(" · v").append(version)
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AbilityBadge(ability: PluginAbility) {
|
||||
val accent = ability.accentColor()
|
||||
Surface(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
|
||||
color = accent.copy(alpha = 0.12f),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ability.abilityIcon(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
tint = accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft translucent bubbles and highlights behind a tile, giving the glossy
|
||||
* Frutiger-Aero look. Deterministic per tile so it does not flicker on recomposition.
|
||||
*/
|
||||
private fun DrawScope.drawAeroDecorations(seed: Int) {
|
||||
val random = Random(seed)
|
||||
repeat(6) {
|
||||
val center = Offset(
|
||||
x = random.nextFloat() * size.width,
|
||||
y = random.nextFloat() * size.height,
|
||||
)
|
||||
val radius = size.minDimension * (0.12f + random.nextFloat() * 0.4f)
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(Color.White.copy(alpha = 0.3f), Color.Transparent),
|
||||
center = center,
|
||||
radius = radius,
|
||||
),
|
||||
radius = radius,
|
||||
center = center,
|
||||
)
|
||||
}
|
||||
repeat(3) {
|
||||
val center = Offset(
|
||||
x = random.nextFloat() * size.width,
|
||||
y = random.nextFloat() * size.height,
|
||||
)
|
||||
val radius = size.minDimension * (0.03f + random.nextFloat() * 0.05f)
|
||||
drawCircle(
|
||||
color = Color.White.copy(alpha = 0.28f),
|
||||
radius = radius,
|
||||
center = center,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import okio.Path
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
@ -65,10 +61,6 @@ import spotube.composeapp.generated.resources.plugin_action_logout
|
||||
import spotube.composeapp.generated.resources.plugin_state_active
|
||||
import spotube.composeapp.generated.resources.plugin_state_builtin
|
||||
import spotube.composeapp.generated.resources.plugin_version_label
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_audio
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_metadata
|
||||
import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble
|
||||
|
||||
@Composable
|
||||
internal fun PluginCard(
|
||||
@ -96,26 +88,22 @@ internal fun PluginCard(
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
|
||||
) {
|
||||
if (logoPath != null) {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
PluginLogo(
|
||||
plugin = plugin,
|
||||
logoPath = logoPath,
|
||||
contentDescription = plugin.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Iconsax.IconsaxBox,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Text content
|
||||
@ -302,12 +290,3 @@ internal fun PluginCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PluginAbility.displayLabel(): String {
|
||||
return when (this) {
|
||||
PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata)
|
||||
PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio)
|
||||
PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics)
|
||||
PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.plugin.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import dev.krtirtho.spotube.modules.plugin.builtInPluginLogo
|
||||
import okio.Path
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
/**
|
||||
* Renders a plugin's logo, resolving the best available source:
|
||||
*
|
||||
* 1. the plugin's own `logo.png` ([logoPath]) when it was installed from disk,
|
||||
* 2. the bundled placeholder logo for built-in plugins (see [builtInPluginLogo]),
|
||||
* 3. [fallback] (for example the ability icon) when no logo is available.
|
||||
*/
|
||||
@Composable
|
||||
fun PluginLogo(
|
||||
plugin: PluginEntry?,
|
||||
logoPath: Path?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val builtInLogo = plugin?.let { builtInPluginLogo(it) }
|
||||
|
||||
when {
|
||||
logoPath != null -> {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
builtInLogo != null -> {
|
||||
Image(
|
||||
painter = painterResource(builtInLogo),
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> fallback()
|
||||
}
|
||||
}
|
||||
@ -39,10 +39,6 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import okio.Path
|
||||
import dev.krtirtho.spotube.core.ui.base.Card
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
@ -100,24 +96,20 @@ fun PluginPermissionDialog(
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (logoPath != null) {
|
||||
val platformContext = LocalPlatformContext.current
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(platformContext)
|
||||
.data(logoPath.toString())
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = pluginInfo.name,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxBoxAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
PluginLogo(
|
||||
plugin = pluginInfo,
|
||||
logoPath = logoPath,
|
||||
contentDescription = pluginInfo.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
fallback = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxBoxAdd,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
|
||||
@ -53,7 +53,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -268,7 +268,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
||||
remotePlaybackController.requestTracksPlayNext(tracks, "Search results")
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = { ApplicationMainBar(backButton = false) }
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
|
||||
@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -16,6 +15,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.modules.settings.components.SwitchSettingCard
|
||||
@ -58,6 +58,29 @@ import spotube.composeapp.generated.resources.settings_section_jam
|
||||
@Composable
|
||||
fun JamSettingsScreen(
|
||||
viewModel: JamSettingsViewModel = koinViewModel(),
|
||||
) {
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = { Text(stringResource(Res.string.settings_section_jam)) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
JamSettingsContent(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Group Jam settings UI without its own [BaseScaffold] / app bar, so it can be embedded
|
||||
* as a tab in the large-screen settings layout as well as hosted by [JamSettingsScreen].
|
||||
*/
|
||||
@Composable
|
||||
internal fun JamSettingsContent(
|
||||
viewModel: JamSettingsViewModel = koinViewModel(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val broker = uiState.settings.jamBroker
|
||||
@ -66,208 +89,199 @@ fun JamSettingsScreen(
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = { Text(stringResource(Res.string.settings_section_jam)) },
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
settingsSectionHeader(Res.string.settings_jam_section_connection)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_host),
|
||||
value = broker.host,
|
||||
placeholder = "broker.example.com",
|
||||
icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
|
||||
onValueSaved = { host ->
|
||||
viewModel.update {
|
||||
copy(
|
||||
jamBroker = jamBroker.copy(
|
||||
host = host
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_connection)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_host),
|
||||
value = broker.host,
|
||||
placeholder = "broker.example.com",
|
||||
icon = { SettingsItemIcon(Iconsax.CustomServer, "Broker") },
|
||||
onValueSaved = { host ->
|
||||
viewModel.update {
|
||||
copy(
|
||||
jamBroker = jamBroker.copy(
|
||||
host = host
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
val errorWholeNumber =
|
||||
stringResource(Res.string.settings_error_whole_number)
|
||||
val errorPortRange =
|
||||
stringResource(Res.string.settings_error_port_range)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
val errorWholeNumber =
|
||||
stringResource(Res.string.settings_error_whole_number)
|
||||
val errorPortRange =
|
||||
stringResource(Res.string.settings_error_port_range)
|
||||
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_port),
|
||||
value = broker.port.toString(),
|
||||
placeholder = "1883",
|
||||
normalize = { it.filter(Char::isDigit).take(5) },
|
||||
validate = { value ->
|
||||
val port = value.toIntOrNull()
|
||||
when {
|
||||
port == null -> errorWholeNumber
|
||||
port !in 1..65535 -> errorPortRange
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
onValueSaved = { port ->
|
||||
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.CustomServer,
|
||||
stringResource(Res.string.settings_jam_broker_port)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_tls),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
|
||||
checked = broker.useTls,
|
||||
onCheckedChange = { tls ->
|
||||
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxWifiSquare,
|
||||
stringResource(Res.string.settings_jam_broker_tls)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_credentials)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_username),
|
||||
value = broker.username.orEmpty(),
|
||||
placeholder = "anonymous",
|
||||
onValueSaved = { username ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.User,
|
||||
stringResource(Res.string.settings_jam_broker_username)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_password),
|
||||
value = broker.password.orEmpty(),
|
||||
placeholder = "••••••••",
|
||||
onValueSaved = { password ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxEye,
|
||||
stringResource(Res.string.settings_jam_broker_password)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_identity)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_client_id),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
|
||||
value = broker.clientIdPrefix,
|
||||
placeholder = "spotube",
|
||||
onValueSaved = { prefix ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxTag,
|
||||
stringResource(Res.string.settings_jam_broker_client_id)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
val canTest = broker.host.isNotBlank() && !uiState.isTesting
|
||||
val resultMessage = uiState.testResult?.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
|
||||
)
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_port),
|
||||
value = broker.port.toString(),
|
||||
placeholder = "1883",
|
||||
normalize = { it.filter(Char::isDigit).take(5) },
|
||||
validate = { value ->
|
||||
val port = value.toIntOrNull()
|
||||
when {
|
||||
port == null -> errorWholeNumber
|
||||
port !in 1..65535 -> errorPortRange
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
onValueSaved = { port ->
|
||||
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.CustomServer,
|
||||
stringResource(Res.string.settings_jam_broker_port)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_tls),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
|
||||
checked = broker.useTls,
|
||||
onCheckedChange = { tls ->
|
||||
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxWifiSquare,
|
||||
stringResource(Res.string.settings_jam_broker_tls)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_credentials)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_username),
|
||||
value = broker.username.orEmpty(),
|
||||
placeholder = "anonymous",
|
||||
onValueSaved = { username ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.User,
|
||||
stringResource(Res.string.settings_jam_broker_username)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_password),
|
||||
value = broker.password.orEmpty(),
|
||||
placeholder = "••••••••",
|
||||
onValueSaved = { password ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxEye,
|
||||
stringResource(Res.string.settings_jam_broker_password)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_identity)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_client_id),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
|
||||
value = broker.clientIdPrefix,
|
||||
placeholder = "spotube",
|
||||
onValueSaved = { prefix ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxTag,
|
||||
stringResource(Res.string.settings_jam_broker_client_id)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
val canTest = broker.host.isNotBlank() && !uiState.isTesting
|
||||
val resultMessage = uiState.testResult?.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { "Failed: ${it.message ?: "Connection failed"}" },
|
||||
)
|
||||
|
||||
SettingCardItem(
|
||||
enabled = canTest,
|
||||
title = stringResource(Res.string.settings_jam_broker_test),
|
||||
subtitle = when {
|
||||
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
|
||||
resultMessage != null -> resultMessage
|
||||
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxInformation,
|
||||
stringResource(Res.string.settings_jam_broker_test),
|
||||
SettingCardItem(
|
||||
enabled = canTest,
|
||||
title = stringResource(Res.string.settings_jam_broker_test),
|
||||
subtitle = when {
|
||||
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
|
||||
resultMessage != null -> resultMessage
|
||||
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxInformation,
|
||||
stringResource(Res.string.settings_jam_broker_test),
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
PrimaryButton(
|
||||
onClick = viewModel::testConnection,
|
||||
enabled = canTest,
|
||||
) {
|
||||
Text(
|
||||
if (uiState.isTesting) {
|
||||
stringResource(Res.string.settings_jam_broker_testing)
|
||||
} else {
|
||||
stringResource(Res.string.settings_jam_broker_test)
|
||||
},
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
PrimaryButton(
|
||||
onClick = viewModel::testConnection,
|
||||
enabled = canTest,
|
||||
) {
|
||||
Text(
|
||||
if (uiState.isTesting) {
|
||||
stringResource(Res.string.settings_jam_broker_testing)
|
||||
} else {
|
||||
stringResource(Res.string.settings_jam_broker_test)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = viewModel::testConnection,
|
||||
)
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
onClick = viewModel::testConnection,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.settings
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@ -35,6 +36,7 @@ data class UserSettings(
|
||||
// Appearance
|
||||
val theme: Theme = Theme.SYSTEM,
|
||||
val accentColor: AccentColors = AccentColors.GREEN_GOBLIN,
|
||||
val surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
|
||||
|
||||
// Playback
|
||||
val streamingMusicFormat: AudioFormat = AudioFormat(
|
||||
|
||||
@ -18,13 +18,13 @@
|
||||
package dev.krtirtho.spotube.modules.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -37,6 +37,8 @@ import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import spotube.composeapp.generated.resources.*
|
||||
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
||||
@ -52,24 +54,44 @@ import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
/**
|
||||
* Minimum width of the settings content area required to show the settings as an
|
||||
* overlay dialog instead of a full-screen destination.
|
||||
*/
|
||||
private val SettingsDialogMinWidth = 720.dp
|
||||
|
||||
/**
|
||||
* On large screens settings is presented as a modal overlay so it does not sit next to
|
||||
* the main navigation sidebar. On smaller screens it stays a full-screen destination.
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
if (maxWidth >= SettingsDialogMinWidth) {
|
||||
SettingsDialog(
|
||||
settingsViewModel = settingsViewModel,
|
||||
onDismiss = { navigatorCommands.pop(Routes.Settings) },
|
||||
)
|
||||
} else {
|
||||
CompactSettingsScreen(settingsViewModel = settingsViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
private fun CompactSettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
val navigatorCommands = koinInject<NavigationCommands>()
|
||||
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
|
||||
val platformType = remember { getPlatform().type }
|
||||
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
||||
platformType == PlatformType.Linux ||
|
||||
platformType == PlatformType.MacOS
|
||||
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
|
||||
val contentPadding = remember(shellBottomInset) {
|
||||
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
@ -79,66 +101,79 @@ fun SettingsScreen(settingsViewModel: SettingsViewModel) {
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
pluginsSection(
|
||||
navigatorCommands = navigatorCommands,
|
||||
)
|
||||
if (settingsState != null) {
|
||||
languageRegionSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
if (settingsState != null)
|
||||
appearanceSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (settingsState != null)
|
||||
playbackSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
)
|
||||
if (settingsState != null)
|
||||
jamSection(
|
||||
settings = settingsState!!,
|
||||
navigationCommands = navigatorCommands,
|
||||
)
|
||||
if (settingsState != null)
|
||||
cacheSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (settingsState != null)
|
||||
downloadsSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (isDesktopPlatform && settingsState != null) {
|
||||
desktopSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
if (settingsState != null)
|
||||
updatesSection(
|
||||
settings = settingsState!!,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
CompactSettingsContent(
|
||||
settings = settingsState,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
contentPadding = contentPadding,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSettingsContent(
|
||||
settings: UserSettings?,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navigatorCommands: NavigationCommands,
|
||||
requestLocalNetworkPermission: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val platformType = remember { getPlatform().type }
|
||||
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
||||
platformType == PlatformType.Linux ||
|
||||
platformType == PlatformType.MacOS
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
pluginsSection(
|
||||
navigatorCommands = navigatorCommands,
|
||||
)
|
||||
if (settings != null) {
|
||||
languageRegionSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
appearanceSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
playbackSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
)
|
||||
jamSection(
|
||||
settings = settings,
|
||||
navigationCommands = navigatorCommands,
|
||||
)
|
||||
cacheSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
downloadsSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
if (isDesktopPlatform) {
|
||||
desktopSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
updatesSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,356 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.modules.settings
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.krtirtho.spotube.PlatformType
|
||||
import dev.krtirtho.spotube.getPlatform
|
||||
import dev.krtirtho.spotube.core.discovery.rememberLocalNetworkPermissionRequester
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.component.SidebarItem
|
||||
import dev.krtirtho.spotube.modules.blacklist.BlacklistContent
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginScreenContent
|
||||
import dev.krtirtho.spotube.modules.settings.sections.appearanceSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.cacheSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.desktopSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.downloadsSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.languageRegionSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.playbackSection
|
||||
import dev.krtirtho.spotube.modules.settings.sections.SettingsPageTitle
|
||||
import dev.krtirtho.spotube.modules.settings.sections.updatesSection
|
||||
import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxBoxAdd
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxForbidden
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxInformation
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxLanguageSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicPlay
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.settings_blacklist_title
|
||||
import spotube.composeapp.generated.resources.settings_screen_title
|
||||
import spotube.composeapp.generated.resources.settings_section_about
|
||||
import spotube.composeapp.generated.resources.settings_section_appearance
|
||||
import spotube.composeapp.generated.resources.settings_section_caching
|
||||
import spotube.composeapp.generated.resources.settings_section_desktop
|
||||
import spotube.composeapp.generated.resources.settings_section_downloads
|
||||
import spotube.composeapp.generated.resources.settings_section_jam
|
||||
import spotube.composeapp.generated.resources.settings_section_language_region
|
||||
import spotube.composeapp.generated.resources.settings_section_playback
|
||||
import spotube.composeapp.generated.resources.settings_section_plugins
|
||||
|
||||
private val SettingsSidebarWidth = 236.dp
|
||||
|
||||
/**
|
||||
* The sections reachable from the large-screen settings sidebar, in display order.
|
||||
*
|
||||
* Each entry maps to either a list-based settings section or a standalone
|
||||
* screen (Plugins, Blacklist, Group Jam) whose content is embedded in the tab.
|
||||
*/
|
||||
internal enum class SettingsTab(
|
||||
val titleRes: StringResource,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Plugins(Res.string.settings_section_plugins, Iconsax.IconsaxBoxAdd),
|
||||
LanguageRegion(Res.string.settings_section_language_region, Iconsax.IconsaxLanguageSquare),
|
||||
Appearance(Res.string.settings_section_appearance, Iconsax.IconsaxColorSwatch),
|
||||
Playback(Res.string.settings_section_playback, Iconsax.IconsaxMusicPlay),
|
||||
Blacklist(Res.string.settings_blacklist_title, Iconsax.IconsaxForbidden),
|
||||
GroupJam(Res.string.settings_section_jam, Iconsax.IconsaxSoundTwotone),
|
||||
Caching(Res.string.settings_section_caching, Iconsax.IconsaxDocumentDownload),
|
||||
Downloads(Res.string.settings_section_downloads, Iconsax.IconsaxDirectboxReceive),
|
||||
Desktop(Res.string.settings_section_desktop, Iconsax.IconsaxImportArrow2),
|
||||
About(Res.string.settings_section_about, Iconsax.IconsaxInformation),
|
||||
}
|
||||
|
||||
/**
|
||||
* Large-screen settings, presented as a modal overlay (dialog) over the whole app so it
|
||||
* never sits next to the main navigation sidebar.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsDialog(
|
||||
settingsViewModel: SettingsViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
// The dialog is its own surface; the player/bottom bars behind it should not
|
||||
// reserve any inset inside the dialog content.
|
||||
CompositionLocalProvider(LocalAppShellBottomInset provides 0.dp) {
|
||||
SettingsPane(
|
||||
settingsViewModel = settingsViewModel,
|
||||
onClose = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPane(
|
||||
settingsViewModel: SettingsViewModel,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
val settingsState by settingsViewModel.settingsState.collectAsStateWithLifecycle()
|
||||
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
|
||||
val contentPadding = remember { PaddingValues(top = 16.dp, bottom = 24.dp) }
|
||||
val dialogTheme = LocalBaseUITheme.current.dialog
|
||||
|
||||
val platformType = remember { getPlatform().type }
|
||||
val isDesktopPlatform = platformType == PlatformType.Windows ||
|
||||
platformType == PlatformType.Linux ||
|
||||
platformType == PlatformType.MacOS
|
||||
|
||||
val tabs = remember(isDesktopPlatform) {
|
||||
SettingsTab.entries.filter { it != SettingsTab.Desktop || isDesktopPlatform }
|
||||
}
|
||||
var selectedIndex by rememberSaveable { mutableStateOf(0) }
|
||||
val selectedTab = tabs.getOrElse(selectedIndex) { tabs.first() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1200.dp)
|
||||
.heightIn(max = 860.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.shadow(
|
||||
elevation = dialogTheme.shadow.elevation,
|
||||
shape = dialogTheme.shape,
|
||||
ambientColor = dialogTheme.shadow.ambientColor,
|
||||
spotColor = dialogTheme.shadow.spotColor,
|
||||
)
|
||||
.clip(dialogTheme.shape)
|
||||
.background(dialogTheme.background, dialogTheme.shape)
|
||||
.border(
|
||||
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
|
||||
dialogTheme.shape,
|
||||
),
|
||||
) {
|
||||
SettingsSidebar(
|
||||
tabs = tabs,
|
||||
selectedTab = selectedTab,
|
||||
onSelect = { selectedIndex = tabs.indexOf(it) },
|
||||
)
|
||||
VerticalDivider(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
thickness = 1.dp,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
SettingsPageTitle(selectedTab.titleRes)
|
||||
}
|
||||
GhostIconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = "Close",
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
SettingsTabContent(
|
||||
tab = selectedTab,
|
||||
settings = settingsState,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
contentPadding = contentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSidebar(
|
||||
tabs: List<SettingsTab>,
|
||||
selectedTab: SettingsTab,
|
||||
onSelect: (SettingsTab) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.width(SettingsSidebarWidth)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(Res.string.settings_screen_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 8.dp, bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
items(tabs, key = { it.name }) { tab ->
|
||||
SidebarItem(
|
||||
label = stringResource(tab.titleRes),
|
||||
icon = tab.icon,
|
||||
selected = tab == selectedTab,
|
||||
onClick = { onSelect(tab) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsTabContent(
|
||||
tab: SettingsTab,
|
||||
settings: UserSettings?,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navigatorCommands: NavigationCommands,
|
||||
requestLocalNetworkPermission: () -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
) {
|
||||
when (tab) {
|
||||
SettingsTab.Plugins -> {
|
||||
PluginScreenContent(viewModel = koinViewModel())
|
||||
}
|
||||
|
||||
SettingsTab.Blacklist -> {
|
||||
BlacklistContent(viewModel = koinViewModel())
|
||||
}
|
||||
|
||||
SettingsTab.GroupJam -> {
|
||||
JamSettingsContent(viewModel = koinViewModel())
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (settings == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
when (tab) {
|
||||
SettingsTab.LanguageRegion ->
|
||||
languageRegionSection(settings, settingsViewModel, includeHeader = false)
|
||||
|
||||
SettingsTab.Appearance ->
|
||||
appearanceSection(settings, settingsViewModel, includeHeader = false)
|
||||
|
||||
SettingsTab.Playback ->
|
||||
playbackSection(
|
||||
settings = settings,
|
||||
settingsViewModel = settingsViewModel,
|
||||
navigatorCommands = navigatorCommands,
|
||||
requestLocalNetworkPermission = requestLocalNetworkPermission,
|
||||
includeBlacklist = false,
|
||||
includeHeader = false,
|
||||
)
|
||||
|
||||
SettingsTab.Caching ->
|
||||
cacheSection(settings, settingsViewModel, includeHeader = false)
|
||||
|
||||
SettingsTab.Downloads ->
|
||||
downloadsSection(settings, settingsViewModel, includeHeader = false)
|
||||
|
||||
SettingsTab.Desktop ->
|
||||
desktopSection(settings, settingsViewModel, includeHeader = false)
|
||||
|
||||
SettingsTab.About ->
|
||||
updatesSection(settings, settingsViewModel, includeHeader = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -52,6 +52,7 @@ import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.Radio
|
||||
import dev.krtirtho.spotube.core.ui.base.SurfaceStyle
|
||||
import dev.krtirtho.spotube.modules.settings.AccentColors
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsViewModel
|
||||
import dev.krtirtho.spotube.modules.settings.Theme
|
||||
@ -61,14 +62,18 @@ import dev.krtirtho.spotube.modules.settings.components.SettingCardItem
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorSwatch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxColorsSquare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
internal fun LazyListScope.appearanceSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
settingsSectionHeader(Res.string.settings_section_appearance)
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_appearance)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
@ -91,6 +96,29 @@ internal fun LazyListScope.appearanceSection(
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_surface_theme_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_surface_theme_subtitle_current,
|
||||
settings.surfaceTheme.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxMagic,
|
||||
stringResource(Res.string.settings_surface_theme_title)
|
||||
)
|
||||
},
|
||||
selectedOption = settings.surfaceTheme,
|
||||
options = SurfaceStyle.entries,
|
||||
optionLabel = { it.displayLabel() },
|
||||
onOptionSelected = { surfaceTheme ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(surfaceTheme = surfaceTheme)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
AccentColorSettingCard(
|
||||
selectedAccent = settings.accentColor,
|
||||
@ -117,6 +145,17 @@ private fun Theme.displayLabel(): String {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SurfaceStyle.displayLabel(): String {
|
||||
return when (this) {
|
||||
SurfaceStyle.Watery -> stringResource(Res.string.settings_surface_watery)
|
||||
SurfaceStyle.Icy -> stringResource(Res.string.settings_surface_icy)
|
||||
SurfaceStyle.Chrome -> stringResource(Res.string.settings_surface_chrome)
|
||||
SurfaceStyle.Acrylic -> stringResource(Res.string.settings_surface_acrylic)
|
||||
SurfaceStyle.Glass -> stringResource(Res.string.settings_surface_glass)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccentColors.displayLabel(): String {
|
||||
return when (this) {
|
||||
|
||||
@ -42,8 +42,11 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.cacheSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
settingsSectionHeader(Res.string.settings_section_caching)
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_caching)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -30,8 +30,11 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.desktopSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
settingsSectionHeader(Res.string.settings_section_desktop)
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_desktop)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -79,6 +79,7 @@ import spotube.composeapp.generated.resources.settings_subtitle_current
|
||||
internal fun LazyListScope.downloadsSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
val downloadFormats = availableAudioFormats(settings.downloadMusicFormat, downloadFormatPresets)
|
||||
val downloadQualities = availableAudioQualities(
|
||||
@ -86,7 +87,9 @@ internal fun LazyListScope.downloadsSection(
|
||||
current = settings.downloadMusicQuality,
|
||||
)
|
||||
|
||||
settingsSectionHeader(Res.string.settings_section_downloads)
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_downloads)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -35,8 +35,11 @@ import org.jetbrains.compose.resources.stringResource
|
||||
internal fun LazyListScope.languageRegionSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
settingsSectionHeader(Res.string.settings_section_language_region)
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_language_region)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
|
||||
@ -20,6 +20,7 @@ package dev.krtirtho.spotube.modules.settings.sections
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.Cast
|
||||
@ -51,6 +52,8 @@ internal fun LazyListScope.playbackSection(
|
||||
settingsViewModel: SettingsViewModel,
|
||||
navigatorCommands: NavigationCommands,
|
||||
requestLocalNetworkPermission: () -> Unit,
|
||||
includeBlacklist: Boolean = true,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
val streamingFormats = availableAudioFormats(settings.streamingMusicFormat, streamingFormatPresets)
|
||||
val streamingQualities = availableAudioQualities(
|
||||
@ -58,163 +61,162 @@ internal fun LazyListScope.playbackSection(
|
||||
current = settings.streamingMusicQuality,
|
||||
)
|
||||
|
||||
settingsSectionHeader(Res.string.settings_section_playback)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_streaming_format_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_streaming_format_subtitle_current,
|
||||
settings.streamingMusicFormat.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title))
|
||||
},
|
||||
selectedOption = settings.streamingMusicFormat,
|
||||
options = streamingFormats,
|
||||
optionLabel = { it.displayLabel() },
|
||||
onOptionSelected = { format ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(
|
||||
streamingMusicFormat = format,
|
||||
streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_streaming_quality_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_subtitle_current,
|
||||
settings.streamingMusicQuality.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title))
|
||||
},
|
||||
selectedOption = settings.streamingMusicQuality,
|
||||
options = streamingQualities,
|
||||
optionLabel = { it.displayLabel() },
|
||||
onOptionSelected = { quality ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(streamingMusicQuality = quality)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_endless_playback_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxRepeatArrow,
|
||||
stringResource(Res.string.settings_enable_endless_playback_title)
|
||||
val cards: List<@Composable () -> Unit> = buildList {
|
||||
add {
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_streaming_format_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_streaming_format_subtitle_current,
|
||||
settings.streamingMusicFormat.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMusicPlay, stringResource(Res.string.settings_streaming_format_title))
|
||||
},
|
||||
selectedOption = settings.streamingMusicFormat,
|
||||
options = streamingFormats,
|
||||
optionLabel = { it.displayLabel() },
|
||||
onOptionSelected = { format ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(
|
||||
streamingMusicFormat = format,
|
||||
streamingMusicQuality = format.resolveQuality(streamingMusicQuality),
|
||||
)
|
||||
},
|
||||
checked = settings.enableEndlessPlayback,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(enableEndlessPlayback = enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_connect_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
|
||||
},
|
||||
checked = settings.enableConnect,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(enableConnect = enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SelectionSettingCard(
|
||||
title = stringResource(Res.string.settings_streaming_quality_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_subtitle_current,
|
||||
settings.streamingMusicQuality.displayLabel()
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxVerticalSetting, stringResource(Res.string.settings_streaming_quality_title))
|
||||
},
|
||||
selectedOption = settings.streamingMusicQuality,
|
||||
options = streamingQualities,
|
||||
optionLabel = { it.displayLabel() },
|
||||
onOptionSelected = { quality ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(streamingMusicQuality = quality)
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_allow_remote_control_title),
|
||||
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxMirroringScreen,
|
||||
stringResource(Res.string.settings_allow_remote_control_title)
|
||||
)
|
||||
},
|
||||
checked = settings.allowRemoteControl,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(allowRemoteControl = enabled)
|
||||
}
|
||||
// Request the local network permission when enabling remote control
|
||||
// so that DNS-SD registration can succeed on Android 16+
|
||||
if (enabled) {
|
||||
requestLocalNetworkPermission()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_endless_playback_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_endless_playback_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxRepeatArrow,
|
||||
stringResource(Res.string.settings_enable_endless_playback_title)
|
||||
)
|
||||
},
|
||||
checked = settings.enableEndlessPlayback,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(enableEndlessPlayback = enabled)
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_remote_device_name_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_remote_device_name_subtitle,
|
||||
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
|
||||
},
|
||||
value = settings.remoteControlDeviceName,
|
||||
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
|
||||
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
|
||||
normalize = { it.trim() },
|
||||
validate = { _ -> null },
|
||||
onValueSaved = { value ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(remoteControlDeviceName = value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_connect_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
|
||||
},
|
||||
checked = settings.enableConnect,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(enableConnect = enabled)
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_allow_remote_control_title),
|
||||
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxMirroringScreen,
|
||||
stringResource(Res.string.settings_allow_remote_control_title)
|
||||
)
|
||||
},
|
||||
checked = settings.allowRemoteControl,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(allowRemoteControl = enabled)
|
||||
}
|
||||
// Request the local network permission when enabling remote control
|
||||
// so that DNS-SD registration can succeed on Android 16+
|
||||
if (enabled) {
|
||||
requestLocalNetworkPermission()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_remote_device_name_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_remote_device_name_subtitle,
|
||||
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
|
||||
},
|
||||
value = settings.remoteControlDeviceName,
|
||||
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
|
||||
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
|
||||
normalize = { it.trim() },
|
||||
validate = { _ -> null },
|
||||
onValueSaved = { value ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(remoteControlDeviceName = value)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
val error_whole_number = stringResource(Res.string.settings_error_whole_number)
|
||||
val error_port_range = stringResource(Res.string.settings_error_port_range)
|
||||
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_playback_port_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_playback_port_subtitle_current,
|
||||
settings.playbackProxyServerPort
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title))
|
||||
},
|
||||
value = settings.playbackProxyServerPort.toString(),
|
||||
dialogDescription = stringResource(Res.string.settings_playback_port_description),
|
||||
placeholder = stringResource(Res.string.settings_playback_port_placeholder),
|
||||
normalize = { it.trim() },
|
||||
validate = { value ->
|
||||
val port = value.toIntOrNull()
|
||||
when {
|
||||
port == null -> error_whole_number
|
||||
port !in 1..65535 -> error_port_range
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
onValueSaved = { value ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(playbackProxyServerPort = value.toInt())
|
||||
}
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_playback_port_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_playback_port_subtitle_current,
|
||||
settings.playbackProxyServerPort
|
||||
),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_playback_port_title))
|
||||
},
|
||||
value = settings.playbackProxyServerPort.toString(),
|
||||
dialogDescription = stringResource(Res.string.settings_playback_port_description),
|
||||
placeholder = stringResource(Res.string.settings_playback_port_placeholder),
|
||||
normalize = { it.trim() },
|
||||
validate = { value ->
|
||||
val port = value.toIntOrNull()
|
||||
when {
|
||||
port == null -> error_whole_number
|
||||
port !in 1..65535 -> error_port_range
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
},
|
||||
{
|
||||
},
|
||||
onValueSaved = { value ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(playbackProxyServerPort = value.toInt())
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
if (includeBlacklist) {
|
||||
add {
|
||||
SettingCardItem(
|
||||
title = stringResource(Res.string.settings_blacklist_title),
|
||||
subtitle = stringResource(Res.string.settings_blacklist_subtitle),
|
||||
@ -235,8 +237,12 @@ internal fun LazyListScope.playbackSection(
|
||||
navigatorCommands.navigateTo(Routes.Blacklist)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_playback)
|
||||
}
|
||||
settingsSectionCard(items = cards)
|
||||
}
|
||||
|
||||
@ -32,8 +32,11 @@ import spotube.composeapp.generated.resources.settings_updates_auto_check_title
|
||||
internal fun LazyListScope.updatesSection(
|
||||
settings: UserSettings,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
includeHeader: Boolean = true,
|
||||
) {
|
||||
settingsSectionHeader(Res.string.settings_section_updates)
|
||||
if (includeHeader) {
|
||||
settingsSectionHeader(Res.string.settings_section_updates)
|
||||
}
|
||||
settingsSectionCard(
|
||||
items = listOf {
|
||||
SwitchSettingCard(
|
||||
|
||||
@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioFormat
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioQuality
|
||||
@ -103,6 +104,23 @@ internal fun LazyListScope.settingsSectionHeader(title: StringResource) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page-level title for a settings destination (used by the large-screen sidebar
|
||||
* layout, where each section is its own page rather than one entry in a long list).
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsPageTitle(title: StringResource) {
|
||||
Text(
|
||||
text = stringResource(title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SettingsItemIcon(
|
||||
imageVector: ImageVector,
|
||||
|
||||
@ -47,7 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
@ -235,7 +235,7 @@ fun AppExpandedPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
|
||||
@ -74,6 +74,8 @@ import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.remote.ConnectionRequestDialogHost
|
||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||
import dev.krtirtho.spotube.core.ui.base.baseSurfaceBackdrop
|
||||
import dev.krtirtho.spotube.modules.devices.PlayDestinationPickerHost
|
||||
import dev.krtirtho.spotube.modules.lyrics.LyricsScreen
|
||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContent
|
||||
@ -100,6 +102,7 @@ fun AppShell(
|
||||
) {
|
||||
val navigatorCommands: NavigationCommands = koinInject()
|
||||
val remotePlaybackController: RemotePlaybackController = koinInject()
|
||||
val baseUIColors = LocalBaseUIColors.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(remotePlaybackController) {
|
||||
remotePlaybackController.events.collect { message ->
|
||||
@ -156,7 +159,7 @@ fun AppShell(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight)
|
||||
.hazeSource(state = bgHazeState) // <-- Put it here!
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
@ -229,6 +232,11 @@ fun AppShell(
|
||||
}
|
||||
} else {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.baseSurfaceBackdrop(baseUIColors.surfaceTheme, baseUIColors.isLight),
|
||||
)
|
||||
CompactPlayerOverlay(
|
||||
navigator = navigator,
|
||||
navigationState = navigationState,
|
||||
|
||||
@ -24,23 +24,18 @@ import androidx.compose.animation.fadeOut
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -51,24 +46,17 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||
import dev.chrisbanes.haze.blur.blurEffect
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.krtirtho.spotube.NavigationItem
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationState
|
||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
||||
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
||||
import dev.krtirtho.spotube.core.ui.component.SidebarItem
|
||||
import dev.krtirtho.spotube.core.ui.component.SidebarSectionLabel
|
||||
import dev.krtirtho.spotube.core.ui.component.VerticalScrollbar
|
||||
import dev.krtirtho.spotube.modules.downloads.DownloadBadgeIndicator
|
||||
import dev.krtirtho.spotube.modules.library.LibraryState
|
||||
@ -94,21 +82,13 @@ fun AppSidebar(
|
||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||
val width by animateDpAsState(targetValue = if (expanded) 236.dp else 86.dp)
|
||||
val currentLibraryTab by libraryState.currentTab.collectAsState()
|
||||
val surfaceTint = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxHeight()
|
||||
.width(width)
|
||||
.hazeEffect(hazeState) {
|
||||
blurEffect {
|
||||
blurRadius = 20.dp
|
||||
colorEffects = listOf(
|
||||
HazeColorEffect.tint(surfaceTint)
|
||||
)
|
||||
}
|
||||
},
|
||||
.width(width),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@ -150,20 +130,12 @@ fun AppSidebar(
|
||||
items(sidebarTabs.size) { index ->
|
||||
when (val tab = sidebarTabs[index]) {
|
||||
is NavigationItem.Group<*> -> {
|
||||
androidx.compose.animation.AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = tab.title.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
SidebarSectionLabel(text = tab.title, expanded = expanded)
|
||||
tab.items.forEach {
|
||||
val selected = navigationState.topLevelRoute == it.route
|
||||
SidebarItem(
|
||||
label = it.title,
|
||||
activeIcon = it.icon,
|
||||
icon = it.icon,
|
||||
onClick = {
|
||||
if (it.route == Routes.Library) {
|
||||
libraryState.currentTab.value =
|
||||
@ -173,8 +145,17 @@ fun AppSidebar(
|
||||
},
|
||||
selected = if (it.route == Routes.Library) selected && currentLibraryTab == it.data else selected,
|
||||
expanded = expanded,
|
||||
showDownloadBadge = it.route == Routes.Library
|
||||
&& it.data == LibraryTab.Downloads
|
||||
badge = if (it.route == Routes.Library
|
||||
&& it.data == LibraryTab.Downloads
|
||||
) {
|
||||
{
|
||||
DownloadBadgeIndicator(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -184,7 +165,7 @@ fun AppSidebar(
|
||||
val selected = navigationState.topLevelRoute == tab.route
|
||||
SidebarItem(
|
||||
label = tab.title,
|
||||
activeIcon = tab.icon,
|
||||
icon = tab.icon,
|
||||
onClick = {
|
||||
navigator.navigate(tab.route)
|
||||
},
|
||||
@ -206,7 +187,7 @@ fun AppSidebar(
|
||||
HorizontalDivider()
|
||||
SidebarItem(
|
||||
label = "Settings",
|
||||
activeIcon = Iconsax.IconsaxSetting2,
|
||||
icon = Iconsax.IconsaxSetting2,
|
||||
onClick = {
|
||||
navigator.navigate(Routes.Settings)
|
||||
},
|
||||
@ -216,75 +197,3 @@ fun AppSidebar(
|
||||
Spacer(modifier = Modifier.height(120.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SidebarItem(
|
||||
label: String,
|
||||
activeIcon: ImageVector,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
onClick: () -> Unit,
|
||||
showDownloadBadge: Boolean = false,
|
||||
) {
|
||||
val itemContent: @Composable RowScope.() -> Unit = {
|
||||
Box(modifier = Modifier.size(16.dp)) {
|
||||
Icon(
|
||||
imageVector = activeIcon,
|
||||
contentDescription = label,
|
||||
tint = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (showDownloadBadge) {
|
||||
DownloadBadgeIndicator(
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
style = LocalTextStyle.current.copy(
|
||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
||||
}
|
||||
|
||||
val buttonModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
val contentPadding = PaddingValues(horizontal = 5.dp)
|
||||
|
||||
if (selected) {
|
||||
SecondaryButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.secondary
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
content = itemContent,
|
||||
)
|
||||
} else {
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.outline
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
hoverOnly = true,
|
||||
content = itemContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -41,7 +41,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -79,7 +79,7 @@ fun WelcomeScreen(viewModel: WelcomeViewModel) {
|
||||
WelcomeTheme {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
WelcomeBackground()
|
||||
Scaffold(
|
||||
BaseScaffold(
|
||||
containerColor = Color.Transparent,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
|
||||
@ -29,6 +29,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
||||
import org.jetbrains.compose.resources.Font
|
||||
@ -60,7 +61,11 @@ fun WelcomeTheme(content: @Composable () -> Unit) {
|
||||
outlineVariant = Color(0xFFD2D0CC),
|
||||
)
|
||||
MaterialTheme(colorScheme = scheme) {
|
||||
CompositionLocalProvider(LocalBaseUITheme provides rememberBaseUITheme()) {
|
||||
val baseTheme = rememberBaseUITheme()
|
||||
CompositionLocalProvider(
|
||||
LocalBaseUITheme provides baseTheme,
|
||||
LocalBaseUIColors provides baseTheme.colors,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import dev.krtirtho.spotube.core.systemtray.SystemTrayService
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
|
||||
import dev.krtirtho.spotube.resources.iconsax.FluentMaximize
|
||||
@ -63,6 +64,7 @@ actual fun ApplicationMainBar(
|
||||
backButton: Boolean,
|
||||
transparent: Boolean,
|
||||
) {
|
||||
val colors = LocalBaseUIColors.current
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
|
||||
@ -82,9 +84,19 @@ actual fun ApplicationMainBar(
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent,
|
||||
titleContentColor = colors.onSurface,
|
||||
actionIconContentColor = colors.onSurfaceVariant,
|
||||
navigationIconContentColor = colors.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
TopAppBarDefaults.topAppBarColors()
|
||||
// The app bar follows the selected surface theme rather than Material's.
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = colors.surface,
|
||||
scrolledContainerColor = colors.surfaceContainer,
|
||||
titleContentColor = colors.onSurface,
|
||||
actionIconContentColor = colors.onSurfaceVariant,
|
||||
navigationIconContentColor = colors.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
|
||||
)
|
||||
|
||||
@ -17,26 +17,43 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
import androidx.compose.foundation.LocalScrollbarStyle
|
||||
import androidx.compose.foundation.ScrollbarStyle
|
||||
import androidx.compose.foundation.VerticalScrollbar
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.rememberScrollbarAdapter
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
||||
|
||||
@Composable
|
||||
actual fun VerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier
|
||||
) {
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(listState),
|
||||
modifier = modifier.fillMaxHeight()
|
||||
.padding(end = 2.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.width(12.dp),
|
||||
)
|
||||
val colors = LocalBaseUIColors.current
|
||||
CompositionLocalProvider(
|
||||
LocalScrollbarStyle provides ScrollbarStyle(
|
||||
minimalHeight = 16.dp,
|
||||
thickness = 8.dp,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
hoverDurationMillis = 300,
|
||||
unhoverColor = colors.onSurface.copy(alpha = 0.18f),
|
||||
hoverColor = colors.onSurface.copy(alpha = 0.4f),
|
||||
),
|
||||
) {
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(listState),
|
||||
modifier = modifier.fillMaxHeight()
|
||||
.padding(end = 2.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
.width(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||