From 477f4531000eb2e4a3d72fa186bc6dae93516def Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 2 Nov 2026 22:31:25 -0500 Subject: [PATCH] =?UTF-8?q?feat(android):=20settings=20read=20as=20a=20men?= =?UTF-8?q?u=20=E2=80=94=20every=20group=20on=20one=20Section=20plate,=20e?= =?UTF-8?q?very=20setting=20one=20SettingRow=20(#6768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings and Notification settings were hand-rolled ElevatedCards, each with its own padding, title style and row layout, and several rows carried two- and three-line explanations. They are now built from one vocabulary in theme/Section.kt: Section (the plate), SettingRow (title, one short line, an optional icon, the control at the end), SwitchRow and ChoiceRow (the whole row toggles or opens the choices), RowValue, RowSpinner, RowChevron, Muted. The plate is Minstrel's own raised surface with its radius tokens, so the app keeps its look. Sign out, sync and clear cache are rows, not loose buttons; the theme, leveling, loudness target and storage caps are choice rows; the About card (also the update sheet on Home) shows the version as a value and the update as a row with its one button. Co-Authored-By: Claude Opus 5.5 --- .../ui/NotificationSettingsScreen.kt | 139 +++----- .../minstrel/settings/ui/ListenBrainzCard.kt | 117 ++----- .../minstrel/settings/ui/NormalizationCard.kt | 128 ++------ .../minstrel/settings/ui/PasswordCard.kt | 79 ++--- .../minstrel/settings/ui/ProfileCard.kt | 53 ++- .../minstrel/settings/ui/SettingsScreen.kt | 303 +++++------------- .../minstrel/settings/ui/StorageCard.kt | 217 +++---------- .../com/fabledsword/minstrel/theme/Section.kt | 262 +++++++++++++++ .../minstrel/update/ui/AppVersionCard.kt | 85 ++--- 9 files changed, 574 insertions(+), 809 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/theme/Section.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt index 70d74219..9cae363f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/notifications/ui/NotificationSettingsScreen.kt @@ -9,23 +9,17 @@ import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -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.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,7 +29,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -45,8 +38,6 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import com.composables.icons.lucide.ChevronRight -import com.composables.icons.lucide.Lucide import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire import com.fabledsword.minstrel.nav.NotificationSettings @@ -54,13 +45,19 @@ import com.fabledsword.minstrel.notifications.data.NotificationChannel import com.fabledsword.minstrel.shared.widgets.LoadingCentered import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets +import com.fabledsword.minstrel.theme.Muted +import com.fabledsword.minstrel.theme.RowChevron +import com.fabledsword.minstrel.theme.Section +import com.fabledsword.minstrel.theme.SectionEdge +import com.fabledsword.minstrel.theme.SectionGap +import com.fabledsword.minstrel.theme.SettingRow +import com.fabledsword.minstrel.theme.SwitchRow -private val CELL_WIDTH = 56.dp +private val CELL_WIDTH = 48.dp /** - * Per-user notification settings: a row per kind with a box for each - * channel. Rows read as a menu; anything that needs explaining - * is one short line. + * Per-user notification settings: what this device does, then a row per + * kind with a box for each channel. */ @Composable fun NotificationSettingsScreen( @@ -109,38 +106,36 @@ private fun SettingsBody( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp), + .padding(horizontal = SectionEdge, vertical = SectionEdge), + verticalArrangement = Arrangement.spacedBy(SectionGap), ) { - deviceRows() - HeaderRow() - settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) } + Section("This device") { deviceRows() } + Section("Requests", trailing = { ChannelHeaders() }) { + settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) } + if (!settings.emailAvailable) { + Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin)) + } + } val adminKinds = settings.kinds.filter { it.adminOnly } if (adminKinds.isNotEmpty()) { - SectionLabel("Library health") - adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) } - } - if (!settings.emailAvailable) { - Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin)) + Section("Library health", trailing = { ChannelHeaders() }) { + adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) } + } } } } +/** The channel names over the boxes, in the section's title line. */ @Composable -private fun HeaderRow() { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Modifier.weight(1f)) - NotificationChannel.entries.forEach { c -> - Text( - text = channelLabel(c), - modifier = Modifier.width(CELL_WIDTH), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } +private fun ChannelHeaders() { + NotificationChannel.entries.forEach { c -> + Text( + text = channelLabel(c), + modifier = Modifier.width(CELL_WIDTH), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) } } @@ -151,11 +146,7 @@ private fun KindRow( onSet: (String, NotificationChannel, Boolean) -> Unit, ) { val label = kindLabel(row.kind) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + SettingRow(title = label) { NotificationChannel.entries.forEach { c -> val enabled = channelEnabled(row, c, emailAvailable) Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) { @@ -204,60 +195,24 @@ private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boole @Composable private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable(value = checked, role = Role.Switch, onValueChange = onChange) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column(Modifier.weight(1f)) { - Text("Notifications when the app is closed", style = MaterialTheme.typography.bodyLarge) - // The ongoing notice is the price of not using Google's push; - // said plainly rather than left to be discovered. - Muted("Keeps a quiet notice in the shade, in place of Google's push", padded = false) - } - Switch(checked = checked, onCheckedChange = null) - } + // The ongoing notice is the price of not using Google's push; said + // plainly rather than left to be discovered. + SwitchRow( + title = "Notifications when the app is closed", + summary = "Keeps a quiet notice in the shade, in place of Google's push", + checked = checked, + onCheckedChange = onChange, + ) } @Composable private fun PhoneBlockedRow(onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column(Modifier.weight(1f)) { - Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge) - Muted("Allow notifications for Minstrel in system settings", padded = false) - } - Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun SectionLabel(text: String) { - Text( - text = text, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -@Composable -private fun Muted(text: String, padded: Boolean = true) { - Text( - text = text, - modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + SettingRow( + title = "Phone alerts are off", + summary = "Allow notifications for Minstrel in system settings", + tone = MaterialTheme.colorScheme.error, + onClick = onClick, + ) { RowChevron() } } private fun openAppNotificationSettings(context: Context) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt index 89718579..2625f1d7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt @@ -1,20 +1,14 @@ package com.fabledsword.minstrel.settings.ui -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.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -25,44 +19,29 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.fabledsword.minstrel.models.ListenBrainzStatus +import com.fabledsword.minstrel.theme.Muted +import com.fabledsword.minstrel.theme.Section +import com.fabledsword.minstrel.theme.SwitchRow @Composable fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "ListenBrainz", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + Section("ListenBrainz") { + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } else { + ListenBrainzForm( + status = state.status, + tokenInput = state.tokenInput, + isSaving = state.isSaving, + message = state.message, + onTokenChange = viewModel::setTokenInput, + onSaveToken = viewModel::saveToken, + onSetEnabled = viewModel::setEnabled, ) - Text( - text = "Send your plays to ListenBrainz so you can keep your " + - "scrobble history portable. Get a token at " + - "listenbrainz.org/profile — it's stored encrypted on " + - "the server and never read back.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (state.isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } - } else { - ListenBrainzForm( - status = state.status, - tokenInput = state.tokenInput, - isSaving = state.isSaving, - message = state.message, - onTokenChange = viewModel::setTokenInput, - onSaveToken = viewModel::saveToken, - onSetEnabled = viewModel::setEnabled, - ) - } } } } @@ -77,25 +56,25 @@ private fun ListenBrainzForm( onSaveToken: () -> Unit, onSetEnabled: (Boolean) -> Unit, ) { - TokenField( - tokenInput = tokenInput, - tokenSet = status?.tokenSet == true, - onChange = onTokenChange, + val tokenSet = status?.tokenSet == true + SwitchRow( + title = "Send my plays to ListenBrainz", + summary = when { + !tokenSet -> "Save a token first" + status?.lastScrobbledAt != null -> "Last scrobble: ${status.lastScrobbledAt}" + else -> null + }, + checked = status?.enabled == true, + onCheckedChange = onSetEnabled, + enabled = !isSaving && tokenSet, ) - Spacer(Modifier.height(4.dp)) + TokenField(tokenInput = tokenInput, tokenSet = tokenSet, onChange = onTokenChange) SaveTokenButton( isSaving = isSaving, enabled = !isSaving && tokenInput.isNotBlank(), onClick = onSaveToken, ) - EnabledRow(status = status, isSaving = isSaving, onSetEnabled = onSetEnabled) - if (message != null) { - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + if (message != null) Muted(message) } @Composable @@ -105,6 +84,7 @@ private fun TokenField(tokenInput: String, tokenSet: Boolean, onChange: (String) onValueChange = onChange, label = { Text(if (tokenSet) "Replace token" else "Token") }, placeholder = { Text(if (tokenSet) "Token saved" else "") }, + supportingText = { Text("From listenbrainz.org/profile; never shown again") }, modifier = Modifier.fillMaxWidth(), singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -129,36 +109,3 @@ private fun SaveTokenButton(isSaving: Boolean, enabled: Boolean, onClick: () -> Text(if (isSaving) "Saving…" else "Save token") } } - -@Composable -private fun EnabledRow( - status: ListenBrainzStatus?, - isSaving: Boolean, - onSetEnabled: (Boolean) -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.padding(end = 12.dp)) { - Text( - text = "Send my plays to ListenBrainz", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - if (status?.lastScrobbledAt != null) { - Text( - text = "Last scrobble: ${status.lastScrobbledAt}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Switch( - checked = status?.enabled == true, - onCheckedChange = onSetEnabled, - enabled = !isSaving && status?.tokenSet == true, - ) - } -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt index 0e9076ee..eae7bab6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/NormalizationCard.kt @@ -1,30 +1,15 @@ package com.fabledsword.minstrel.settings.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.fabledsword.minstrel.settings.data.NormalizationBoost import com.fabledsword.minstrel.settings.data.NormalizationMode import com.fabledsword.minstrel.settings.data.NormalizationPrefs +import com.fabledsword.minstrel.theme.ChoiceRow +import com.fabledsword.minstrel.theme.Section +import com.fabledsword.minstrel.theme.SwitchRow /** * Volume leveling. The choice is stored on the server, so the @@ -36,98 +21,39 @@ fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) { NormalizationCardContent(prefs = prefs, onChange = viewModel::update) } -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun NormalizationCardContent( prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit, ) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Volume leveling", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Hint(modeHint(prefs.mode)) - val modes = NormalizationMode.entries - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - modes.forEachIndexed { index, mode -> - SegmentedButton( - selected = mode == prefs.mode, - onClick = { onChange(prefs.copy(mode = mode)) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = modes.size, - ), - ) { Text(modeLabel(mode)) } - } - } - if (prefs.mode != NormalizationMode.OFF) { - TargetRow(prefs = prefs, onChange = onChange) - BoostRow(prefs = prefs, onChange = onChange) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) { - Text( - text = "Target loudness", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - val targets = NormalizationPrefs.TARGETS - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - targets.forEachIndexed { index, lufs -> - SegmentedButton( - selected = lufs == prefs.targetLufs, - onClick = { onChange(prefs.copy(targetLufs = lufs)) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = targets.size, - ), - ) { Text("$lufs LUFS") } - } - } -} - -@Composable -private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Boost quiet tracks fully", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Hint("A limiter catches the peaks.") - } - Spacer(Modifier.size(12.dp)) - Switch( - checked = prefs.boost == NormalizationBoost.LIMITER, - onCheckedChange = { on -> - val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM - onChange(prefs.copy(boost = boost)) - }, + Section("Volume leveling") { + ChoiceRow( + title = "Leveling", + summary = modeHint(prefs.mode), + options = NormalizationMode.entries.map { it to modeLabel(it) }, + selected = prefs.mode, + onSelect = { onChange(prefs.copy(mode = it)) }, ) + if (prefs.mode != NormalizationMode.OFF) { + ChoiceRow( + title = "Target loudness", + options = NormalizationPrefs.TARGETS.map { it to "$it LUFS" }, + selected = prefs.targetLufs, + onSelect = { onChange(prefs.copy(targetLufs = it)) }, + ) + SwitchRow( + title = "Boost quiet tracks fully", + summary = "A limiter catches the peaks", + checked = prefs.boost == NormalizationBoost.LIMITER, + onCheckedChange = { on -> + val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM + onChange(prefs.copy(boost = boost)) + }, + ) + } } } -@Composable -private fun Hint(text: String) { - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - private fun modeLabel(mode: NormalizationMode): String = when (mode) { NormalizationMode.OFF -> "Off" NormalizationMode.AUTO -> "Auto" diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt index f54e2b02..749ebf60 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/PasswordCard.kt @@ -1,7 +1,5 @@ package com.fabledsword.minstrel.settings.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -22,59 +19,45 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fabledsword.minstrel.theme.Muted +import com.fabledsword.minstrel.theme.Section @Composable fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + Section("Password") { + PasswordField( + value = state.current, + label = "Current password", + onChange = viewModel::setCurrent, + ) + PasswordField( + value = state.next, + label = "New password", + onChange = viewModel::setNext, + ) + PasswordField( + value = state.confirm, + label = "Confirm new password", + onChange = viewModel::setConfirm, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = viewModel::change, + enabled = !state.isChanging, + modifier = Modifier.fillMaxWidth(), ) { - Text( - text = "Password", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - PasswordField( - value = state.current, - label = "Current password", - onChange = viewModel::setCurrent, - ) - PasswordField( - value = state.next, - label = "New password", - onChange = viewModel::setNext, - ) - PasswordField( - value = state.confirm, - label = "Confirm new password", - onChange = viewModel::setConfirm, - ) - Spacer(Modifier.height(4.dp)) - Button( - onClick = viewModel::change, - enabled = !state.isChanging, - modifier = Modifier.fillMaxWidth(), - ) { - if (state.isChanging) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.size(8.dp)) - } - Text(if (state.isChanging) "Changing…" else "Change password") - } - if (state.message != null) { - Text( - text = state.message!!, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + if (state.isChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, ) + Spacer(Modifier.size(8.dp)) } + Text(if (state.isChanging) "Changing…" else "Change password") } + state.message?.let { Muted(it) } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt index f76661fd..6de99a4a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ProfileCard.kt @@ -1,8 +1,6 @@ package com.fabledsword.minstrel.settings.ui -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -21,36 +18,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fabledsword.minstrel.theme.Muted +import com.fabledsword.minstrel.theme.Section @Composable fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Profile", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + Section("Profile") { + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + } else { + ProfileForm( + displayName = state.displayName, + email = state.email, + isSaving = state.isSaving, + message = state.message, + onDisplayNameChange = viewModel::setDisplayName, + onEmailChange = viewModel::setEmail, + onSave = viewModel::save, ) - if (state.isLoading) { - Box( - modifier = Modifier.fillMaxWidth().padding(16.dp), - contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } - } else { - ProfileForm( - displayName = state.displayName, - email = state.email, - isSaving = state.isSaving, - message = state.message, - onDisplayNameChange = viewModel::setDisplayName, - onEmailChange = viewModel::setEmail, - onSave = viewModel::save, - ) - } } } } @@ -95,11 +84,5 @@ private fun ProfileForm( } Text(if (isSaving) "Saving…" else "Save profile") } - if (message != null) { - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + if (message != null) Muted(message) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index 349f9b6c..0565fc94 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -1,34 +1,19 @@ -@file:Suppress("TooManyFunctions") // Settings screen + per-card helper composables +@file:Suppress("TooManyFunctions") // Settings screen + per-section composables package com.fabledsword.minstrel.settings.ui -import androidx.compose.foundation.clickable -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -37,16 +22,11 @@ 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.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.composables.icons.lucide.Bell -import com.composables.icons.lucide.ChevronRight import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.Lucide @@ -54,10 +34,18 @@ import com.composables.icons.lucide.Shield import com.fabledsword.minstrel.nav.Admin import com.fabledsword.minstrel.nav.NotificationSettings import com.fabledsword.minstrel.nav.Requests -import com.fabledsword.minstrel.nav.Settings as SettingsRoute import com.fabledsword.minstrel.nav.ServerUrl -import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets +import com.fabledsword.minstrel.nav.Settings as SettingsRoute import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar +import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets +import com.fabledsword.minstrel.theme.ChoiceRow +import com.fabledsword.minstrel.theme.RowChevron +import com.fabledsword.minstrel.theme.RowSpinner +import com.fabledsword.minstrel.theme.Section +import com.fabledsword.minstrel.theme.SectionEdge +import com.fabledsword.minstrel.theme.SectionGap +import com.fabledsword.minstrel.theme.SettingRow +import com.fabledsword.minstrel.theme.SwitchRow import com.fabledsword.minstrel.theme.ThemeMode import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.update.ui.AppVersionCard @@ -119,7 +107,7 @@ fun SettingsScreen( @Composable private fun SettingsList( - inner: androidx.compose.foundation.layout.PaddingValues, + inner: PaddingValues, state: SettingsState, themeMode: ThemeMode, onPickTheme: (ThemeMode) -> Unit, @@ -134,48 +122,26 @@ private fun SettingsList( .fillMaxSize() .padding(inner) .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = SectionEdge, vertical = SectionEdge), + verticalArrangement = Arrangement.spacedBy(SectionGap), ) { - AccountCard( - username = state.username, - isAdmin = state.isAdmin, - serverUrl = state.serverUrl, + AccountSection( + state = state, + onNavToRequests = onNavToRequests, + onNavToNotifications = onNavToNotifications, + onNavToAdmin = onNavToAdmin, + onSignOutClick = onSignOutClick, ) - NavTile( - icon = Lucide.ListMusic, - title = "My requests", - subtitle = "Track what you've asked Minstrel to add", - onClick = onNavToRequests, - ) - NavTile( - icon = Lucide.Bell, - title = "Notifications", - subtitle = "What reaches you, and where", - onClick = onNavToNotifications, - ) - if (state.isAdmin) { - NavTile( - icon = Lucide.Shield, - title = "Admin", - subtitle = "Manage requests, quarantine, and users", - onClick = onNavToAdmin, - ) - } ProfileCard() PasswordCard() ListenBrainzCard() if (state.diagnosticsEnabledByAdmin) { - DiagnosticsCard( - optOut = state.diagnosticsOptOut, - onToggle = onToggleDiagnostics, - ) + DiagnosticsSection(optOut = state.diagnosticsOptOut, onToggle = onToggleDiagnostics) } NormalizationCard() - AppearanceCard(themeMode = themeMode, onPick = onPickTheme) + AppearanceSection(themeMode = themeMode, onPick = onPickTheme) StorageCard() AboutCard() - SignOutButton(isSigningOut = state.isSigningOut, onSignOut = onSignOutClick) } } @@ -205,84 +171,48 @@ private fun SignOutConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { ) } +/** Who is signed in, where, the screens that belong to the account, and signing out. */ @Composable -private fun NavTile( - icon: ImageVector, - title: String, - subtitle: String, - onClick: () -> Unit, +private fun AccountSection( + state: SettingsState, + onNavToRequests: () -> Unit, + onNavToNotifications: () -> Unit, + onNavToAdmin: () -> Unit, + onSignOutClick: () -> Unit, ) { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.size(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Lucide.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "Account", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - val adminTag = if (isAdmin) " · admin" else "" - Text( - text = when { - username.isEmpty() -> "Signed in" - else -> "Signed in as $username$adminTag" - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = "Server", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = serverUrl, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Section("Account") { + SettingRow( + title = state.username.ifEmpty { "Signed in" }, + summary = if (state.isAdmin) "Signed in · admin" else "Signed in", + ) + SettingRow(title = "Server", summary = state.serverUrl) + SettingRow( + title = "My requests", + summary = "What you've asked Minstrel to add", + icon = Lucide.ListMusic, + onClick = onNavToRequests, + ) { RowChevron() } + SettingRow( + title = "Notifications", + summary = "What reaches you, and where", + icon = Lucide.Bell, + onClick = onNavToNotifications, + ) { RowChevron() } + if (state.isAdmin) { + SettingRow( + title = "Admin", + summary = "Requests, quarantine and users", + icon = Lucide.Shield, + onClick = onNavToAdmin, + ) { RowChevron() } } + SettingRow( + title = if (state.isSigningOut) "Signing out…" else "Sign out", + icon = Lucide.LogOut, + tone = MaterialTheme.colorScheme.error, + enabled = !state.isSigningOut, + onClick = onSignOutClick, + ) { if (state.isSigningOut) RowSpinner() } } } @@ -290,74 +220,27 @@ private fun AccountCard(username: String, isAdmin: Boolean, serverUrl: String) { // the user turn reporting OFF on this device (battery/privacy) without // touching the account flag. Checked = reporting on = NOT opted out. @Composable -private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Diagnostics", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Send diagnostic reports", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Your admin enabled debug mode to investigate an " + - "issue. This device streams connectivity and playback " + - "diagnostics. Turn off any time.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.size(12.dp)) - Switch( - checked = !optOut, - onCheckedChange = { checked -> onToggle(!checked) }, - ) - } - } +private fun DiagnosticsSection(optOut: Boolean, onToggle: (Boolean) -> Unit) { + Section("Diagnostics") { + SwitchRow( + title = "Send diagnostic reports", + summary = "Your admin turned on debug mode", + checked = !optOut, + onCheckedChange = { checked -> onToggle(!checked) }, + ) } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) { - val options = ThemeMode.entries - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Appearance", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "System follows your device setting.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, mode -> - SegmentedButton( - selected = mode == themeMode, - onClick = { onPick(mode) }, - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = options.size, - ), - ) { Text(displayName(mode)) } - } - } - } +private fun AppearanceSection(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) { + Section("Appearance") { + ChoiceRow( + title = "Theme", + summary = if (themeMode == ThemeMode.SYSTEM) "Follows your device" else null, + options = ThemeMode.entries.map { it to displayName(it) }, + selected = themeMode, + onSelect = onPick, + ) } } @@ -376,29 +259,3 @@ private fun AboutCard(model: UpdateViewModel = hiltViewModel()) { CheckWhileShown(model) AppVersionCard(model) } - -@Composable -private fun SignOutButton(isSigningOut: Boolean, onSignOut: () -> Unit) { - Button( - onClick = onSignOut, - enabled = !isSigningOut, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ), - ) { - if (isSigningOut) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - strokeWidth = 2.dp, - ) - } else { - Box(modifier = Modifier.padding(end = 4.dp)) { - Icon(Lucide.LogOut, contentDescription = null) - } - Text("Sign out") - } - } -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt index 3cc572a7..88d638c9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt @@ -1,23 +1,9 @@ package com.fabledsword.minstrel.settings.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.selection.toggleable import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -25,12 +11,13 @@ 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.semantics.Role -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.fabledsword.minstrel.theme.ChoiceRow +import com.fabledsword.minstrel.theme.RowSpinner +import com.fabledsword.minstrel.theme.Section +import com.fabledsword.minstrel.theme.SettingRow +import com.fabledsword.minstrel.theme.SwitchRow private const val GIB: Long = 1024L * 1024 * 1024 private const val MIB: Long = 1024L * 1024 @@ -48,44 +35,39 @@ private val CAP_OPTIONS: List> = listOf( fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() var showClearConfirm by remember { mutableStateOf(false) } - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "Storage", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - // Two budgets: a liked track that is also recently played counts - // against Liked songs only. The prefetch window has no control; - // AudioPrefetcher uses the stored value (default 5). - CapDropdown( - label = "Liked songs", - hint = "${formatBytes(state.likedBytes)} used", - value = state.settings.likedCapBytes, - onSet = viewModel::setLikedCapBytes, - ) - CapDropdown( - label = "Recently played", - hint = "${formatBytes(state.recentBytes)} used", - value = state.settings.rollingCapBytes, - onSet = viewModel::setRollingCapBytes, - ) - SwitchRow( - label = "Keep liked songs offline", - hint = "Downloads on Wi-Fi", - checked = state.settings.cacheLikedTracks, - onCheckedChange = viewModel::setCacheLikedTracks, - ) - StorageActions( - isSyncing = state.isSyncing, - isClearing = state.isClearing, - onSync = viewModel::syncNow, - onClear = { showClearConfirm = true }, - ) - } + Section("Storage") { + // Two budgets: a liked track that is also recently played counts + // against Liked songs only. The prefetch window has no control; + // AudioPrefetcher uses the stored value (default 5). + CapRow( + title = "Liked songs", + used = state.likedBytes, + value = state.settings.likedCapBytes, + onSet = viewModel::setLikedCapBytes, + ) + CapRow( + title = "Recently played", + used = state.recentBytes, + value = state.settings.rollingCapBytes, + onSet = viewModel::setRollingCapBytes, + ) + SwitchRow( + title = "Keep liked songs offline", + summary = "Downloads on Wi-Fi", + checked = state.settings.cacheLikedTracks, + onCheckedChange = viewModel::setCacheLikedTracks, + ) + SettingRow( + title = if (state.isSyncing) "Syncing…" else "Sync library now", + enabled = !state.isSyncing, + onClick = viewModel::syncNow, + ) { if (state.isSyncing) RowSpinner() } + SettingRow( + title = if (state.isClearing) "Clearing…" else "Clear cached audio", + tone = MaterialTheme.colorScheme.error, + enabled = !state.isClearing, + onClick = { showClearConfirm = true }, + ) { if (state.isClearing) RowSpinner() } } if (showClearConfirm) { ClearCacheConfirmDialog( @@ -98,128 +80,19 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) { } } +/** A storage budget: how much it holds now, and the cap it may grow to. */ @Composable -private fun CapDropdown(label: String, hint: String, value: Long, onSet: (Long) -> Unit) { - val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second - ?: formatBytes(value) - LabeledDropdown( - label = label, - hint = hint, - selectedLabel = selectedLabel, +private fun CapRow(title: String, used: Long, value: Long, onSet: (Long) -> Unit) { + ChoiceRow( + title = title, + summary = "${formatBytes(used)} used", options = CAP_OPTIONS, + selected = value, onSelect = onSet, + selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second ?: formatBytes(value), ) } -/** A setting's name, with one short line under it. */ -@Composable -private fun RowLabel(label: String, hint: String, modifier: Modifier = Modifier) { - Column(modifier = modifier) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = hint, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun SwitchRow(label: String, hint: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange), - verticalAlignment = Alignment.CenterVertically, - ) { - RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f)) - Spacer(Modifier.size(12.dp)) - Switch(checked = checked, onCheckedChange = null) - } -} - -/** - * Label and hint on the left, button-with-current-value on the right that - * opens a DropdownMenu of [options]. Used by the cap selectors. - */ -@Composable -private fun LabeledDropdown( - label: String, - hint: String, - selectedLabel: String, - options: List>, - onSelect: (T) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f)) - OutlinedButton(onClick = { expanded = true }) { - Text(selectedLabel) - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - options.forEach { (value, name) -> - DropdownMenuItem( - text = { Text(name) }, - onClick = { - expanded = false - onSelect(value) - }, - ) - } - } - } - } -} - -@Composable -private fun StorageActions( - isSyncing: Boolean, - isClearing: Boolean, - onSync: () -> Unit, - onClear: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = onClear, - enabled = !isClearing, - ) { - if (isClearing) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.size(8.dp)) - } - Text(if (isClearing) "Clearing…" else "Clear cache") - } - OutlinedButton( - onClick = onSync, - enabled = !isSyncing, - ) { - if (isSyncing) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.size(8.dp)) - } - Text(if (isSyncing) "Syncing…" else "Sync now") - } - } -} - @Composable private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { AlertDialog( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/Section.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/Section.kt new file mode 100644 index 00000000..d1245e21 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/Section.kt @@ -0,0 +1,262 @@ +@file:Suppress("TooManyFunctions") // the settings vocabulary: a plate and its row kinds + +package com.fabledsword.minstrel.theme + +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.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.ChevronRight +import com.composables.icons.lucide.ChevronDown +import com.composables.icons.lucide.Lucide + +/** + * The plate every group of settings sits on. Every section gets the same + * plate, so the screen reads as one thing; a section that matters earns its + * weight with what is inside it, never with a louder box. + * + * The rhythm between lines lives here, not at the call sites. + */ +@Composable +fun Section( + title: String, + modifier: Modifier = Modifier, + trailing: @Composable (RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(FabledSwordFlatTokens.radiusLg), + // `iron`, the raised surface, on the `obsidian` ground. + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier.padding(SectionInset), + verticalArrangement = Arrangement.spacedBy(SectionRow), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (trailing != null) { + Row(verticalAlignment = Alignment.CenterVertically, content = trailing) + } + } + Spacer(Modifier.height(TITLE_GAP - SectionRow)) + content() + } + } +} + +/** The quiet line: a caption, a count, a consequence. */ +@Composable +fun Muted(said: String, modifier: Modifier = Modifier, tone: Color = Color.Unspecified) { + Text( + said, + style = MaterialTheme.typography.bodySmall, + color = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurfaceVariant else tone, + modifier = modifier, + ) +} + +/** + * One line of a settings section: what it is, one short line about it, and + * its control at the end. A settings screen is a menu, so the summary is one + * short line, never an explanation. + * + * With [onClick] the whole row is the tap target. [tone] colours the title of + * a row reporting something wrong, so the control beside it still reads as a + * control. + */ +@Composable +fun SettingRow( + title: String, + modifier: Modifier = Modifier, + summary: String? = null, + icon: ImageVector? = null, + tone: Color = Color.Unspecified, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + trailing: @Composable (RowScope.() -> Unit)? = null, +) { + val tappable = if (onClick != null) { + Modifier + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusMd)) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + } else { + Modifier + } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = ROW_HEIGHT) + .then(tappable) + .alpha(if (enabled) 1f else DISABLED_ALPHA), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + icon, + contentDescription = null, + tint = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurfaceVariant else tone, + modifier = Modifier.padding(end = ICON_GAP).size(ICON), + ) + } + Column(Modifier.weight(1f).padding(vertical = ROW_PAD)) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + color = if (tone == Color.Unspecified) MaterialTheme.colorScheme.onSurface else tone, + ) + if (summary != null) Muted(summary) + } + if (trailing != null) { + Row( + modifier = Modifier.padding(start = ICON_GAP), + verticalAlignment = Alignment.CenterVertically, + content = trailing, + ) + } + } +} + +/** A row whose control is a switch; the whole row toggles it. */ +@Composable +fun SwitchRow( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + enabled: Boolean = true, +) { + SettingRow( + title = title, + summary = summary, + enabled = enabled, + modifier = modifier + .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusMd)) + .toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange), + ) { + Switch(checked = checked, onCheckedChange = null, enabled = enabled) + } +} + +/** + * A one-of-N setting: the row shows the current choice, and a tap opens the + * choices under it. + */ +@Composable +fun ChoiceRow( + title: String, + options: List>, + selected: T, + onSelect: (T) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + selectedLabel: String = options.firstOrNull { it.first == selected }?.second.orEmpty(), +) { + var open by remember { mutableStateOf(false) } + Box(modifier) { + SettingRow(title = title, summary = summary, onClick = { open = true }) { + RowValue(selectedLabel) + Icon( + Lucide.ChevronDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp).size(SMALL_ICON), + ) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEach { (value, name) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + open = false + onSelect(value) + }, + ) + } + } + } +} + +/** A row's value, set to the right: a count, a version, a size. */ +@Composable +fun RowValue(said: String) { + Text( + said, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +/** Work in progress, at the size a row's trailing slot expects. */ +@Composable +fun RowSpinner() { + CircularProgressIndicator(modifier = Modifier.size(SPINNER), strokeWidth = 2.dp) +} + +/** "This opens something", for a row whose tap leads somewhere. */ +@Composable +fun RowChevron() { + Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) +} + +/** The screen margin either side of a plate. */ +val SectionEdge = 16.dp + +/** Between one plate and the next. */ +val SectionGap = 14.dp + +/** Inside a plate. */ +val SectionInset = 18.dp + +/** Between one line of a plate and the next. */ +val SectionRow = 6.dp + +private val TITLE_GAP = 12.dp + +/** Material's one-line list item height: the smallest a thumb reliably hits. */ +private val ROW_HEIGHT = 48.dp +private val ROW_PAD = 6.dp +private val ICON = 22.dp +private val SMALL_ICON = 16.dp +private val ICON_GAP = 14.dp +private const val DISABLED_ALPHA = 0.5f +private val SPINNER = 16.dp diff --git a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt index 1dee8f39..5f1e28b6 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/update/ui/AppVersionCard.kt @@ -1,24 +1,23 @@ package com.fabledsword.minstrel.update.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.settings.ui.formatBytes +import com.fabledsword.minstrel.theme.Muted +import com.fabledsword.minstrel.theme.RowSpinner +import com.fabledsword.minstrel.theme.RowValue +import com.fabledsword.minstrel.theme.Section +import com.fabledsword.minstrel.theme.SettingRow import com.fabledsword.minstrel.update.data.InstallStage import com.fabledsword.minstrel.update.data.UpdateUiState @@ -36,53 +35,41 @@ fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) { onPauseOrDispose {} } val state by model.state.collectAsStateWithLifecycle() - ElevatedCard(modifier = modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "About", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Installed: Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - val offer = state.offer - if (offer != null) { - Offered(offer, state, model::update) - } else { - Text( - text = versionWords(state, selfUpdates = !BuildConfig.DEBUG), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - state.problem?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - TextButton( - onClick = model::check, - enabled = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE, - ) { Text(if (state.checking) "Checking…" else "Check for updates") } + Section("About", modifier) { + SettingRow(title = "Version") { + RowValue("${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") } + val offer = state.offer + if (offer != null) { + Offered(offer, state, model::update) + } else { + val canCheck = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE + SettingRow( + title = "Check for updates", + summary = versionWords(state, selfUpdates = !BuildConfig.DEBUG), + enabled = canCheck || state.checking, + onClick = if (canCheck) model::check else null, + ) { if (state.checking) RowSpinner() } + } + state.problem?.let { Muted(it, tone = MaterialTheme.colorScheme.error) } } } /** The newer build, and the one button that fetches and installs it. */ @Composable private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) { - Text(offeredWords(offer, state.ready), style = MaterialTheme.typography.bodyMedium) - when (state.stage) { - InstallStage.IDLE -> Button(onClick = update, modifier = Modifier.fillMaxWidth()) { - Text(if (state.ready) "Install" else "Update") + SettingRow( + title = if (state.ready) "Ready to install" else "New version on your server", + summary = describe(offer), + ) { + when (state.stage) { + InstallStage.IDLE -> Button(onClick = update) { Text(if (state.ready) "Install" else "Update") } + InstallStage.DOWNLOADING -> RowSpinner() + InstallStage.INSTALLING -> RowValue("Installing…") } - InstallStage.DOWNLOADING -> - LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth()) - InstallStage.INSTALLING -> Text("Installing…", style = MaterialTheme.typography.bodyMedium) + } + if (state.stage == InstallStage.DOWNLOADING) { + LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth()) } } @@ -95,14 +82,6 @@ internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): St else -> "This is the newest version." } -/** The newer build, and whether it is already downloaded. */ -internal fun offeredWords(offer: UpdateInfo, ready: Boolean): String = - if (ready) { - "A new version of the app is downloaded and ready: ${describe(offer)}" - } else { - "A new version of the app is on your server: ${describe(offer)}" - } - /** The name, the channel when it is the test line, and the size. */ internal fun describe(offer: UpdateInfo): String { val channel = if (offer.channel == "dev") " (test build)" else ""