* 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
+872
-39
No files matched your search
@@ -1,7 +1,50 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
data class AchievementsResponse(
|
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(
|
data class Achievement(
|
||||||
@@ -16,7 +59,10 @@ data class Achievement(
|
|||||||
val state: String?,
|
val state: String?,
|
||||||
val tier: String?,
|
val tier: String?,
|
||||||
val progress: Double?,
|
val progress: Double?,
|
||||||
val next_tier: String?,
|
@SerializedName("next_tier") val nextTier: String?,
|
||||||
val next_threshold: Double?,
|
@SerializedName("next_threshold") val nextThreshold: Double?,
|
||||||
val progress_pct: 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.ProfileSoulResponse
|
||||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||||
import com.m57.hermescontrol.data.model.RawConfigResponse
|
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.SaveSkillContentRequest
|
||||||
|
import com.m57.hermescontrol.data.model.ScanStatus
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||||
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
|
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
|
||||||
@@ -180,6 +182,18 @@ interface HermesApiService {
|
|||||||
@GET("api/plugins/hermes-achievements/achievements")
|
@GET("api/plugins/hermes-achievements/achievements")
|
||||||
suspend fun getAchievements(): Response<AchievementsResponse>
|
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")
|
@GET("api/pairing")
|
||||||
suspend fun getPairing(): Response<PairingResponse>
|
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
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
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.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -26,18 +43,27 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
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.EmptyState
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.common.SearchBar
|
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
|
@Composable
|
||||||
fun AchievementsScreen(
|
fun AchievementsScreen(
|
||||||
@@ -47,18 +73,56 @@ fun AchievementsScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
var showResetDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val filteredAchievements =
|
val filteredAchievements =
|
||||||
remember(query, state.achievements) {
|
remember(query, state.activeCategory, state.activeState, state.achievements) {
|
||||||
state.achievements.filter { achievement ->
|
state.achievements.filter { a ->
|
||||||
achievement.name.contains(query, ignoreCase = true) ||
|
val matchesSearch =
|
||||||
achievement.description?.contains(query, ignoreCase = true) == true ||
|
query.isEmpty() ||
|
||||||
achievement.category?.contains(query, ignoreCase = true) == true
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadAchievements()
|
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(
|
HermesScaffold(
|
||||||
@@ -66,6 +130,42 @@ fun AchievementsScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadAchievements() },
|
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 ->
|
) { paddingValues ->
|
||||||
when {
|
when {
|
||||||
state.isLoading && state.achievements.isEmpty() -> {
|
state.isLoading && state.achievements.isEmpty() -> {
|
||||||
@@ -91,31 +191,377 @@ fun AchievementsScreen(
|
|||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier =
|
||||||
contentPadding = PaddingValues(16.dp),
|
Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
// Search
|
||||||
|
item(key = "search") {
|
||||||
SearchBar(
|
SearchBar(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = { query = it },
|
onQueryChange = { query = it },
|
||||||
placeholder = "Search achievements...",
|
placeholder = stringResource(R.string.achievements_search_hint),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header stats
|
||||||
|
item(key = "stats") {
|
||||||
|
StatsRow(
|
||||||
|
unlockedCount = state.unlockedCount,
|
||||||
|
discoveredCount = state.discoveredCount,
|
||||||
|
secretCount = state.secretCount,
|
||||||
|
totalCount = state.totalCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ->
|
items(filteredAchievements, key = { it.id }) { achievement ->
|
||||||
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
colors =
|
colors =
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
containerColor =
|
containerColor =
|
||||||
if (achievement.unlocked) {
|
when {
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
isUnlocked -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
|
||||||
} else {
|
isDiscovered -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
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(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -124,11 +570,23 @@ fun AchievementsScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
|
// Header row: icon + name + tier badge
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = achievement.name,
|
text = achievement.name,
|
||||||
@@ -140,39 +598,47 @@ fun AchievementsScreen(
|
|||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Display Icon/Emoji/Tier
|
// Tier / state badge
|
||||||
val badgeText =
|
val stateText =
|
||||||
when {
|
when {
|
||||||
achievement.unlocked -> achievement.tier ?: "🏆"
|
isUnlocked -> achievement.tier ?: "\uD83C\uDFC6"
|
||||||
achievement.discovered -> "🔒 Discovered"
|
achievement.state == "secret" -> "\u2753"
|
||||||
else -> "❓ Secret"
|
isDiscovered -> "\uD83D\uDD13"
|
||||||
|
else -> "\u2753"
|
||||||
|
}
|
||||||
|
val stateColor =
|
||||||
|
when {
|
||||||
|
isUnlocked -> MaterialTheme.colorScheme.primary
|
||||||
|
achievement.state == "secret" -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = badgeText,
|
text = stateText,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color =
|
color = stateColor,
|
||||||
if (achievement.unlocked) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
achievement.description?.let {
|
achievement.description?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (achievement.discovered && !achievement.unlocked) {
|
// Progress bar (discovered but not unlocked)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
if (isDiscovered) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -183,18 +649,76 @@ fun AchievementsScreen(
|
|||||||
stringResource(
|
stringResource(
|
||||||
R.string.achievements_label_progress,
|
R.string.achievements_label_progress,
|
||||||
achievement.progress?.toInt() ?: 0,
|
achievement.progress?.toInt() ?: 0,
|
||||||
achievement.next_threshold?.toInt() ?: 0,
|
achievement.nextThreshold?.toInt() ?: 0,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "${achievement.progress_pct?.toInt() ?: 0}%",
|
text = "${achievement.progressPct?.toInt() ?: 0}%",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { pct.coerceIn(0f, 1f) },
|
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(),
|
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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +726,25 @@ fun AchievementsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 androidx.lifecycle.ViewModel
|
||||||
import com.m57.hermescontrol.data.model.Achievement
|
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.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
@@ -13,22 +14,63 @@ import kotlinx.coroutines.flow.update
|
|||||||
data class AchievementsUiState(
|
data class AchievementsUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val achievements: List<Achievement> = emptyList(),
|
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,
|
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() {
|
class AchievementsViewModel : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(AchievementsUiState())
|
private val _uiState = MutableStateFlow(AchievementsUiState())
|
||||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val api get() = ApiClient.hermesApi
|
||||||
|
|
||||||
fun loadAchievements() {
|
fun loadAchievements() {
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getAchievements() } },
|
apiCall = { safeApiCall { api.getAchievements() } },
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
onStart = {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
},
|
||||||
onSuccess = { data ->
|
onSuccess = { data ->
|
||||||
|
val categories =
|
||||||
|
data.achievements
|
||||||
|
?.mapNotNull { it.category }
|
||||||
|
?.distinct()
|
||||||
|
?.sorted() ?: emptyList()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
achievements = data.achievements.orEmpty(),
|
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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -391,6 +391,31 @@
|
|||||||
<string name="achievements_empty_title">No achievements yet</string>
|
<string name="achievements_empty_title">No achievements yet</string>
|
||||||
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
||||||
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
||||||
|
<string name="achievements_search_hint">Search achievements…</string>
|
||||||
|
<string name="achievements_stat_unlocked">Unlocked</string>
|
||||||
|
<string name="achievements_stat_discovered">Discovered</string>
|
||||||
|
<string name="achievements_stat_secret">Secret</string>
|
||||||
|
<string name="achievements_stat_total">Total</string>
|
||||||
|
<string name="achievements_scan_idle">Idle</string>
|
||||||
|
<string name="achievements_scan_running">Scanning…</string>
|
||||||
|
<string name="achievements_scan_scanning">Rescanning…</string>
|
||||||
|
<string name="achievements_scan_error">Error</string>
|
||||||
|
<string name="achievements_scan_count">%1$d scans</string>
|
||||||
|
<string name="achievements_stale">Cached</string>
|
||||||
|
<string name="achievements_filter_all">All</string>
|
||||||
|
<string name="achievements_state_unlocked">Unlocked</string>
|
||||||
|
<string name="achievements_state_discovered">Discovered</string>
|
||||||
|
<string name="achievements_state_secret">Secret</string>
|
||||||
|
<string name="achievements_recent_title">Recent Unlocks</string>
|
||||||
|
<string name="achievements_count_label">%1$d of %2$d achievements</string>
|
||||||
|
<string name="achievements_criteria_show">Show criteria</string>
|
||||||
|
<string name="achievements_criteria_hide">Hide criteria</string>
|
||||||
|
<string name="achievements_unlocked_at">Unlocked %1$s</string>
|
||||||
|
<string name="achievements_menu_desc">Achievements menu</string>
|
||||||
|
<string name="achievements_action_rescan">Rescan all sessions</string>
|
||||||
|
<string name="achievements_action_reset">Reset all achievement data</string>
|
||||||
|
<string name="achievements_reset_title">Reset achievements?</string>
|
||||||
|
<string name="achievements_reset_desc">This will wipe all unlock and discovery data. Achievements will be re-scanned from scratch on the next scan. This cannot be undone.</string>
|
||||||
|
|
||||||
<!-- Keys Screen -->
|
<!-- Keys Screen -->
|
||||||
<string name="keys_empty_title">No environment keys reported</string>
|
<string name="keys_empty_title">No environment keys reported</string>
|
||||||
|
|||||||
Reference in new issue
Block a user