feat(#433): achievements tab — full dashboard parity (#450)

* 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:
M57andM57 authored and GitHub committed 2026-07-02 23:48:44 +03:00
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) }
}
}