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 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,117 +191,560 @@ 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),
) )
} }
items(filteredAchievements, key = { it.id }) { achievement ->
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
Card( // Header stats
modifier = Modifier.fillMaxWidth(), item(key = "stats") {
colors = StatsRow(
CardDefaults.cardColors( unlockedCount = state.unlockedCount,
containerColor = discoveredCount = state.discoveredCount,
if (achievement.unlocked) { secretCount = state.secretCount,
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) totalCount = state.totalCount,
} 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,
)
}
}
// Display Icon/Emoji/Tier // Scan status
val badgeText = item(key = "scan_status") {
when { ScanStatusChip(
achievement.unlocked -> achievement.tier ?: "🏆" scanState = state.scanState,
achievement.discovered -> "🔒 Discovered" isStale = state.isStale,
else -> "❓ Secret" scanLastError = state.scanLastError,
} scanRunCount = state.scanRunCount,
Text( isRescanning = state.isRescanning,
text = badgeText, )
style = MaterialTheme.typography.bodyMedium, }
fontWeight = FontWeight.SemiBold,
color = // Category filters
if (achievement.unlocked) { if (state.categories.isNotEmpty()) {
MaterialTheme.colorScheme.primary item(key = "category_filters") {
} else { CategoryFilterRow(
MaterialTheme.colorScheme.onSurfaceVariant 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 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) }
}
} }
+25
View File
@@ -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>