feat(android): settings read as a menu — every group on one Section plate, every setting one SettingRow (#6768)
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m48s
release / govulncheck (push) Successful in 18s
release / integration (push) Successful in 4m29s
release / android (push) Successful in 4m16s
release / Build signed APK (push) Successful in 4m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 22:31:25 -05:00
co-authored by Claude Opus 5.5
parent 598abeb8cf
commit 477f453100
9 changed files with 574 additions and 809 deletions
@@ -9,23 +9,17 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -35,7 +29,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign 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.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController 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.NotificationKindSettingWire
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
import com.fabledsword.minstrel.nav.NotificationSettings 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.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets 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 * Per-user notification settings: what this device does, then a row per
* channel. Rows read as a menu; anything that needs explaining * kind with a box for each channel.
* is one short line.
*/ */
@Composable @Composable
fun NotificationSettingsScreen( fun NotificationSettingsScreen(
@@ -109,38 +106,36 @@ private fun SettingsBody(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(horizontal = SectionEdge, vertical = SectionEdge),
verticalArrangement = Arrangement.spacedBy(SectionGap),
) { ) {
deviceRows() Section("This device") { deviceRows() }
HeaderRow() Section("Requests", trailing = { ChannelHeaders() }) {
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) } 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 } val adminKinds = settings.kinds.filter { it.adminOnly }
if (adminKinds.isNotEmpty()) { if (adminKinds.isNotEmpty()) {
SectionLabel("Library health") Section("Library health", trailing = { ChannelHeaders() }) {
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) } adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
} }
if (!settings.emailAvailable) {
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
} }
} }
} }
/** The channel names over the boxes, in the section's title line. */
@Composable @Composable
private fun HeaderRow() { private fun ChannelHeaders() {
Row( NotificationChannel.entries.forEach { c ->
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), Text(
verticalAlignment = Alignment.CenterVertically, text = channelLabel(c),
) { modifier = Modifier.width(CELL_WIDTH),
Box(Modifier.weight(1f)) style = MaterialTheme.typography.labelMedium,
NotificationChannel.entries.forEach { c -> color = MaterialTheme.colorScheme.onSurfaceVariant,
Text( textAlign = TextAlign.Center,
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, onSet: (String, NotificationChannel, Boolean) -> Unit,
) { ) {
val label = kindLabel(row.kind) val label = kindLabel(row.kind)
Row( SettingRow(title = label) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
NotificationChannel.entries.forEach { c -> NotificationChannel.entries.forEach { c ->
val enabled = channelEnabled(row, c, emailAvailable) val enabled = channelEnabled(row, c, emailAvailable)
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) { Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
@@ -204,60 +195,24 @@ private fun DeviceRows(backgroundDelivery: Boolean, onBackgroundDelivery: (Boole
@Composable @Composable
private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) { private fun BackgroundDeliveryRow(checked: Boolean, onChange: (Boolean) -> Unit) {
Row( // The ongoing notice is the price of not using Google's push; said
modifier = Modifier // plainly rather than left to be discovered.
.fillMaxWidth() SwitchRow(
.toggleable(value = checked, role = Role.Switch, onValueChange = onChange) title = "Notifications when the app is closed",
.padding(horizontal = 16.dp, vertical = 12.dp), summary = "Keeps a quiet notice in the shade, in place of Google's push",
verticalAlignment = Alignment.CenterVertically, checked = checked,
horizontalArrangement = Arrangement.spacedBy(8.dp), onCheckedChange = onChange,
) { )
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)
}
} }
@Composable @Composable
private fun PhoneBlockedRow(onClick: () -> Unit) { private fun PhoneBlockedRow(onClick: () -> Unit) {
Row( SettingRow(
modifier = Modifier title = "Phone alerts are off",
.fillMaxWidth() summary = "Allow notifications for Minstrel in system settings",
.clickable(onClick = onClick) tone = MaterialTheme.colorScheme.error,
.padding(horizontal = 16.dp, vertical = 12.dp), onClick = onClick,
verticalAlignment = Alignment.CenterVertically, ) { RowChevron() }
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,
)
} }
private fun openAppNotificationSettings(context: Context) { private fun openAppNotificationSettings(context: Context) {
@@ -1,20 +1,14 @@
package com.fabledsword.minstrel.settings.ui package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -25,44 +19,29 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.models.ListenBrainzStatus 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 @Composable
fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) { fun ListenBrainzCard(viewModel: ListenBrainzViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Section("ListenBrainz") {
Column( if (state.isLoading) {
modifier = Modifier.padding(16.dp), Box(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
) { contentAlignment = Alignment.Center,
Text( ) { CircularProgressIndicator() }
text = "ListenBrainz", } else {
style = MaterialTheme.typography.titleMedium, ListenBrainzForm(
color = MaterialTheme.colorScheme.onSurface, 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, onSaveToken: () -> Unit,
onSetEnabled: (Boolean) -> Unit, onSetEnabled: (Boolean) -> Unit,
) { ) {
TokenField( val tokenSet = status?.tokenSet == true
tokenInput = tokenInput, SwitchRow(
tokenSet = status?.tokenSet == true, title = "Send my plays to ListenBrainz",
onChange = onTokenChange, 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( SaveTokenButton(
isSaving = isSaving, isSaving = isSaving,
enabled = !isSaving && tokenInput.isNotBlank(), enabled = !isSaving && tokenInput.isNotBlank(),
onClick = onSaveToken, onClick = onSaveToken,
) )
EnabledRow(status = status, isSaving = isSaving, onSetEnabled = onSetEnabled) if (message != null) Muted(message)
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
@Composable @Composable
@@ -105,6 +84,7 @@ private fun TokenField(tokenInput: String, tokenSet: Boolean, onChange: (String)
onValueChange = onChange, onValueChange = onChange,
label = { Text(if (tokenSet) "Replace token" else "Token") }, label = { Text(if (tokenSet) "Replace token" else "Token") },
placeholder = { Text(if (tokenSet) "Token saved" else "") }, placeholder = { Text(if (tokenSet) "Token saved" else "") },
supportingText = { Text("From listenbrainz.org/profile; never shown again") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
@@ -129,36 +109,3 @@ private fun SaveTokenButton(isSaving: Boolean, enabled: Boolean, onClick: () ->
Text(if (isSaving) "Saving…" else "Save token") 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,
)
}
}
@@ -1,30 +1,15 @@
package com.fabledsword.minstrel.settings.ui 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.Composable
import androidx.compose.runtime.getValue 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.settings.data.NormalizationBoost import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs 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 * 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) NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun NormalizationCardContent( internal fun NormalizationCardContent(
prefs: NormalizationPrefs, prefs: NormalizationPrefs,
onChange: (NormalizationPrefs) -> Unit, onChange: (NormalizationPrefs) -> Unit,
) { ) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Section("Volume leveling") {
Column( ChoiceRow(
modifier = Modifier.padding(16.dp), title = "Leveling",
verticalArrangement = Arrangement.spacedBy(8.dp), summary = modeHint(prefs.mode),
) { options = NormalizationMode.entries.map { it to modeLabel(it) },
Text( selected = prefs.mode,
text = "Volume leveling", onSelect = { onChange(prefs.copy(mode = it)) },
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))
},
) )
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) { private fun modeLabel(mode: NormalizationMode): String = when (mode) {
NormalizationMode.OFF -> "Off" NormalizationMode.OFF -> "Off"
NormalizationMode.AUTO -> "Auto" NormalizationMode.AUTO -> "Auto"
@@ -1,7 +1,5 @@
package com.fabledsword.minstrel.settings.ui 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.foundation.text.KeyboardOptions
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -22,59 +19,45 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.Section
@Composable @Composable
fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) { fun PasswordCard(viewModel: PasswordViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Section("Password") {
Column( PasswordField(
modifier = Modifier.padding(16.dp), value = state.current,
verticalArrangement = Arrangement.spacedBy(8.dp), 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( if (state.isChanging) {
text = "Password", CircularProgressIndicator(
style = MaterialTheme.typography.titleMedium, modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onSurface, strokeWidth = 2.dp,
) color = MaterialTheme.colorScheme.onPrimary,
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,
) )
Spacer(Modifier.size(8.dp))
} }
Text(if (state.isChanging) "Changing…" else "Change password")
} }
state.message?.let { Muted(it) }
} }
} }
@@ -1,8 +1,6 @@
package com.fabledsword.minstrel.settings.ui package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -21,36 +18,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.theme.Muted
import com.fabledsword.minstrel.theme.Section
@Composable @Composable
fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) { fun ProfileCard(viewModel: ProfileViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Section("Profile") {
Column( if (state.isLoading) {
modifier = Modifier.padding(16.dp), Box(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
) { contentAlignment = Alignment.Center,
Text( ) { CircularProgressIndicator() }
text = "Profile", } else {
style = MaterialTheme.typography.titleMedium, ProfileForm(
color = MaterialTheme.colorScheme.onSurface, 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") Text(if (isSaving) "Saving…" else "Save profile")
} }
if (message != null) { if (message != null) Muted(message)
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
@@ -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 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults 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.MaterialTheme
import androidx.compose.material3.Scaffold 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -37,16 +22,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import com.composables.icons.lucide.Bell import com.composables.icons.lucide.Bell
import com.composables.icons.lucide.ChevronRight
import com.composables.icons.lucide.ListMusic import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.LogOut import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide 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.Admin
import com.fabledsword.minstrel.nav.NotificationSettings import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.nav.Requests import com.fabledsword.minstrel.nav.Requests
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
import com.fabledsword.minstrel.nav.ServerUrl 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.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.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import com.fabledsword.minstrel.update.ui.AppVersionCard import com.fabledsword.minstrel.update.ui.AppVersionCard
@@ -119,7 +107,7 @@ fun SettingsScreen(
@Composable @Composable
private fun SettingsList( private fun SettingsList(
inner: androidx.compose.foundation.layout.PaddingValues, inner: PaddingValues,
state: SettingsState, state: SettingsState,
themeMode: ThemeMode, themeMode: ThemeMode,
onPickTheme: (ThemeMode) -> Unit, onPickTheme: (ThemeMode) -> Unit,
@@ -134,48 +122,26 @@ private fun SettingsList(
.fillMaxSize() .fillMaxSize()
.padding(inner) .padding(inner)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(horizontal = SectionEdge, vertical = SectionEdge),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(SectionGap),
) { ) {
AccountCard( AccountSection(
username = state.username, state = state,
isAdmin = state.isAdmin, onNavToRequests = onNavToRequests,
serverUrl = state.serverUrl, 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() ProfileCard()
PasswordCard() PasswordCard()
ListenBrainzCard() ListenBrainzCard()
if (state.diagnosticsEnabledByAdmin) { if (state.diagnosticsEnabledByAdmin) {
DiagnosticsCard( DiagnosticsSection(optOut = state.diagnosticsOptOut, onToggle = onToggleDiagnostics)
optOut = state.diagnosticsOptOut,
onToggle = onToggleDiagnostics,
)
} }
NormalizationCard() NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme) AppearanceSection(themeMode = themeMode, onPick = onPickTheme)
StorageCard() StorageCard()
AboutCard() 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 @Composable
private fun NavTile( private fun AccountSection(
icon: ImageVector, state: SettingsState,
title: String, onNavToRequests: () -> Unit,
subtitle: String, onNavToNotifications: () -> Unit,
onClick: () -> Unit, onNavToAdmin: () -> Unit,
onSignOutClick: () -> Unit,
) { ) {
ElevatedCard( Section("Account") {
modifier = Modifier SettingRow(
.fillMaxWidth() title = state.username.ifEmpty { "Signed in" },
.clickable(onClick = onClick), summary = if (state.isAdmin) "Signed in · admin" else "Signed in",
) { )
Row( SettingRow(title = "Server", summary = state.serverUrl)
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), SettingRow(
verticalAlignment = Alignment.CenterVertically, title = "My requests",
) { summary = "What you've asked Minstrel to add",
Icon( icon = Lucide.ListMusic,
imageVector = icon, onClick = onNavToRequests,
contentDescription = null, ) { RowChevron() }
tint = MaterialTheme.colorScheme.onSurface, SettingRow(
) title = "Notifications",
Spacer(Modifier.size(16.dp)) summary = "What reaches you, and where",
Column(modifier = Modifier.weight(1f)) { icon = Lucide.Bell,
Text( onClick = onNavToNotifications,
text = title, ) { RowChevron() }
style = MaterialTheme.typography.titleMedium, if (state.isAdmin) {
color = MaterialTheme.colorScheme.onSurface, SettingRow(
) title = "Admin",
Text( summary = "Requests, quarantine and users",
text = subtitle, icon = Lucide.Shield,
style = MaterialTheme.typography.bodySmall, onClick = onNavToAdmin,
color = MaterialTheme.colorScheme.onSurfaceVariant, ) { RowChevron() }
)
}
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,
)
} }
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 // the user turn reporting OFF on this device (battery/privacy) without
// touching the account flag. Checked = reporting on = NOT opted out. // touching the account flag. Checked = reporting on = NOT opted out.
@Composable @Composable
private fun DiagnosticsCard(optOut: Boolean, onToggle: (Boolean) -> Unit) { private fun DiagnosticsSection(optOut: Boolean, onToggle: (Boolean) -> Unit) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Section("Diagnostics") {
Column( SwitchRow(
modifier = Modifier.padding(16.dp), title = "Send diagnostic reports",
verticalArrangement = Arrangement.spacedBy(8.dp), summary = "Your admin turned on debug mode",
) { checked = !optOut,
Text( onCheckedChange = { checked -> onToggle(!checked) },
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) },
)
}
}
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AppearanceCard(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) { private fun AppearanceSection(themeMode: ThemeMode, onPick: (ThemeMode) -> Unit) {
val options = ThemeMode.entries Section("Appearance") {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { ChoiceRow(
Column( title = "Theme",
modifier = Modifier.padding(16.dp), summary = if (themeMode == ThemeMode.SYSTEM) "Follows your device" else null,
verticalArrangement = Arrangement.spacedBy(8.dp), options = ThemeMode.entries.map { it to displayName(it) },
) { selected = themeMode,
Text( onSelect = onPick,
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)) }
}
}
}
} }
} }
@@ -376,29 +259,3 @@ private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
CheckWhileShown(model) CheckWhileShown(model)
AppVersionCard(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")
}
}
}
@@ -1,23 +1,9 @@
package com.fabledsword.minstrel.settings.ui 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults 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.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,12 +11,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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 GIB: Long = 1024L * 1024 * 1024
private const val MIB: Long = 1024L * 1024 private const val MIB: Long = 1024L * 1024
@@ -48,44 +35,39 @@ private val CAP_OPTIONS: List<Pair<Long, String>> = listOf(
fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) { fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
var showClearConfirm by remember { mutableStateOf(false) } var showClearConfirm by remember { mutableStateOf(false) }
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Section("Storage") {
Column( // Two budgets: a liked track that is also recently played counts
modifier = Modifier.padding(16.dp), // against Liked songs only. The prefetch window has no control;
verticalArrangement = Arrangement.spacedBy(8.dp), // AudioPrefetcher uses the stored value (default 5).
) { CapRow(
Text( title = "Liked songs",
text = "Storage", used = state.likedBytes,
style = MaterialTheme.typography.titleMedium, value = state.settings.likedCapBytes,
color = MaterialTheme.colorScheme.onSurface, onSet = viewModel::setLikedCapBytes,
) )
// Two budgets: a liked track that is also recently played counts CapRow(
// against Liked songs only. The prefetch window has no control; title = "Recently played",
// AudioPrefetcher uses the stored value (default 5). used = state.recentBytes,
CapDropdown( value = state.settings.rollingCapBytes,
label = "Liked songs", onSet = viewModel::setRollingCapBytes,
hint = "${formatBytes(state.likedBytes)} used", )
value = state.settings.likedCapBytes, SwitchRow(
onSet = viewModel::setLikedCapBytes, title = "Keep liked songs offline",
) summary = "Downloads on Wi-Fi",
CapDropdown( checked = state.settings.cacheLikedTracks,
label = "Recently played", onCheckedChange = viewModel::setCacheLikedTracks,
hint = "${formatBytes(state.recentBytes)} used", )
value = state.settings.rollingCapBytes, SettingRow(
onSet = viewModel::setRollingCapBytes, title = if (state.isSyncing) "Syncing…" else "Sync library now",
) enabled = !state.isSyncing,
SwitchRow( onClick = viewModel::syncNow,
label = "Keep liked songs offline", ) { if (state.isSyncing) RowSpinner() }
hint = "Downloads on Wi-Fi", SettingRow(
checked = state.settings.cacheLikedTracks, title = if (state.isClearing) "Clearing…" else "Clear cached audio",
onCheckedChange = viewModel::setCacheLikedTracks, tone = MaterialTheme.colorScheme.error,
) enabled = !state.isClearing,
StorageActions( onClick = { showClearConfirm = true },
isSyncing = state.isSyncing, ) { if (state.isClearing) RowSpinner() }
isClearing = state.isClearing,
onSync = viewModel::syncNow,
onClear = { showClearConfirm = true },
)
}
} }
if (showClearConfirm) { if (showClearConfirm) {
ClearCacheConfirmDialog( 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 @Composable
private fun CapDropdown(label: String, hint: String, value: Long, onSet: (Long) -> Unit) { private fun CapRow(title: String, used: Long, value: Long, onSet: (Long) -> Unit) {
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second ChoiceRow(
?: formatBytes(value) title = title,
LabeledDropdown( summary = "${formatBytes(used)} used",
label = label,
hint = hint,
selectedLabel = selectedLabel,
options = CAP_OPTIONS, options = CAP_OPTIONS,
selected = value,
onSelect = onSet, 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 <T> LabeledDropdown(
label: String,
hint: String,
selectedLabel: String,
options: List<Pair<T, String>>,
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 @Composable
private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
@@ -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 <T> ChoiceRow(
title: String,
options: List<Pair<T, String>>,
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
@@ -1,24 +1,23 @@
package com.fabledsword.minstrel.update.ui 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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.BuildConfig import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.models.UpdateInfo import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.settings.ui.formatBytes 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.InstallStage
import com.fabledsword.minstrel.update.data.UpdateUiState import com.fabledsword.minstrel.update.data.UpdateUiState
@@ -36,53 +35,41 @@ fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
onPauseOrDispose {} onPauseOrDispose {}
} }
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = modifier.fillMaxWidth()) { Section("About", modifier) {
Column( SettingRow(title = "Version") {
modifier = Modifier.padding(16.dp), RowValue("${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
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") }
} }
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. */ /** The newer build, and the one button that fetches and installs it. */
@Composable @Composable
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) { private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
Text(offeredWords(offer, state.ready), style = MaterialTheme.typography.bodyMedium) SettingRow(
when (state.stage) { title = if (state.ready) "Ready to install" else "New version on your server",
InstallStage.IDLE -> Button(onClick = update, modifier = Modifier.fillMaxWidth()) { summary = describe(offer),
Text(if (state.ready) "Install" else "Update") ) {
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()) if (state.stage == InstallStage.DOWNLOADING) {
InstallStage.INSTALLING -> Text("Installing…", style = MaterialTheme.typography.bodyMedium) 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." 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. */ /** The name, the channel when it is the test line, and the size. */
internal fun describe(offer: UpdateInfo): String { internal fun describe(offer: UpdateInfo): String {
val channel = if (offer.channel == "dev") " (test build)" else "" val channel = if (offer.channel == "dev") " (test build)" else ""