From f8871460562ee5df2f239e68407d60163d0dd22d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:48:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(#433):=20achievements=20tab=20=E2=80=94=20?= =?UTF-8?q?full=20dashboard=20parity=20(#450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../hermescontrol/data/model/Achievement.kt | 54 +- .../data/remote/HermesApiService.kt | 14 + .../ui/achievements/AchievementIcon.kt | 53 ++ .../ui/achievements/AchievementsScreen.kt | 747 +++++++++++++++--- .../ui/achievements/AchievementsViewModel.kt | 156 +++- app/src/main/res/values/strings.xml | 25 + 6 files changed, 941 insertions(+), 108 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementIcon.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Achievement.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Achievement.kt index d6e5b0ec..8fc9c630 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Achievement.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Achievement.kt @@ -1,7 +1,50 @@ package com.m57.hermescontrol.data.model +import com.google.gson.annotations.SerializedName + data class AchievementsResponse( - val achievements: List, + val achievements: List? = 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?, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 5c4f009e..85954db4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + @GET("api/plugins/hermes-achievements/scan-status") + suspend fun getAchievementScanStatus(): Response + + @POST("api/plugins/hermes-achievements/rescan") + suspend fun rescanAchievements(): Response + + @POST("api/plugins/hermes-achievements/reset-state") + suspend fun resetAchievementState(): Response + + @GET("api/plugins/hermes-achievements/recent-unlocks") + suspend fun getRecentUnlocks(): Response> + @GET("api/pairing") suspend fun getPairing(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementIcon.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementIcon.kt new file mode 100644 index 00000000..8c8ef8fb --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementIcon.kt @@ -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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt index bd18f32b..e39083bf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsScreen.kt @@ -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, + 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)) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt index f70c90e9..2ce961f8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/achievements/AchievementsViewModel.kt @@ -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 = 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 = emptyList(), + // Filters + val categories: List = 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 = _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) } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90590340..0d20a604 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -391,6 +391,31 @@ No achievements yet Complete tasks to unlock achievements. Progress: %1$d / %2$d + Search achievements… + Unlocked + Discovered + Secret + Total + Idle + Scanning… + Rescanning… + Error + %1$d scans + Cached + All + Unlocked + Discovered + Secret + Recent Unlocks + %1$d of %2$d achievements + Show criteria + Hide criteria + Unlocked %1$s + Achievements menu + Rescan all sessions + Reset all achievement data + Reset achievements? + This will wipe all unlock and discovery data. Achievements will be re-scanned from scratch on the next scan. This cannot be undone. No environment keys reported