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 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) {
@@ -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,
)
}
}
@@ -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"
@@ -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) }
}
}
@@ -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)
}
@@ -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")
}
}
}
@@ -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<Pair<Long, String>> = 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 <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
private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
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
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 ""