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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user