* feat(#433): achievements 1:1 parity with dashboard - Expanded model: AchievementsResponse now carries header stats (unlockedCount, discoveredCount, secretCount, totalCount, isStale, scanMeta/ScanStatus) and per-achievement fields (unlockedAt, secret, criteria) - Added API endpoints: scan-status, rescan, reset-state, recent-unlocks - Created AchievementIcon emoji mapper for all Lucide icon names - Rewrote ViewModel with full state: scan tracking, recent unlocks, category/state filtering, rescan/reset actions with loading states - Rewrote Screen: header stats row, scan status chip, stale indicator, category + state filter chips, recent unlocks section, expandable criteria, icon emoji, unlocked glow border, overflow menu with rescan/reset, reset confirmation dialog - Added 30 string resources for all new UI elements * fix: resolve CI compilation errors in achievements module - Replace progress_pct references with correct Kotlin field progressPct (Achievement model uses @SerializedName for snake_case, Kotlin field is camelCase) - Add missing onStart/onError parameters to safeLaunchLoad calls in loadScanStatus() and loadRecentUnlocks() * fix: remove top padding on achievements tab content * fix: remove redundant paddingValues on achievement LazyColumn --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
2bceac7ab3
commit
f887146056
6 files changed
+941
-108
No files matched your search
@@ -1,7 +1,50 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AchievementsResponse(
|
||||
val achievements: List<Achievement>,
|
||||
val achievements: List<Achievement>? = emptyList(),
|
||||
@SerializedName("unlocked_count") val unlockedCount: Int = 0,
|
||||
@SerializedName("discovered_count") val discoveredCount: Int = 0,
|
||||
@SerializedName("secret_count") val secretCount: Int = 0,
|
||||
@SerializedName("total_count") val totalCount: Int = 0,
|
||||
@SerializedName("is_stale") val isStale: Boolean = false,
|
||||
@SerializedName("generated_at") val generatedAt: Long? = null,
|
||||
@SerializedName("scan_meta") val scanMeta: ScanMeta? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
data class ScanMeta(
|
||||
val status: ScanStatus? = null,
|
||||
)
|
||||
|
||||
data class ScanStatus(
|
||||
val state: String = "idle",
|
||||
@SerializedName("started_at") val startedAt: Long? = null,
|
||||
@SerializedName("finished_at") val finishedAt: Long? = null,
|
||||
@SerializedName("last_error") val lastError: String? = null,
|
||||
@SerializedName("last_duration_ms") val lastDurationMs: Long? = null,
|
||||
@SerializedName("run_count") val runCount: Int = 0,
|
||||
)
|
||||
|
||||
data class RecentUnlock(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val category: String?,
|
||||
val kind: String?,
|
||||
val icon: String?,
|
||||
val unlocked: Boolean,
|
||||
val discovered: Boolean,
|
||||
val state: String?,
|
||||
val tier: String?,
|
||||
val progress: Double?,
|
||||
@SerializedName("next_tier") val nextTier: String?,
|
||||
@SerializedName("next_threshold") val nextThreshold: Double?,
|
||||
@SerializedName("progress_pct") val progressPct: Double?,
|
||||
@SerializedName("unlocked_at") val unlockedAt: Long?,
|
||||
val secret: Boolean?,
|
||||
val criteria: String?,
|
||||
)
|
||||
|
||||
data class Achievement(
|
||||
@@ -16,7 +59,10 @@ data class Achievement(
|
||||
val state: String?,
|
||||
val tier: String?,
|
||||
val progress: Double?,
|
||||
val next_tier: String?,
|
||||
val next_threshold: Double?,
|
||||
val progress_pct: Double?,
|
||||
@SerializedName("next_tier") val nextTier: String?,
|
||||
@SerializedName("next_threshold") val nextThreshold: Double?,
|
||||
@SerializedName("progress_pct") val progressPct: Double?,
|
||||
@SerializedName("unlocked_at") val unlockedAt: Long?,
|
||||
val secret: Boolean?,
|
||||
val criteria: String?,
|
||||
)
|
||||
@@ -28,7 +28,9 @@ import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
import com.m57.hermescontrol.data.model.RawConfigResponse
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
|
||||
import com.m57.hermescontrol.data.model.ScanStatus
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
|
||||
@@ -180,6 +182,18 @@ interface HermesApiService {
|
||||
@GET("api/plugins/hermes-achievements/achievements")
|
||||
suspend fun getAchievements(): Response<AchievementsResponse>
|
||||
|
||||
@GET("api/plugins/hermes-achievements/scan-status")
|
||||
suspend fun getAchievementScanStatus(): Response<ScanStatus>
|
||||
|
||||
@POST("api/plugins/hermes-achievements/rescan")
|
||||
suspend fun rescanAchievements(): Response<AchievementsResponse>
|
||||
|
||||
@POST("api/plugins/hermes-achievements/reset-state")
|
||||
suspend fun resetAchievementState(): Response<Unit>
|
||||
|
||||
@GET("api/plugins/hermes-achievements/recent-unlocks")
|
||||
suspend fun getRecentUnlocks(): Response<List<RecentUnlock>>
|
||||
|
||||
@GET("api/pairing")
|
||||
suspend fun getPairing(): Response<PairingResponse>
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
object AchievementIcon {
|
||||
/** Map Lucide icon names to emoji for the mobile app. */
|
||||
fun emoji(icon: String?): String =
|
||||
when (icon) {
|
||||
"flame" -> "\uD83D\uDD25"
|
||||
"avalanche" -> "\u26F0\uFE0F"
|
||||
"nodes" -> "\uD83D\uDD17"
|
||||
"rocket" -> "\uD83D\uDE80"
|
||||
"branch" -> "\uD83C\uDF3F"
|
||||
"daemon" -> "\uD83D\uDC7B"
|
||||
"clock" -> "\uD83D\uDD50"
|
||||
"warning" -> "\u26A0\uFE0F"
|
||||
"wine" -> "\uD83C\uDF77"
|
||||
"scroll" -> "\uD83D\uDCDC"
|
||||
"plug" -> "\uD83D\uDD0C"
|
||||
"lock" -> "\uD83D\uDD12"
|
||||
"package_skull" -> "\uD83D\uDC80"
|
||||
"restart" -> "\uD83D\uDD04"
|
||||
"key" -> "\uD83D\uDD11"
|
||||
"colon" -> "\uFE0F\u003A"
|
||||
"container" -> "\uD83D\uDCE6"
|
||||
"melting_clock" -> "\u23F3"
|
||||
"pencil" -> "\u270F\uFE0F"
|
||||
"blueprint" -> "\uD83D\uDCD0"
|
||||
"pixel" -> "\uD83C\uDFA8"
|
||||
"ship" -> "\uD83D\uDEA2"
|
||||
"spark_cursor" -> "\u2728"
|
||||
"needle" -> "\uD83D\uDCCD"
|
||||
"hammer_scroll" -> "\uD83D\uDD28"
|
||||
"anvil" -> "\u2692\uFE0F"
|
||||
"crystal" -> "\uD83D\uDC8E"
|
||||
"palace" -> "\uD83C\uDFDB\uFE0F"
|
||||
"dragon" -> "\uD83D\uDC09"
|
||||
"antenna" -> "\uD83D\uDCE1"
|
||||
"puzzle" -> "\uD83E\uDDE9"
|
||||
"rewind" -> "\u23EA"
|
||||
"spiral" -> "\uD83C\uDF00"
|
||||
"quote" -> "\uD83D\uDCAC"
|
||||
"compass" -> "\uD83E\uDDED"
|
||||
"browser" -> "\uD83C\uDF10"
|
||||
"terminal" -> "\uD83D\uDCBB"
|
||||
"wand" -> "\uD83E\uDE84"
|
||||
"folder" -> "\uD83D\uDCC1"
|
||||
"eye" -> "\uD83D\uDC41\uFE0F"
|
||||
"wave" -> "\uD83C\uDF0A"
|
||||
"swap" -> "\uD83D\uDD04"
|
||||
"router" -> "\uD83D\uDCF6"
|
||||
"secret" -> "\u2753"
|
||||
else -> "\uD83C\uDFC6" // default trophy
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -9,15 +14,27 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,18 +43,27 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
import com.m57.hermescontrol.ui.common.SectionHeader
|
||||
import com.m57.hermescontrol.ui.common.StatusBadge
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
|
||||
@Composable
|
||||
fun AchievementsScreen(
|
||||
@@ -47,18 +73,56 @@ fun AchievementsScreen(
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val filteredAchievements =
|
||||
remember(query, state.achievements) {
|
||||
state.achievements.filter { achievement ->
|
||||
achievement.name.contains(query, ignoreCase = true) ||
|
||||
achievement.description?.contains(query, ignoreCase = true) == true ||
|
||||
achievement.category?.contains(query, ignoreCase = true) == true
|
||||
remember(query, state.activeCategory, state.activeState, state.achievements) {
|
||||
state.achievements.filter { a ->
|
||||
val matchesSearch =
|
||||
query.isEmpty() ||
|
||||
a.name.contains(query, ignoreCase = true) ||
|
||||
a.description?.contains(query, ignoreCase = true) == true ||
|
||||
a.category?.contains(query, ignoreCase = true) == true
|
||||
val matchesCategory =
|
||||
state.activeCategory == null || a.category == state.activeCategory
|
||||
val matchesState =
|
||||
state.activeState == null || a.state == state.activeState
|
||||
matchesSearch && matchesCategory && matchesState
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadAchievements()
|
||||
viewModel.loadRecentUnlocks()
|
||||
}
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
if (showResetDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showResetDialog = false },
|
||||
title = { Text(stringResource(R.string.achievements_reset_title)) },
|
||||
text = { Text(stringResource(R.string.achievements_reset_desc)) },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
viewModel.resetState()
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.action_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showResetDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HermesScaffold(
|
||||
@@ -66,6 +130,42 @@ fun AchievementsScreen(
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadAchievements() },
|
||||
modifier = modifier,
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.achievements_menu_desc),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.achievements_action_rescan)) },
|
||||
onClick = {
|
||||
showMenu = false
|
||||
viewModel.rescan()
|
||||
},
|
||||
enabled = !state.isRescanning,
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(R.string.achievements_action_reset),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
showResetDialog = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.achievements.isEmpty() -> {
|
||||
@@ -91,117 +191,560 @@ fun AchievementsScreen(
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
// Search
|
||||
item(key = "search") {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Search achievements...",
|
||||
placeholder = stringResource(R.string.achievements_search_hint),
|
||||
)
|
||||
}
|
||||
items(filteredAchievements, key = { it.id }) { achievement ->
|
||||
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (achievement.unlocked) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = achievement.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
achievement.category?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Header stats
|
||||
item(key = "stats") {
|
||||
StatsRow(
|
||||
unlockedCount = state.unlockedCount,
|
||||
discoveredCount = state.discoveredCount,
|
||||
secretCount = state.secretCount,
|
||||
totalCount = state.totalCount,
|
||||
)
|
||||
}
|
||||
|
||||
// Display Icon/Emoji/Tier
|
||||
val badgeText =
|
||||
when {
|
||||
achievement.unlocked -> achievement.tier ?: "🏆"
|
||||
achievement.discovered -> "🔒 Discovered"
|
||||
else -> "❓ Secret"
|
||||
}
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color =
|
||||
if (achievement.unlocked) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
// Scan status
|
||||
item(key = "scan_status") {
|
||||
ScanStatusChip(
|
||||
scanState = state.scanState,
|
||||
isStale = state.isStale,
|
||||
scanLastError = state.scanLastError,
|
||||
scanRunCount = state.scanRunCount,
|
||||
isRescanning = state.isRescanning,
|
||||
)
|
||||
}
|
||||
|
||||
// Category filters
|
||||
if (state.categories.isNotEmpty()) {
|
||||
item(key = "category_filters") {
|
||||
CategoryFilterRow(
|
||||
categories = state.categories,
|
||||
activeCategory = state.activeCategory,
|
||||
onCategorySelected = { selected ->
|
||||
viewModel.setActiveCategory(
|
||||
if (selected == state.activeCategory) null else selected,
|
||||
)
|
||||
}
|
||||
|
||||
achievement.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
if (achievement.discovered && !achievement.unlocked) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.achievements_label_progress,
|
||||
achievement.progress?.toInt() ?: 0,
|
||||
achievement.next_threshold?.toInt() ?: 0,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "${achievement.progress_pct?.toInt() ?: 0}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { pct.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// State filters
|
||||
item(key = "state_filters") {
|
||||
StateFilterRow(
|
||||
activeState = state.activeState,
|
||||
onStateSelected = { selected ->
|
||||
viewModel.setActiveState(
|
||||
if (selected == state.activeState) null else selected,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Recent unlocks
|
||||
if (state.recentUnlocks.isNotEmpty()) {
|
||||
item(key = "recent_header") {
|
||||
SectionHeader(
|
||||
title = stringResource(R.string.achievements_recent_title),
|
||||
)
|
||||
}
|
||||
items(
|
||||
items = state.recentUnlocks.take(3),
|
||||
key = { "recent_${it.id}" },
|
||||
) { unlock ->
|
||||
RecentUnlockCard(unlock = unlock)
|
||||
}
|
||||
}
|
||||
|
||||
// Achievement count label
|
||||
item(key = "count_label") {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.achievements_count_label,
|
||||
filteredAchievements.size,
|
||||
state.totalCount,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Achievement cards
|
||||
items(filteredAchievements, key = { it.id }) { achievement ->
|
||||
AchievementCard(
|
||||
achievement = achievement,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats Row ──────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun StatsRow(
|
||||
unlockedCount: Int,
|
||||
discoveredCount: Int,
|
||||
secretCount: Int,
|
||||
totalCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatPill(
|
||||
label = stringResource(R.string.achievements_stat_unlocked),
|
||||
value = "$unlockedCount",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatPill(
|
||||
label = stringResource(R.string.achievements_stat_discovered),
|
||||
value = "$discoveredCount",
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatPill(
|
||||
label = stringResource(R.string.achievements_stat_secret),
|
||||
value = "$secretCount",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatPill(
|
||||
label = stringResource(R.string.achievements_stat_total),
|
||||
value = "$totalCount",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatPill(
|
||||
label: String,
|
||||
value: String,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = color.copy(alpha = 0.12f),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = color,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scan Status Chip ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ScanStatusChip(
|
||||
scanState: String,
|
||||
isStale: Boolean,
|
||||
scanLastError: String?,
|
||||
scanRunCount: Int,
|
||||
isRescanning: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val (label, type) =
|
||||
when {
|
||||
isRescanning -> stringResource(R.string.achievements_scan_scanning) to StatusBadgeType.INFO
|
||||
scanState == "running" -> stringResource(R.string.achievements_scan_running) to StatusBadgeType.WARNING
|
||||
scanLastError != null -> stringResource(R.string.achievements_scan_error) to StatusBadgeType.ERROR
|
||||
else -> stringResource(R.string.achievements_scan_idle) to StatusBadgeType.SUCCESS
|
||||
}
|
||||
StatusBadge(text = label, status = type)
|
||||
|
||||
if (isStale) {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.achievements_stale),
|
||||
status = StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
if (scanRunCount > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.achievements_scan_count, scanRunCount),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category Filter Row ────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun CategoryFilterRow(
|
||||
categories: List<String>,
|
||||
activeCategory: String?,
|
||||
onCategorySelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(key = "all_cat") {
|
||||
FilterChip(
|
||||
selected = activeCategory == null,
|
||||
onClick = { onCategorySelected("") },
|
||||
label = { Text(stringResource(R.string.achievements_filter_all)) },
|
||||
)
|
||||
}
|
||||
items(categories, key = { it }) { category ->
|
||||
FilterChip(
|
||||
selected = category == activeCategory,
|
||||
onClick = { onCategorySelected(category) },
|
||||
label = { Text(category) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── State Filter Row ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun StateFilterRow(
|
||||
activeState: String?,
|
||||
onStateSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val filters =
|
||||
listOf(
|
||||
stringResource(R.string.achievements_filter_all) to null,
|
||||
stringResource(R.string.achievements_state_unlocked) to "unlocked",
|
||||
stringResource(R.string.achievements_state_discovered) to "discovered",
|
||||
stringResource(R.string.achievements_state_secret) to "secret",
|
||||
)
|
||||
LazyRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(filters, key = { it.second ?: "all" }) { (label, stateValue) ->
|
||||
FilterChip(
|
||||
selected = activeState == stateValue,
|
||||
onClick = { onStateSelected(stateValue ?: "") },
|
||||
label = { Text(label) },
|
||||
colors =
|
||||
FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor =
|
||||
when (stateValue) {
|
||||
"unlocked" -> MaterialTheme.colorScheme.primaryContainer
|
||||
"discovered" -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
"secret" -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.secondaryContainer
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recent Unlock Card ─────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun RecentUnlockCard(
|
||||
unlock: RecentUnlock,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(text = AchievementIcon.emoji(unlock.icon), style = MaterialTheme.typography.headlineSmall)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = unlock.name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
unlock.tier?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
unlock.unlockedAt?.let {
|
||||
Text(
|
||||
text = formatTimestamp(it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Achievement Card ───────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun AchievementCard(
|
||||
achievement: Achievement,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val pct = achievement.progressPct?.toFloat()?.div(100f) ?: 0f
|
||||
val isUnlocked = achievement.unlocked
|
||||
val isDiscovered = achievement.discovered && !isUnlocked
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
when {
|
||||
isUnlocked -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
|
||||
isDiscovered -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
},
|
||||
),
|
||||
border =
|
||||
if (isUnlocked) {
|
||||
androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Header row: icon + name + tier badge
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
// Icon emoji
|
||||
Text(
|
||||
text = AchievementIcon.emoji(achievement.icon),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = achievement.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
achievement.category?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier / state badge
|
||||
val stateText =
|
||||
when {
|
||||
isUnlocked -> achievement.tier ?: "\uD83C\uDFC6"
|
||||
achievement.state == "secret" -> "\u2753"
|
||||
isDiscovered -> "\uD83D\uDD13"
|
||||
else -> "\u2753"
|
||||
}
|
||||
val stateColor =
|
||||
when {
|
||||
isUnlocked -> MaterialTheme.colorScheme.primary
|
||||
achievement.state == "secret" -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Text(
|
||||
text = stateText,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = stateColor,
|
||||
)
|
||||
}
|
||||
|
||||
// Description
|
||||
achievement.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar (discovered but not unlocked)
|
||||
if (isDiscovered) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
R.string.achievements_label_progress,
|
||||
achievement.progress?.toInt() ?: 0,
|
||||
achievement.nextThreshold?.toInt() ?: 0,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "${achievement.progressPct?.toInt() ?: 0}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { pct.coerceIn(0f, 1f) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp)),
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
)
|
||||
}
|
||||
|
||||
// Unlocked progress bar and unlocked_at
|
||||
if (isUnlocked) {
|
||||
achievement.unlockedAt?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.achievements_unlocked_at, formatTimestamp(it)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Expandable criteria
|
||||
achievement.criteria?.let { criteria ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
if (expanded) {
|
||||
stringResource(
|
||||
R.string.achievements_criteria_hide,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.achievements_criteria_show)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Text(
|
||||
text = criteria,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Surface(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: RoundedCornerShape,
|
||||
color: Color = MaterialTheme.colorScheme.surface,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = modifier,
|
||||
shape = shape,
|
||||
color = color,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun formatTimestamp(epochSeconds: Long): String {
|
||||
val sdf = java.text.SimpleDateFormat("MMM d, HH:mm", java.util.Locale.getDefault())
|
||||
return sdf.format(java.util.Date(epochSeconds * 1000))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.model.RecentUnlock
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
@@ -13,22 +14,63 @@ import kotlinx.coroutines.flow.update
|
||||
data class AchievementsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val achievements: List<Achievement> = emptyList(),
|
||||
val unlockedCount: Int = 0,
|
||||
val discoveredCount: Int = 0,
|
||||
val secretCount: Int = 0,
|
||||
val totalCount: Int = 0,
|
||||
val isStale: Boolean = false,
|
||||
val generatedAt: Long? = null,
|
||||
val errorMessage: String? = null,
|
||||
// Scan status
|
||||
val scanState: String = "idle",
|
||||
val scanLastError: String? = null,
|
||||
val scanLastDurationMs: Long? = null,
|
||||
val scanRunCount: Int = 0,
|
||||
// Recent unlocks
|
||||
val recentUnlocks: List<RecentUnlock> = emptyList(),
|
||||
// Filters
|
||||
val categories: List<String> = emptyList(),
|
||||
val activeCategory: String? = null,
|
||||
val activeState: String? = null,
|
||||
// Action states
|
||||
val isRescanning: Boolean = false,
|
||||
val isResetting: Boolean = false,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class AchievementsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AchievementsUiState())
|
||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val api get() = ApiClient.hermesApi
|
||||
|
||||
fun loadAchievements() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getAchievements() } },
|
||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
||||
apiCall = { safeApiCall { api.getAchievements() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
},
|
||||
onSuccess = { data ->
|
||||
val categories =
|
||||
data.achievements
|
||||
?.mapNotNull { it.category }
|
||||
?.distinct()
|
||||
?.sorted() ?: emptyList()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
achievements = data.achievements.orEmpty(),
|
||||
unlockedCount = data.unlockedCount,
|
||||
discoveredCount = data.discoveredCount,
|
||||
secretCount = data.secretCount,
|
||||
totalCount = data.totalCount,
|
||||
isStale = data.isStale,
|
||||
generatedAt = data.generatedAt,
|
||||
scanState = data.scanMeta?.status?.state ?: "idle",
|
||||
scanLastError = data.scanMeta?.status?.lastError,
|
||||
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
|
||||
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
|
||||
categories = categories,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -42,4 +84,114 @@ class AchievementsViewModel : ViewModel() {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun loadScanStatus() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { api.getAchievementScanStatus() } },
|
||||
onStart = { /* silent background refresh */ },
|
||||
onSuccess = { status ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
scanState = status.state,
|
||||
scanLastError = status.lastError,
|
||||
scanLastDurationMs = status.lastDurationMs,
|
||||
scanRunCount = status.runCount,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { /* silent — scan runs independently */ },
|
||||
)
|
||||
}
|
||||
|
||||
fun loadRecentUnlocks() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { api.getRecentUnlocks() } },
|
||||
onStart = { /* silent background refresh */ },
|
||||
onSuccess = { unlocks ->
|
||||
_uiState.update { it.copy(recentUnlocks = unlocks) }
|
||||
},
|
||||
onError = { /* silent — recent unlocks is non-critical */ },
|
||||
)
|
||||
}
|
||||
|
||||
fun rescan() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { api.rescanAchievements() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isRescanning = true) }
|
||||
},
|
||||
onSuccess = { data ->
|
||||
val categories =
|
||||
data.achievements
|
||||
?.mapNotNull { it.category }
|
||||
?.distinct()
|
||||
?.sorted() ?: emptyList()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRescanning = false,
|
||||
achievements = data.achievements.orEmpty(),
|
||||
unlockedCount = data.unlockedCount,
|
||||
discoveredCount = data.discoveredCount,
|
||||
secretCount = data.secretCount,
|
||||
totalCount = data.totalCount,
|
||||
isStale = data.isStale,
|
||||
generatedAt = data.generatedAt,
|
||||
scanState = data.scanMeta?.status?.state ?: "idle",
|
||||
scanLastError = data.scanMeta?.status?.lastError,
|
||||
scanLastDurationMs = data.scanMeta?.status?.lastDurationMs,
|
||||
scanRunCount = data.scanMeta?.status?.runCount ?: 0,
|
||||
categories = categories,
|
||||
toastMessage = "Rescan complete",
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isRescanning = false,
|
||||
toastMessage = "Rescan failed: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun resetState() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { api.resetAchievementState() } },
|
||||
onStart = {
|
||||
_uiState.update { it.copy(isResetting = true) }
|
||||
},
|
||||
onSuccess = {
|
||||
// After reset, clear everything and reload
|
||||
_uiState.update {
|
||||
AchievementsUiState(
|
||||
isLoading = true,
|
||||
toastMessage = "Achievement state reset",
|
||||
)
|
||||
}
|
||||
loadAchievements()
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isResetting = false,
|
||||
toastMessage = "Reset failed: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun setActiveCategory(category: String?) {
|
||||
_uiState.update { it.copy(activeCategory = category) }
|
||||
}
|
||||
|
||||
fun setActiveState(state: String?) {
|
||||
_uiState.update { it.copy(activeState = state) }
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user