feat(android): Phase 10 Commit C — Hidden tab (quarantine list + unflag)
Replaces the Library Hidden-tab ComingSoonTab placeholder with the
real screen. Mirrors `flutter_client/lib/library/library_screen.dart`
`_HiddenTab` / `_QuarantineTile`.
New:
- models/wire/QuarantineWire.kt — @Serializable QuarantineMineWire
matching `/api/quarantine/mine`.
- models/Quarantine.kt — QuarantineRef domain; `reasonLabel` maps
the raw reason key ("bad_rip" / "wrong_file" / "wrong_tags" /
"duplicate" / other) to its display string.
- api/endpoints/QuarantineApi.kt — Retrofit listMine + flag + unflag
plus a FlagRequest @Serializable body for POST. The unflag
endpoint is idempotent server-side, which is what makes the
queued-replay path safe.
- quarantine/data/QuarantineRepository.kt — listMine + offline-first
unflag that returns UnflagOutcome (ACCEPTED vs. QUEUED).
`feedback_offline_first_for_server_writes` rationale identical
to LikesRepository.toggleLike.
- quarantine/ui/HiddenTabViewModel.kt — VM + UiState in its own
file (preempts TooManyFunctions). Optimistic-remove on unflag
with no rollback; the queue replays on failure.
- quarantine/ui/HiddenTab.kt — composable. List of rows showing
track title + "artist · album" subtitle + reason pill + Unhide
icon button (Lucide.ArchiveRestore).
Modified:
- cache/mutations/MutationQueue.kt — adds MutationKind.QUARANTINE_UNFLAG
+ QuarantineUnflagPayload + enqueueQuarantineUnflag helper.
- library/ui/LibraryScreen.kt — TAB_HIDDEN dispatch swaps from
ComingSoonTab to `HiddenTab()`. Drops the now-unreferenced
ComingSoonTab helper.
Admin slices (admin requests / quarantine / users) are still Commit D.
Flag-from-track-row UI lands once Album/Artist/Playlist detail screens
expose per-track action menus.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.QuarantineMineWire
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for `/api/quarantine`. Mirrors the relevant
|
||||||
|
* parts of `flutter_client/lib/api/endpoints/quarantine.dart` (flag
|
||||||
|
* and unflag) plus the `/api/quarantine/mine` endpoint from `me.dart`.
|
||||||
|
*
|
||||||
|
* Both flag and unflag are user-scoped — callers act on their own
|
||||||
|
* quarantine entries. The cross-user admin surface is a separate
|
||||||
|
* `/api/admin/quarantine` route (Phase 10 Commit D).
|
||||||
|
*/
|
||||||
|
interface QuarantineApi {
|
||||||
|
@GET("api/quarantine/mine")
|
||||||
|
suspend fun listMine(): List<QuarantineMineWire>
|
||||||
|
|
||||||
|
@POST("api/quarantine")
|
||||||
|
suspend fun flag(@Body body: FlagRequest)
|
||||||
|
|
||||||
|
@DELETE("api/quarantine/{trackId}")
|
||||||
|
suspend fun unflag(@Path("trackId") trackId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/quarantine body. `reason` is one of bad_rip / wrong_file
|
||||||
|
* / wrong_tags / duplicate / other; `notes` is the free-text comment.
|
||||||
|
*/
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
data class FlagRequest(
|
||||||
|
@kotlinx.serialization.SerialName("track_id") val trackId: String,
|
||||||
|
val reason: String,
|
||||||
|
val notes: String = "",
|
||||||
|
)
|
||||||
+19
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
|||||||
object MutationKind {
|
object MutationKind {
|
||||||
const val LIKE_TOGGLE: String = "like_toggle"
|
const val LIKE_TOGGLE: String = "like_toggle"
|
||||||
const val REQUEST_CREATE: String = "request_create"
|
const val REQUEST_CREATE: String = "request_create"
|
||||||
|
const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,4 +86,22 @@ class MutationQueue @Inject constructor(
|
|||||||
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
|
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert(
|
||||||
|
CachedMutationEntity(
|
||||||
|
kind = MutationKind.QUARANTINE_UNFLAG,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
QuarantineUnflagPayload.serializer(),
|
||||||
|
QuarantineUnflagPayload(trackId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||||
|
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||||
|
* hiccup. The replayer just re-fires the DELETE; idempotent server-side.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class QuarantineUnflagPayload(val trackId: String)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import com.fabledsword.minstrel.history.ui.HistoryTab
|
|||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
import com.fabledsword.minstrel.likes.ui.LikedTab
|
import com.fabledsword.minstrel.likes.ui.LikedTab
|
||||||
|
import com.fabledsword.minstrel.quarantine.ui.HiddenTab
|
||||||
import com.fabledsword.minstrel.models.AlbumRef
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
import com.fabledsword.minstrel.models.ArtistRef
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
@@ -97,11 +98,7 @@ fun LibraryScreen(
|
|||||||
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
TAB_ALBUMS -> AlbumsTab(viewModel = viewModel, navController = navController)
|
||||||
TAB_HISTORY -> HistoryTab()
|
TAB_HISTORY -> HistoryTab()
|
||||||
TAB_LIKED -> LikedTab(navController = navController)
|
TAB_LIKED -> LikedTab(navController = navController)
|
||||||
TAB_HIDDEN -> ComingSoonTab(
|
TAB_HIDDEN -> HiddenTab()
|
||||||
title = "Hidden",
|
|
||||||
body = "Tracks you've flagged as bad rips, wrong tags, or " +
|
|
||||||
"duplicates will appear here in Phase 10.",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,11 +189,6 @@ private fun AlbumsGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ComingSoonTab(title: String, body: String) {
|
|
||||||
EmptyState(title = title, body = body, modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingCentered() {
|
private fun LoadingCentered() {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One quarantined track owned by the caller. Mirrors the Flutter
|
||||||
|
* `QuarantineMineRow` (web `LidarrQuarantineMineRow`).
|
||||||
|
*
|
||||||
|
* `reasonLabel` provides the display string mapped from the raw
|
||||||
|
* server-side reason key — UI renders it as a pill on the row.
|
||||||
|
*/
|
||||||
|
data class QuarantineRef(
|
||||||
|
val trackId: String,
|
||||||
|
val reason: String,
|
||||||
|
val notes: String? = null,
|
||||||
|
val createdAt: String = "",
|
||||||
|
val trackTitle: String = "",
|
||||||
|
val trackDurationMs: Int = 0,
|
||||||
|
val albumId: String = "",
|
||||||
|
val albumTitle: String = "",
|
||||||
|
val albumCoverArtPath: String? = null,
|
||||||
|
val artistId: String = "",
|
||||||
|
val artistName: String = "",
|
||||||
|
) {
|
||||||
|
val reasonLabel: String
|
||||||
|
get() = when (reason) {
|
||||||
|
"bad_rip" -> "Bad rip"
|
||||||
|
"wrong_file" -> "Wrong file"
|
||||||
|
"wrong_tags" -> "Wrong tags"
|
||||||
|
"duplicate" -> "Duplicate"
|
||||||
|
else -> "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/quarantine/mine`. Mirrors
|
||||||
|
* `flutter_client/lib/models/quarantine_mine.dart QuarantineMineRow`
|
||||||
|
* (web `LidarrQuarantineMineRow`).
|
||||||
|
*
|
||||||
|
* Reason values: `bad_rip` / `wrong_file` / `wrong_tags` / `duplicate`
|
||||||
|
* / `other`. Stored as a plain string on the wire; the UI does the
|
||||||
|
* display-label mapping inline rather than via an enum to match
|
||||||
|
* Flutter's switch-on-string pattern.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class QuarantineMineWire(
|
||||||
|
@SerialName("track_id") val trackId: String = "",
|
||||||
|
val reason: String = "other",
|
||||||
|
val notes: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
@SerialName("track_title") val trackTitle: String = "",
|
||||||
|
@SerialName("track_duration_ms") val trackDurationMs: Int = 0,
|
||||||
|
@SerialName("album_id") val albumId: String = "",
|
||||||
|
@SerialName("album_title") val albumTitle: String = "",
|
||||||
|
@SerialName("album_cover_art_path") val albumCoverArtPath: String? = null,
|
||||||
|
@SerialName("artist_id") val artistId: String = "",
|
||||||
|
@SerialName("artist_name") val artistName: String = "",
|
||||||
|
)
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package com.fabledsword.minstrel.quarantine.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
|
import com.fabledsword.minstrel.models.wire.QuarantineMineWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of an `unflag` call — mirrors LikesRepository.toggleLike's
|
||||||
|
* RequestOutcome flavor. ACCEPTED = server confirmed; QUEUED = the
|
||||||
|
* call landed in the MutationQueue for later replay.
|
||||||
|
*/
|
||||||
|
enum class UnflagOutcome { ACCEPTED, QUEUED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through accessor for the caller's quarantine list + offline-
|
||||||
|
* first unflag write. No Room caching for v1 (same shape as Requests/
|
||||||
|
* History — offline-snapshot mirror lands with Phase 13). Flag is
|
||||||
|
* deferred to the AlbumDetail/ArtistDetail/PlaylistDetail per-track
|
||||||
|
* actions phase since there's no surface to flag from in the current
|
||||||
|
* screens.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class QuarantineRepository @Inject constructor(
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: QuarantineApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun listMine(): List<QuarantineRef> = api.listMine().map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun unflag(trackId: String): UnflagOutcome = try {
|
||||||
|
api.unflag(trackId)
|
||||||
|
UnflagOutcome.ACCEPTED
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Intentional swallow: queue the unhide for later replay; the
|
||||||
|
// UI has already removed the row optimistically. Same rationale
|
||||||
|
// as LikesRepository.toggleLike.
|
||||||
|
mutationQueue.enqueueQuarantineUnflag(trackId)
|
||||||
|
UnflagOutcome.QUEUED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mappers (internal — wire stays out of UI) ──
|
||||||
|
|
||||||
|
private fun QuarantineMineWire.toDomain(): QuarantineRef = QuarantineRef(
|
||||||
|
trackId = trackId,
|
||||||
|
reason = reason,
|
||||||
|
notes = notes,
|
||||||
|
createdAt = createdAt,
|
||||||
|
trackTitle = trackTitle,
|
||||||
|
trackDurationMs = trackDurationMs,
|
||||||
|
albumId = albumId,
|
||||||
|
albumTitle = albumTitle,
|
||||||
|
albumCoverArtPath = albumCoverArtPath,
|
||||||
|
artistId = artistId,
|
||||||
|
artistName = artistName,
|
||||||
|
)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.fabledsword.minstrel.quarantine.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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composables.icons.lucide.ArchiveRestore
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
when (val s = state) {
|
||||||
|
HiddenTabUiState.Loading -> LoadingCentered()
|
||||||
|
HiddenTabUiState.Empty -> EmptyState(
|
||||||
|
title = "No hidden tracks",
|
||||||
|
body = "Flag a track from its menu (bad rip, wrong tags, etc.) " +
|
||||||
|
"and it'll show up here. The track stays out of system " +
|
||||||
|
"playlists until you unhide it.",
|
||||||
|
)
|
||||||
|
is HiddenTabUiState.Error -> EmptyState(
|
||||||
|
title = "Couldn't load hidden tracks",
|
||||||
|
body = s.message,
|
||||||
|
)
|
||||||
|
is HiddenTabUiState.Success -> HiddenList(
|
||||||
|
rows = s.rows,
|
||||||
|
onUnflag = viewModel::unflag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HiddenList(rows: List<QuarantineRef>, onUnflag: (String) -> Unit) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(items = rows, key = { it.trackId }) { row ->
|
||||||
|
HiddenRow(row = row, onUnflag = { onUnflag(row.trackId) })
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HiddenRow(row: QuarantineRef, onUnflag: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.trackTitle,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${row.artistName} · ${row.albumTitle}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
label = { Text(row.reasonLabel) },
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onUnflag) {
|
||||||
|
Icon(
|
||||||
|
Lucide.ArchiveRestore,
|
||||||
|
contentDescription = "Unhide",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingCentered() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
package com.fabledsword.minstrel.quarantine.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
|
import com.fabledsword.minstrel.quarantine.data.QuarantineRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed interface HiddenTabUiState {
|
||||||
|
data object Loading : HiddenTabUiState
|
||||||
|
data object Empty : HiddenTabUiState
|
||||||
|
data class Success(val rows: List<QuarantineRef>) : HiddenTabUiState
|
||||||
|
data class Error(val message: String) : HiddenTabUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HiddenTabViewModel @Inject constructor(
|
||||||
|
private val repository: QuarantineRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow<HiddenTabUiState>(HiddenTabUiState.Loading)
|
||||||
|
val uiState: StateFlow<HiddenTabUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.value = HiddenTabUiState.Loading
|
||||||
|
try {
|
||||||
|
val rows = repository.listMine()
|
||||||
|
internal.value = if (rows.isEmpty()) {
|
||||||
|
HiddenTabUiState.Empty
|
||||||
|
} else {
|
||||||
|
HiddenTabUiState.Success(rows)
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = HiddenTabUiState.Error(e.message ?: "Hidden load failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic unhide: drop the row from local state immediately,
|
||||||
|
* call the server, refetch on success or rollback-via-refetch on
|
||||||
|
* failure. The repository handles the offline-queue fallback so
|
||||||
|
* the user's intent persists across connectivity loss.
|
||||||
|
*/
|
||||||
|
fun unflag(trackId: String) {
|
||||||
|
val before = internal.value
|
||||||
|
if (before is HiddenTabUiState.Success) {
|
||||||
|
val filtered = before.rows.filterNot { it.trackId == trackId }
|
||||||
|
internal.value = if (filtered.isEmpty()) {
|
||||||
|
HiddenTabUiState.Empty
|
||||||
|
} else {
|
||||||
|
HiddenTabUiState.Success(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repository.unflag(trackId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user