ci(release): add workflow_dispatch + docker diagnostics #2
@@ -0,0 +1,25 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.RequestWire
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for the user-side `/api/requests`. Mirrors
|
||||||
|
* `flutter_client/lib/api/endpoints/requests.dart`.
|
||||||
|
*
|
||||||
|
* Server scopes results to the caller — admins see only their own
|
||||||
|
* requests through this endpoint. The cross-user admin view lives on
|
||||||
|
* a separate `/api/admin/requests` route (Phase 10 Commit D).
|
||||||
|
*
|
||||||
|
* The DELETE endpoint returns the cancelled row (not 204) so the UI
|
||||||
|
* can patch local state without a refetch.
|
||||||
|
*/
|
||||||
|
interface RequestsApi {
|
||||||
|
@GET("api/requests")
|
||||||
|
suspend fun listMine(): List<RequestWire>
|
||||||
|
|
||||||
|
@DELETE("api/requests/{id}")
|
||||||
|
suspend fun cancel(@Path("id") id: String): RequestWire
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a Lidarr request. Stored on the wire as a plain string;
|
||||||
|
* the typed enum keeps switch / when blocks exhaustive and lets the
|
||||||
|
* UI map directly to a status pill color without raw-string matches.
|
||||||
|
*/
|
||||||
|
enum class RequestStatus {
|
||||||
|
PENDING, APPROVED, REJECTED, COMPLETED, FAILED, UNKNOWN;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(s: String): RequestStatus = when (s) {
|
||||||
|
"pending" -> PENDING
|
||||||
|
"approved" -> APPROVED
|
||||||
|
"rejected" -> REJECTED
|
||||||
|
"completed" -> COMPLETED
|
||||||
|
"failed" -> FAILED
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val wire: String get() = name.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Lidarr request the user has submitted. Mirrors
|
||||||
|
* `flutter_client/lib/models/admin_request.dart AdminRequest` —
|
||||||
|
* shared between the user-side `/api/requests` view and the admin
|
||||||
|
* cross-user view since the wire shape is identical.
|
||||||
|
*
|
||||||
|
* `displayName` picks the right field based on kind:
|
||||||
|
* - album → albumTitle (fallback artistName)
|
||||||
|
* - track → trackTitle (fallback artistName)
|
||||||
|
* - artist → artistName
|
||||||
|
*
|
||||||
|
* `listenHrefId` returns the most-specific matched library ID once the
|
||||||
|
* request completes (track → album → artist) so the "Listen" CTA can
|
||||||
|
* navigate to the right detail screen. Returns null until the
|
||||||
|
* server's ingest job populates the matched_* fields.
|
||||||
|
*/
|
||||||
|
data class RequestRef(
|
||||||
|
val id: String,
|
||||||
|
val userId: String,
|
||||||
|
val status: RequestStatus,
|
||||||
|
val kind: String,
|
||||||
|
val artistName: String,
|
||||||
|
val albumTitle: String? = null,
|
||||||
|
val trackTitle: String? = null,
|
||||||
|
val requestedAt: String,
|
||||||
|
val decidedAt: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
val importedAlbumCount: Int = 0,
|
||||||
|
val importedTrackCount: Int = 0,
|
||||||
|
val matchedTrackId: String? = null,
|
||||||
|
val matchedAlbumId: String? = null,
|
||||||
|
val matchedArtistId: String? = null,
|
||||||
|
) {
|
||||||
|
val displayName: String
|
||||||
|
get() = when (kind) {
|
||||||
|
"album" -> albumTitle ?: artistName
|
||||||
|
"track" -> trackTitle ?: artistName
|
||||||
|
else -> artistName
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shape of `requestView` from `internal/api/requests.go` — the
|
||||||
|
* row returned by both `GET /api/requests` (caller's own requests) and
|
||||||
|
* `GET /api/admin/requests` (cross-user admin view). Mirrors
|
||||||
|
* `flutter_client/lib/models/admin_request.dart AdminRequest`.
|
||||||
|
*
|
||||||
|
* Status values: `pending` / `approved` / `rejected` / `completed` /
|
||||||
|
* `failed`. Kind values: `artist` / `album` / `track`.
|
||||||
|
*
|
||||||
|
* `matched_*_id` are set after a completed request ingests into the
|
||||||
|
* local library; the user-side "Listen" affordance picks the most
|
||||||
|
* specific one (track → album → artist).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RequestWire(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("user_id") val userId: String = "",
|
||||||
|
val status: String = "pending",
|
||||||
|
val kind: String = "artist",
|
||||||
|
@SerialName("artist_name") val artistName: String = "",
|
||||||
|
@SerialName("album_title") val albumTitle: String? = null,
|
||||||
|
@SerialName("track_title") val trackTitle: String? = null,
|
||||||
|
@SerialName("requested_at") val requestedAt: String = "",
|
||||||
|
@SerialName("decided_at") val decidedAt: String? = null,
|
||||||
|
val notes: String? = null,
|
||||||
|
@SerialName("imported_album_count") val importedAlbumCount: Int = 0,
|
||||||
|
@SerialName("imported_track_count") val importedTrackCount: Int = 0,
|
||||||
|
@SerialName("matched_track_id") val matchedTrackId: String? = null,
|
||||||
|
@SerialName("matched_album_id") val matchedAlbumId: String? = null,
|
||||||
|
@SerialName("matched_artist_id") val matchedArtistId: String? = null,
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.library.ui.LibraryScreen
|
|||||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistsListScreen
|
||||||
|
import com.fabledsword.minstrel.requests.ui.RequestsScreen
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
import com.fabledsword.minstrel.shared.widgets.ShellScaffold
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ private fun NavGraphBuilder.inShellTopLevel(
|
|||||||
}
|
}
|
||||||
composable<Requests> {
|
composable<Requests> {
|
||||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
ComingSoon("Requests", "Your Lidarr requests — Phase 10.")
|
RequestsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.fabledsword.minstrel.requests.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||||
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
|
import com.fabledsword.minstrel.models.wire.RequestWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through accessor for the caller's own Lidarr requests +
|
||||||
|
* cancel-while-pending write. No Room caching for v1 — same rationale
|
||||||
|
* as HistoryRepository; the offline-snapshot mirror lands with
|
||||||
|
* Phase 13. Cancel + the create path (DiscoverRepository) both fire
|
||||||
|
* direct REST today; the create path enqueues on failure via the
|
||||||
|
* MutationQueue, cancel does not (it's a no-op if the request is
|
||||||
|
* already gone).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class RequestsRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: RequestsApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun listMine(): List<RequestRef> =
|
||||||
|
api.listMine().map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels a pending request. Server returns the cancelled row
|
||||||
|
* (now status=rejected with cancel notes) so callers can refresh
|
||||||
|
* local state from the return value without a list refetch.
|
||||||
|
*/
|
||||||
|
suspend fun cancel(id: String): RequestRef = api.cancel(id).toDomain()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mappers (internal — wire stays out of UI) ──
|
||||||
|
|
||||||
|
private fun RequestWire.toDomain(): RequestRef = RequestRef(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
status = RequestStatus.fromWire(status),
|
||||||
|
kind = kind,
|
||||||
|
artistName = artistName,
|
||||||
|
albumTitle = albumTitle,
|
||||||
|
trackTitle = trackTitle,
|
||||||
|
requestedAt = requestedAt,
|
||||||
|
decidedAt = decidedAt,
|
||||||
|
notes = notes,
|
||||||
|
importedAlbumCount = importedAlbumCount,
|
||||||
|
importedTrackCount = importedTrackCount,
|
||||||
|
matchedTrackId = matchedTrackId,
|
||||||
|
matchedAlbumId = matchedAlbumId,
|
||||||
|
matchedArtistId = matchedArtistId,
|
||||||
|
)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package com.fabledsword.minstrel.requests.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.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
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 androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RequestsScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: RequestsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Your requests") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
MainAppBarActions(
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = Requests::class.qualifiedName,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(inner)) {
|
||||||
|
when (val s = state) {
|
||||||
|
RequestsUiState.Loading -> LoadingCentered()
|
||||||
|
RequestsUiState.Empty -> EmptyState(
|
||||||
|
title = "Nothing requested yet",
|
||||||
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
|
"requests show up here.",
|
||||||
|
)
|
||||||
|
is RequestsUiState.Error -> EmptyState(
|
||||||
|
title = "Couldn't load requests",
|
||||||
|
body = s.message,
|
||||||
|
)
|
||||||
|
is RequestsUiState.Success -> RequestList(
|
||||||
|
rows = s.requests,
|
||||||
|
onCancel = viewModel::cancel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequestList(rows: List<RequestRef>, onCancel: (String) -> Unit) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(items = rows, key = { it.id }) { req ->
|
||||||
|
RequestRow(req = req, onCancel = { onCancel(req.id) })
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RequestRow(req: RequestRef, onCancel: () -> 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 = req.displayName,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
StatusPill(status = req.status)
|
||||||
|
KindPill(label = req.kind)
|
||||||
|
}
|
||||||
|
if (req.status == RequestStatus.REJECTED && !req.notes.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = req.notes,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.status == RequestStatus.PENDING) {
|
||||||
|
TextButton(onClick = onCancel) {
|
||||||
|
Icon(Lucide.X, contentDescription = null, modifier = Modifier.padding(end = 4.dp))
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusPill(status: RequestStatus) {
|
||||||
|
val (label, isError) = when (status) {
|
||||||
|
RequestStatus.PENDING -> "pending" to false
|
||||||
|
RequestStatus.APPROVED -> "approved" to false
|
||||||
|
RequestStatus.COMPLETED -> "completed" to false
|
||||||
|
RequestStatus.REJECTED -> "rejected" to true
|
||||||
|
RequestStatus.FAILED -> "failed" to true
|
||||||
|
RequestStatus.UNKNOWN -> "unknown" to false
|
||||||
|
}
|
||||||
|
val color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
label = { Text(label, color = color) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KindPill(label: String) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadingCentered() {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.fabledsword.minstrel.requests.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
|
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||||
|
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
|
||||||
|
|
||||||
|
sealed interface RequestsUiState {
|
||||||
|
data object Loading : RequestsUiState
|
||||||
|
data object Empty : RequestsUiState
|
||||||
|
data class Success(val requests: List<RequestRef>) : RequestsUiState
|
||||||
|
data class Error(val message: String) : RequestsUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RequestsViewModel @Inject constructor(
|
||||||
|
private val repository: RequestsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow<RequestsUiState>(RequestsUiState.Loading)
|
||||||
|
val uiState: StateFlow<RequestsUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
internal.value = RequestsUiState.Loading
|
||||||
|
try {
|
||||||
|
val rows = repository.listMine()
|
||||||
|
internal.value = if (rows.isEmpty()) {
|
||||||
|
RequestsUiState.Empty
|
||||||
|
} else {
|
||||||
|
RequestsUiState.Success(rows)
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = RequestsUiState.Error(e.message ?: "Requests load failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistically removes [id] from the success list so the row
|
||||||
|
* disappears immediately; on server-side success the row is
|
||||||
|
* replaced by the canonical cancelled state from the return value.
|
||||||
|
* On error rolls back by refetching the list.
|
||||||
|
*/
|
||||||
|
fun cancel(id: String) {
|
||||||
|
val before = internal.value
|
||||||
|
if (before is RequestsUiState.Success) {
|
||||||
|
val filtered = before.requests.filterNot { it.id == id }
|
||||||
|
internal.value = if (filtered.isEmpty()) {
|
||||||
|
RequestsUiState.Empty
|
||||||
|
} else {
|
||||||
|
RequestsUiState.Success(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val updated = repository.cancel(id)
|
||||||
|
internal.update { state ->
|
||||||
|
if (state !is RequestsUiState.Success) {
|
||||||
|
// Mid-refresh — let the refresh result win, but
|
||||||
|
// splice the cancelled row in so it shows the new
|
||||||
|
// status if the user re-pulls.
|
||||||
|
RequestsUiState.Success(listOf(updated))
|
||||||
|
} else {
|
||||||
|
// Re-insert the cancelled row in its old position
|
||||||
|
// would require remembering the prior ordering;
|
||||||
|
// simpler to refetch in the background.
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Roll back by re-fetching; the failed cancel might
|
||||||
|
// already be visible to the server (eventual consistency)
|
||||||
|
// or not at all (transport drop). The refetch makes
|
||||||
|
// local state match either outcome.
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user