feat(android): ListenBrainz Settings card (audit v2 #20, slice 1)

* MeApi gains getListenBrainz + setListenBrainz (single PUT shape
  with optional token / enabled fields, server treats untouched).
* MeRepository facade methods setListenBrainzToken / setListenBrainzEnabled.
* ListenBrainzStatus domain + ListenBrainzStatusWire (server never
  echoes the token back; tokenSet is the visible signal).
* ListenBrainzViewModel — load on init, separate flows for save-token
  and toggle-enabled, inline status message.
* ListenBrainzCard — descriptive copy, masked token field with
  "Replace token" / "Token saved" placeholder, Save button, Switch
  for "Send my plays" (disabled until a token is stored), "Last
  scrobble: …" timestamp once present.

Slotted between PasswordCard and AppearanceCard.

About-panel update check is the second half of #20; lands in slice 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:32:29 -04:00
parent e6bf99f580
commit e3e9c48a0a
7 changed files with 335 additions and 0 deletions
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -33,6 +34,23 @@ interface MeApi {
*/
@PUT("api/me/password")
suspend fun changePassword(@Body body: ChangePasswordRequest)
/**
* Caller's ListenBrainz state — never returns the token itself,
* only whether one is stored, whether scrobbling is enabled, and
* the last successful scrobble timestamp (RFC3339 string or null).
*/
@GET("api/me/listenbrainz")
suspend fun getListenBrainz(): ListenBrainzStatusWire
/**
* PUT against the same `/api/me/listenbrainz` endpoint with one
* of two shapes — `{token: ...}` stashes a new token,
* `{enabled: ...}` flips the scrobble switch. Server returns the
* post-write status so callers refresh without a separate GET.
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
}
/**
@@ -54,3 +72,15 @@ data class ChangePasswordRequest(
@SerialName("current_password") val currentPassword: String,
@SerialName("new_password") val newPassword: String,
)
/**
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
* is set; the server treats other fields as untouched. Mirrors
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
* over a single endpoint shape.
*/
@Serializable
data class ListenBrainzPutBody(
val token: String? = null,
val enabled: Boolean? = null,
)
@@ -0,0 +1,16 @@
package com.fabledsword.minstrel.models
/**
* Caller's ListenBrainz integration state. Mirrors
* `flutter_client/lib/models/my_profile.dart ListenBrainzStatus`
* and the server's `listenBrainzResp`.
*
* The token itself is never read back from the server — `tokenSet`
* is the visible signal. Empty `lastScrobbledAt` means no scrobble
* has happened (either never enabled, or first play hasn't ended yet).
*/
data class ListenBrainzStatus(
val enabled: Boolean,
val tokenSet: Boolean,
val lastScrobbledAt: String? = null,
)
@@ -0,0 +1,16 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shape for `GET /api/me/listenbrainz` and the responses from
* the two PUT variants. `token` is write-only — the server never
* sends it back, so the field doesn't appear here.
*/
@Serializable
data class ListenBrainzStatusWire(
val enabled: Boolean = false,
@SerialName("token_set") val tokenSet: Boolean = false,
@SerialName("last_scrobbled_at") val lastScrobbledAt: String? = null,
)
@@ -1,9 +1,12 @@
package com.fabledsword.minstrel.settings.data
import com.fabledsword.minstrel.api.endpoints.ChangePasswordRequest
import com.fabledsword.minstrel.api.endpoints.ListenBrainzPutBody
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.api.endpoints.UpdateProfileRequest
import com.fabledsword.minstrel.models.ListenBrainzStatus
import com.fabledsword.minstrel.models.MyProfile
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import retrofit2.Retrofit
import javax.inject.Inject
@@ -32,6 +35,15 @@ class MeRepository @Inject constructor(retrofit: Retrofit) {
ChangePasswordRequest(currentPassword = current, newPassword = next),
)
}
suspend fun getListenBrainz(): ListenBrainzStatus =
api.getListenBrainz().toDomain()
suspend fun setListenBrainzToken(token: String): ListenBrainzStatus =
api.setListenBrainz(ListenBrainzPutBody(token = token)).toDomain()
suspend fun setListenBrainzEnabled(enabled: Boolean): ListenBrainzStatus =
api.setListenBrainz(ListenBrainzPutBody(enabled = enabled)).toDomain()
}
private fun MyProfileWire.toDomain(): MyProfile = MyProfile(
@@ -41,3 +53,9 @@ private fun MyProfileWire.toDomain(): MyProfile = MyProfile(
email = email,
isAdmin = isAdmin,
)
private fun ListenBrainzStatusWire.toDomain(): ListenBrainzStatus = ListenBrainzStatus(
enabled = enabled,
tokenSet = tokenSet,
lastScrobbledAt = lastScrobbledAt,
)
@@ -0,0 +1,141 @@
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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.models.ListenBrainzStatus
@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,
)
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,
)
}
}
}
}
@Composable
private fun ListenBrainzForm(
status: ListenBrainzStatus?,
tokenInput: String,
isSaving: Boolean,
message: String?,
onTokenChange: (String) -> Unit,
onSaveToken: () -> Unit,
onSetEnabled: (Boolean) -> Unit,
) {
OutlinedTextField(
value = tokenInput,
onValueChange = onTokenChange,
label = {
Text(if (status?.tokenSet == true) "Replace token" else "Token")
},
placeholder = {
Text(if (status?.tokenSet == true) "Token saved" else "")
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
Spacer(Modifier.height(4.dp))
Button(
onClick = onSaveToken,
enabled = !isSaving && tokenInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
Text(if (isSaving) "Saving…" else "Save token")
}
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,
)
}
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,113 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.models.ListenBrainzStatus
import com.fabledsword.minstrel.settings.data.MeRepository
import dagger.hilt.android.lifecycle.HiltViewModel
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 javax.inject.Inject
data class ListenBrainzUiState(
val isLoading: Boolean = true,
val isSaving: Boolean = false,
val status: ListenBrainzStatus? = null,
val tokenInput: String = "",
val message: String? = null,
)
/**
* Backs the ListenBrainz card in Settings. Loads the caller's state
* on init, then handles token-save and enabled-toggle. The token is
* never read back from the server — the UI shows "token saved" /
* "no token" based on [ListenBrainzStatus.tokenSet] without exposing
* the value. Submit clears [tokenInput] on success so the field
* acts as a write-only entry.
*/
@HiltViewModel
class ListenBrainzViewModel @Inject constructor(
private val repository: MeRepository,
) : ViewModel() {
private val internal = MutableStateFlow(ListenBrainzUiState())
val state: StateFlow<ListenBrainzUiState> = internal.asStateFlow()
init {
refresh()
}
fun setTokenInput(value: String) {
internal.update { it.copy(tokenInput = value) }
}
fun refresh() {
viewModelScope.launch {
internal.update { it.copy(isLoading = true, message = null) }
try {
val s = repository.getListenBrainz()
internal.update { it.copy(isLoading = false, status = s) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isLoading = false,
message = "Couldn't load: ${e.message ?: "unknown error"}",
)
}
}
}
}
fun saveToken() {
val token = internal.value.tokenInput.trim()
if (token.isEmpty() || internal.value.isSaving) return
viewModelScope.launch {
internal.update { it.copy(isSaving = true, message = null) }
try {
val s = repository.setListenBrainzToken(token)
internal.update {
it.copy(
isSaving = false,
status = s,
tokenInput = "",
message = "Token saved.",
)
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isSaving = false,
message = "Couldn't save: ${e.message ?: "unknown error"}",
)
}
}
}
}
fun setEnabled(enabled: Boolean) {
if (internal.value.isSaving) return
viewModelScope.launch {
internal.update { it.copy(isSaving = true, message = null) }
try {
val s = repository.setListenBrainzEnabled(enabled)
internal.update { it.copy(isSaving = false, status = s) }
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.update {
it.copy(
isSaving = false,
message = "Couldn't update: ${e.message ?: "unknown error"}",
)
}
}
}
}
}
@@ -107,6 +107,7 @@ fun SettingsScreen(
}
ProfileCard()
PasswordCard()
ListenBrainzCard()
AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode)
StorageCard()
AboutCard()