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