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