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
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:
+47
-92
@@ -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) {
|
||||
|
||||
+32
-85
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-101
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user