Skip to content

Commit d4fe523

Browse files
adsamcikCopilot
andcommitted
refactor(game): split AchievementDetailRoute (770 lines) into 5 focused files
Pulls the achievement-detail screen apart along visual responsibility boundaries: - AchievementCategoryAccents.kt — tier/category color/label/icon helpers + PERCENT_MULTIPLIER + NEAR_COMPLETE_THRESHOLD shared across the screen - AchievementHeroCard.kt — hero progress card + ProgressRing + TierBreakdownRow + TierPill - AchievementEmptyState.kt — animated empty state with pulse - AchievementDetailItemCard.kt — per-achievement card with TierMedal + ThickProgressBar - AchievementDetailRoute.kt (rewritten, 9.0 KB vs 25.7 KB before) — ViewModel + Row data + route Scaffold + ShowLockedToggleRow + CategorySectionHeader + countByTier Helpers used cross-file (tierColor, categoryIcon, etc.) are now internal instead of private. Helpers used only by one file (ProgressRing, TierMedal, ThickProgressBar) stay private. No behavior change — visual diff is zero. Verified: :game:compileDebugKotlin clean, :game:testDebugUnitTest 256/256 green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 46a2b0d commit d4fe523

5 files changed

Lines changed: 649 additions & 525 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.adsamcik.tracker.game.ui.compose
2+
3+
import androidx.compose.material.icons.Icons
4+
import androidx.compose.material.icons.automirrored.outlined.DirectionsBike
5+
import androidx.compose.material.icons.automirrored.outlined.DirectionsWalk
6+
import androidx.compose.material.icons.outlined.CalendarMonth
7+
import androidx.compose.material.icons.outlined.EmojiEvents
8+
import androidx.compose.material.icons.outlined.Explore
9+
import androidx.compose.material.icons.outlined.LocalFireDepartment
10+
import androidx.compose.material.icons.outlined.Route
11+
import androidx.compose.material.icons.outlined.Schedule
12+
import androidx.compose.material3.MaterialTheme
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.ui.graphics.Color
15+
import androidx.compose.ui.graphics.vector.ImageVector
16+
import androidx.compose.ui.res.stringResource
17+
import com.adsamcik.tracker.game.R
18+
import com.adsamcik.tracker.stats.api.AchievementCategory
19+
import com.adsamcik.tracker.stats.api.AchievementTier
20+
21+
/** Percent multiplier used when rendering progress fractions in the UI. */
22+
internal const val PERCENT_MULTIPLIER = 100
23+
24+
/**
25+
* Progress fraction (0f-1f) at which a locked achievement is considered
26+
* "near complete" — surfaced with a softer tier-colored border to telegraph
27+
* imminent unlock.
28+
*/
29+
internal const val NEAR_COMPLETE_THRESHOLD = 0.5f
30+
31+
/** Material color matching the given tier's display accent. */
32+
@Composable
33+
internal fun tierColor(tier: AchievementTier): Color = when (tier) {
34+
AchievementTier.BRONZE -> MaterialTheme.colorScheme.tertiary
35+
AchievementTier.SILVER -> MaterialTheme.colorScheme.outline
36+
AchievementTier.GOLD -> MaterialTheme.colorScheme.primary
37+
AchievementTier.DIAMOND -> MaterialTheme.colorScheme.inversePrimary
38+
AchievementTier.MYTHIC -> MaterialTheme.colorScheme.error
39+
}
40+
41+
/** Localized label for the given tier (e.g. "Bronze", "Silver"). */
42+
@Composable
43+
internal fun tierLabel(tier: AchievementTier): String = stringResource(
44+
when (tier) {
45+
AchievementTier.BRONZE -> R.string.achievements_bronze
46+
AchievementTier.SILVER -> R.string.achievements_silver
47+
AchievementTier.GOLD -> R.string.achievements_gold
48+
AchievementTier.DIAMOND -> R.string.achievements_diamond
49+
AchievementTier.MYTHIC -> R.string.achievements_mythic
50+
},
51+
)
52+
53+
/** Material color identifying the given achievement category in UI accents. */
54+
@Composable
55+
internal fun categoryColor(category: AchievementCategory): Color = when (category) {
56+
AchievementCategory.EXPLORATION -> MaterialTheme.colorScheme.primary
57+
AchievementCategory.DISTANCE -> MaterialTheme.colorScheme.tertiary
58+
AchievementCategory.STEPS -> MaterialTheme.colorScheme.secondary
59+
AchievementCategory.STREAKS -> MaterialTheme.colorScheme.error
60+
AchievementCategory.MILESTONES -> MaterialTheme.colorScheme.primary
61+
AchievementCategory.MODES -> MaterialTheme.colorScheme.secondary
62+
AchievementCategory.TIME -> MaterialTheme.colorScheme.tertiary
63+
AchievementCategory.CALENDAR -> MaterialTheme.colorScheme.inversePrimary
64+
}
65+
66+
/** Vector icon representing the given achievement category. */
67+
internal fun categoryIcon(category: AchievementCategory): ImageVector = when (category) {
68+
AchievementCategory.EXPLORATION -> Icons.Outlined.Explore
69+
AchievementCategory.DISTANCE -> Icons.Outlined.Route
70+
AchievementCategory.STEPS -> Icons.AutoMirrored.Outlined.DirectionsWalk
71+
AchievementCategory.STREAKS -> Icons.Outlined.LocalFireDepartment
72+
AchievementCategory.MILESTONES -> Icons.Outlined.EmojiEvents
73+
AchievementCategory.MODES -> Icons.AutoMirrored.Outlined.DirectionsBike
74+
AchievementCategory.TIME -> Icons.Outlined.Schedule
75+
AchievementCategory.CALENDAR -> Icons.Outlined.CalendarMonth
76+
}
77+
78+
/** Display label for the given achievement category. */
79+
internal fun categoryLabel(category: AchievementCategory): String =
80+
category.name.lowercase().replaceFirstChar { it.uppercase() }
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package com.adsamcik.tracker.game.ui.compose
2+
3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.fadeIn
5+
import androidx.compose.animation.fadeOut
6+
import androidx.compose.foundation.background
7+
import androidx.compose.foundation.border
8+
import androidx.compose.foundation.layout.Arrangement
9+
import androidx.compose.foundation.layout.Box
10+
import androidx.compose.foundation.layout.Column
11+
import androidx.compose.foundation.layout.Row
12+
import androidx.compose.foundation.layout.fillMaxWidth
13+
import androidx.compose.foundation.layout.height
14+
import androidx.compose.foundation.layout.padding
15+
import androidx.compose.foundation.layout.size
16+
import androidx.compose.foundation.shape.CircleShape
17+
import androidx.compose.foundation.shape.RoundedCornerShape
18+
import androidx.compose.material.icons.Icons
19+
import androidx.compose.material.icons.outlined.EmojiEvents
20+
import androidx.compose.material.icons.outlined.Lock
21+
import androidx.compose.material.icons.outlined.Stars
22+
import androidx.compose.material.icons.outlined.WorkspacePremium
23+
import androidx.compose.material3.Card
24+
import androidx.compose.material3.CardDefaults
25+
import androidx.compose.material3.Icon
26+
import androidx.compose.material3.MaterialTheme
27+
import androidx.compose.material3.Text
28+
import androidx.compose.runtime.Composable
29+
import androidx.compose.ui.Alignment
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.draw.clip
32+
import androidx.compose.ui.graphics.Brush
33+
import androidx.compose.ui.graphics.Color
34+
import androidx.compose.ui.res.stringResource
35+
import androidx.compose.ui.text.font.FontWeight
36+
import androidx.compose.ui.unit.dp
37+
import com.adsamcik.tracker.game.R
38+
import com.adsamcik.tracker.game.ui.achievement.AchievementFormatting
39+
import com.adsamcik.tracker.stats.api.AchievementCategory
40+
import com.adsamcik.tracker.stats.api.AchievementTier
41+
42+
@Composable
43+
internal fun AchievementDetailCard(row: AchievementDetailRow) {
44+
val tier = row.definition.tier
45+
val tColor = tierColor(tier)
46+
val isUnlocked = row.isUnlocked
47+
val isNearComplete = !isUnlocked && row.progress >= NEAR_COMPLETE_THRESHOLD
48+
49+
val containerColor = if (isUnlocked) {
50+
MaterialTheme.colorScheme.surfaceContainerHigh
51+
} else {
52+
MaterialTheme.colorScheme.surfaceContainer
53+
}
54+
val borderColor = when {
55+
isUnlocked -> tColor.copy(alpha = 0.5f)
56+
isNearComplete -> tColor.copy(alpha = 0.35f)
57+
else -> Color.Transparent
58+
}
59+
val contentAlpha = if (isUnlocked || isNearComplete) 1f else 0.78f
60+
61+
Card(
62+
modifier = Modifier
63+
.fillMaxWidth()
64+
.padding(horizontal = 16.dp)
65+
.border(
66+
width = if (borderColor == Color.Transparent) 0.dp else 1.5.dp,
67+
color = borderColor,
68+
shape = RoundedCornerShape(20.dp),
69+
),
70+
colors = CardDefaults.cardColors(containerColor = containerColor),
71+
shape = RoundedCornerShape(20.dp),
72+
) {
73+
// Subtle tier-color gradient overlay only for unlocked cards
74+
Box {
75+
if (isUnlocked) {
76+
Box(
77+
modifier = Modifier
78+
.fillMaxWidth()
79+
.height(80.dp)
80+
.background(
81+
Brush.verticalGradient(
82+
colors = listOf(
83+
tColor.copy(alpha = 0.10f),
84+
tColor.copy(alpha = 0f),
85+
),
86+
),
87+
),
88+
)
89+
}
90+
Column(
91+
modifier = Modifier.padding(16.dp),
92+
verticalArrangement = Arrangement.spacedBy(12.dp),
93+
) {
94+
Row(
95+
modifier = Modifier.fillMaxWidth(),
96+
verticalAlignment = Alignment.CenterVertically,
97+
horizontalArrangement = Arrangement.spacedBy(14.dp),
98+
) {
99+
TierMedal(tier = tier, isUnlocked = isUnlocked, category = row.definition.category)
100+
Column(
101+
modifier = Modifier.weight(1f),
102+
verticalArrangement = Arrangement.spacedBy(2.dp),
103+
) {
104+
Text(
105+
AchievementFormatting.rememberTitle(row.definition),
106+
style = MaterialTheme.typography.titleMedium,
107+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
108+
fontWeight = FontWeight.SemiBold,
109+
)
110+
Row(
111+
verticalAlignment = Alignment.CenterVertically,
112+
horizontalArrangement = Arrangement.spacedBy(4.dp),
113+
) {
114+
Icon(
115+
Icons.Outlined.Stars,
116+
contentDescription = null,
117+
tint = tColor,
118+
modifier = Modifier.size(12.dp),
119+
)
120+
Text(
121+
stringResource(
122+
R.string.achievement_tier_label,
123+
tierLabel(tier),
124+
tier.pointBonus,
125+
),
126+
style = MaterialTheme.typography.labelSmall,
127+
color = tColor,
128+
fontWeight = FontWeight.Medium,
129+
)
130+
}
131+
}
132+
if (isUnlocked) {
133+
Icon(
134+
Icons.Outlined.EmojiEvents,
135+
contentDescription = stringResource(R.string.achievement_unlocked_content_description),
136+
tint = tColor,
137+
modifier = Modifier.size(24.dp),
138+
)
139+
} else {
140+
Icon(
141+
Icons.Outlined.Lock,
142+
contentDescription = stringResource(R.string.achievement_locked_content_description),
143+
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
144+
modifier = Modifier.size(20.dp),
145+
)
146+
}
147+
}
148+
Text(
149+
AchievementFormatting.rememberDescription(row.definition),
150+
style = MaterialTheme.typography.bodyMedium,
151+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
152+
)
153+
Row(
154+
modifier = Modifier.fillMaxWidth(),
155+
horizontalArrangement = Arrangement.SpaceBetween,
156+
verticalAlignment = Alignment.CenterVertically,
157+
) {
158+
Text(
159+
AchievementFormatting.rememberValueLabel(row.currentValue, row.definition),
160+
style = MaterialTheme.typography.bodySmall,
161+
color = MaterialTheme.colorScheme.onSurfaceVariant,
162+
)
163+
Text(
164+
stringResource(
165+
R.string.achievement_progress_percent,
166+
(row.progress * PERCENT_MULTIPLIER).toInt(),
167+
),
168+
style = MaterialTheme.typography.labelMedium,
169+
color = if (isUnlocked) tColor else MaterialTheme.colorScheme.primary,
170+
fontWeight = FontWeight.SemiBold,
171+
)
172+
}
173+
ThickProgressBar(progress = row.progress, color = tColor, isUnlocked = isUnlocked)
174+
}
175+
}
176+
}
177+
}
178+
179+
@Composable
180+
private fun TierMedal(tier: AchievementTier, isUnlocked: Boolean, category: AchievementCategory) {
181+
val baseColor = tierColor(tier)
182+
val displayColor = if (isUnlocked) baseColor else baseColor.copy(alpha = 0.55f)
183+
val bgAlpha = if (isUnlocked) 0.28f else 0.10f
184+
Box(
185+
modifier = Modifier
186+
.size(48.dp)
187+
.clip(CircleShape)
188+
.background(
189+
Brush.linearGradient(
190+
colors = listOf(
191+
baseColor.copy(alpha = bgAlpha + 0.05f),
192+
baseColor.copy(alpha = bgAlpha),
193+
),
194+
),
195+
)
196+
.border(
197+
width = if (isUnlocked) 1.5.dp else 1.dp,
198+
color = displayColor.copy(alpha = if (isUnlocked) 0.55f else 0.30f),
199+
shape = CircleShape,
200+
),
201+
contentAlignment = Alignment.Center,
202+
) {
203+
Icon(
204+
if (isUnlocked) Icons.Outlined.WorkspacePremium else categoryIcon(category),
205+
contentDescription = null,
206+
tint = displayColor,
207+
modifier = Modifier.size(24.dp),
208+
)
209+
}
210+
}
211+
212+
@Composable
213+
private fun ThickProgressBar(progress: Float, color: Color, isUnlocked: Boolean) {
214+
val trackColor = MaterialTheme.colorScheme.surfaceContainerHighest
215+
Box(
216+
modifier = Modifier
217+
.fillMaxWidth()
218+
.height(10.dp)
219+
.clip(RoundedCornerShape(5.dp))
220+
.background(trackColor),
221+
) {
222+
AnimatedVisibility(
223+
visible = progress > 0f,
224+
enter = fadeIn(),
225+
exit = fadeOut(),
226+
) {
227+
Box(
228+
modifier = Modifier
229+
.fillMaxWidth(progress)
230+
.height(10.dp)
231+
.clip(RoundedCornerShape(5.dp))
232+
.background(
233+
if (isUnlocked) {
234+
Brush.horizontalGradient(
235+
colors = listOf(color.copy(alpha = 0.85f), color),
236+
)
237+
} else {
238+
Brush.horizontalGradient(
239+
colors = listOf(color.copy(alpha = 0.65f), color.copy(alpha = 0.85f)),
240+
)
241+
},
242+
),
243+
)
244+
}
245+
}
246+
}

0 commit comments

Comments
 (0)