diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt index b5ebc046..d91c13cb 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/MeApi.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt new file mode 100644 index 00000000..0813d11e --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ListenBrainzStatus.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ListenBrainzWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ListenBrainzWire.kt new file mode 100644 index 00000000..52deaa73 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/ListenBrainzWire.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt index 65a4a054..634f8483 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/data/MeRepository.kt @@ -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, +) 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 new file mode 100644 index 00000000..a5700ac8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzCard.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzViewModel.kt new file mode 100644 index 00000000..52987842 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/ListenBrainzViewModel.kt @@ -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 = 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"}", + ) + } + } + } + } +} 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 f295919e..d1168d67 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 @@ -107,6 +107,7 @@ fun SettingsScreen( } ProfileCard() PasswordCard() + ListenBrainzCard() AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode) StorageCard() AboutCard()