feat(android): Settings — Storage section UI + Sync / Clear actions

Audit #5 user-visible parity. Storage card in SettingsScreen exposes
the four CacheSettings prefs (liked/rolling cap dropdowns, prefetch
window dropdown, cache-liked switch), live cache usage display, and
two action buttons:

- Sync now → SyncController.syncSafe()
- Clear cache → SimpleCache.removeResource for every cached key
  (safe mid-flight; releasing the cache would crash live playback).
  Confirmation dialog before delete.

Cap settings persist via AuthStore.setCacheSettings (from the prior
commit). The card surfaces the "limits take effect on next app
launch" caveat — SimpleCache is constructed once per process.

Prefetch window + cache-liked-tracks toggle persist but have no
effect yet — the prefetcher + pin-on-like flows are separate audit
follow-ups.

Per-bucket usage (Flutter shows Liked vs Rolling sizes separately)
is collapsed to a single "Used" stat on Android v1 since SimpleCache
doesn't expose per-bucket totals without custom indexing — separate
follow-up if user wants the breakdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 16:25:24 -04:00
parent b438772c96
commit 6ef08edd99
3 changed files with 431 additions and 0 deletions
@@ -83,6 +83,7 @@ fun SettingsScreen(
) {
AccountCard(username = state.username, serverUrl = state.serverUrl)
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
StorageCard()
AboutCard()
SignOutButton(isSigningOut = state.isSigningOut, onSignOut = viewModel::signOut)
}
@@ -0,0 +1,306 @@
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
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.material3.TextField
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
private const val GIB: Long = 1024L * 1024 * 1024
private const val MIB: Long = 1024L * 1024
private const val KIB: Long = 1024L
private val CAP_OPTIONS: List<Pair<Long, String>> = listOf(
1L * GIB to "1 GB",
5L * GIB to "5 GB",
10L * GIB to "10 GB",
25L * GIB to "25 GB",
0L to "Unlimited",
)
private val PREFETCH_OPTIONS: List<Int> = listOf(1, 3, 5, 7, 10)
@Composable
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,
)
UsageRow(usedBytes = state.usedBytes)
CapDropdown(
label = "Liked cache limit",
value = state.settings.likedCapBytes,
onSet = viewModel::setLikedCapBytes,
)
CapDropdown(
label = "Recently-played cache limit",
value = state.settings.rollingCapBytes,
onSet = viewModel::setRollingCapBytes,
)
PrefetchDropdown(
value = state.settings.prefetchWindow,
onSet = viewModel::setPrefetchWindow,
)
SwitchRow(
label = "Cache liked tracks",
value = state.settings.cacheLikedTracks,
onSet = viewModel::setCacheLikedTracks,
)
Text(
text = "Cache limits take effect on the next app launch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StorageActions(
isSyncing = state.isSyncing,
isClearing = state.isClearing,
onSync = viewModel::syncNow,
onClear = { showClearConfirm = true },
)
}
}
if (showClearConfirm) {
ClearCacheConfirmDialog(
onConfirm = {
showClearConfirm = false
viewModel.clearCache()
},
onDismiss = { showClearConfirm = false },
)
}
}
@Composable
private fun UsageRow(usedBytes: Long) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Used",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = formatBytes(usedBytes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
?: formatBytes(value)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
TextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor(),
)
ExposedDropdownMenuDefaults.run {
androidx.compose.material3.ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
CAP_OPTIONS.forEach { (bytes, name) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
expanded = false
onSet(bytes)
},
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PrefetchDropdown(value: Int, onSet: (Int) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val selected = if (value in PREFETCH_OPTIONS) value else PREFETCH_OPTIONS[2]
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Pre-fetch ahead",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
) {
TextField(
value = "$selected tracks",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor(),
)
androidx.compose.material3.ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
PREFETCH_OPTIONS.forEach { n ->
DropdownMenuItem(
text = { Text("$n tracks") },
onClick = {
expanded = false
onSet(n)
},
)
}
}
}
}
}
@Composable
private fun SwitchRow(label: String, value: Boolean, onSet: (Boolean) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Switch(checked = value, onCheckedChange = onSet)
}
}
@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(
onDismissRequest = onDismiss,
title = { Text("Clear cache?") },
text = {
Text(
"This deletes all cached audio. The next play of any track " +
"re-downloads from the server.",
)
},
confirmButton = {
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { Text("Clear") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
private fun formatBytes(n: Long): String {
if (n <= 0L) return "0 B"
return when {
n < KIB -> "$n B"
n < MIB -> "${"%.1f".format(n.toDouble() / KIB)} KB"
n < GIB -> "${"%.1f".format(n.toDouble() / MIB)} MB"
else -> "${"%.2f".format(n.toDouble() / GIB)} GB"
}
}
@@ -0,0 +1,124 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.player.PlayerFactory
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
data class StorageUiState(
val settings: CacheSettings = CacheSettings.DEFAULT,
val usedBytes: Long = 0L,
val isSyncing: Boolean = false,
val isClearing: Boolean = false,
)
/**
* Backs the Storage section of the Settings screen. Exposes the
* persisted CacheSettings + the current SimpleCache usage, plus
* actions for "Sync now" and "Clear cache".
*
* Settings changes go through AuthStore.setCacheSettings (write-
* through to Room). The cap settings only take effect on next app
* launch because SimpleCache is constructed once per process; the
* UI surfaces that caveat (next commit).
*/
@HiltViewModel
class StorageViewModel @Inject constructor(
private val authStore: AuthStore,
private val playerFactory: PlayerFactory,
private val syncController: SyncController,
) : ViewModel() {
private val internal = MutableStateFlow(StorageUiState())
val state: StateFlow<StorageUiState> = internal.asStateFlow()
init {
viewModelScope.launch {
authStore.cacheSettings.collect { s ->
internal.update { it.copy(settings = s) }
}
}
refreshUsage()
}
fun setLikedCapBytes(bytes: Long) {
val current = authStore.cacheSettings.value
authStore.setCacheSettings(current.copy(likedCapBytes = bytes))
}
fun setRollingCapBytes(bytes: Long) {
val current = authStore.cacheSettings.value
authStore.setCacheSettings(current.copy(rollingCapBytes = bytes))
}
fun setPrefetchWindow(n: Int) {
val clamped = n.coerceIn(1, MAX_PREFETCH_WINDOW)
val current = authStore.cacheSettings.value
authStore.setCacheSettings(current.copy(prefetchWindow = clamped))
}
fun setCacheLikedTracks(on: Boolean) {
val current = authStore.cacheSettings.value
authStore.setCacheSettings(current.copy(cacheLikedTracks = on))
}
fun syncNow() {
if (internal.value.isSyncing) return
viewModelScope.launch {
internal.update { it.copy(isSyncing = true) }
try {
syncController.syncSafe()
} finally {
internal.update { it.copy(isSyncing = false) }
}
}
}
fun clearCache() {
if (internal.value.isClearing) return
viewModelScope.launch {
internal.update { it.copy(isClearing = true) }
try {
withContext(Dispatchers.IO) {
// SimpleCache.getKeys() + removeResource is the safe
// path — releasing the cache mid-flight would crash
// any in-progress playback. Iterating + removing
// works while the cache is in use.
val cache = playerFactory.simpleCache
val keys = cache.keys.toList()
for (k in keys) {
runCatching { cache.removeResource(k) }
}
}
refreshUsage()
} finally {
internal.update { it.copy(isClearing = false) }
}
}
}
/** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */
private fun refreshUsage() {
viewModelScope.launch {
val used = withContext(Dispatchers.IO) {
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L)
}
internal.update { it.copy(usedBytes = used) }
}
}
companion object {
private const val MAX_PREFETCH_WINDOW = 10
}
}