From 348f001c47b352c2dcfba7bf4fcd435885d39e81 Mon Sep 17 00:00:00 2001 From: Kainoa Kanter Date: Sun, 21 Jun 2026 11:01:34 -0700 Subject: [PATCH] - make leaderboard screen easier to navigate - fix: show more than 10 friends - fix: don't count UTAGE in player data charts/stats - fix: proper chart version in DX rating calculations --- androidApp/build.gradle.kts | 4 +- .../kotlin/com/derakkuma/data/MaimaiClient.kt | 24 +- .../kotlin/com/derakkuma/data/Models.kt | 2 + .../kotlin/com/derakkuma/data/Parsers.kt | 11 + .../com/derakkuma/ui/tabs/PlayerDataTab.kt | 10 +- .../com/derakkuma/ui/tabs/RankingTab.kt | 236 ++++++++---------- .../kotlin/com/derakkuma/ui/tabs/RatingTab.kt | 1 + 7 files changed, 157 insertions(+), 131 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index a77c60b..018188c 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -74,8 +74,8 @@ android { applicationId = "com.derakkuma" minSdk = 26 targetSdk = 36 - versionCode = 10 - versionName = "0.4.1" + versionCode = 11 + versionName = "0.4.2" } compileOptions { diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt index 997041e..d75d714 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt @@ -371,7 +371,29 @@ class MaimaiClient( // ---- Friends / Circles ---- - suspend fun fetchFriends(cookie: String = currentCookie()): FriendsPage = parseFriendsPage(fetchPage("/friend/", cookie)) + suspend fun fetchFriends(cookie: String = currentCookie()): FriendsPage { + val firstPage = parseFriendsPage(fetchPage("/friend/", cookie)) + val totalPages = maxOf( + firstPage.totalPages, + if (firstPage.friends.isNotEmpty()) ((firstPage.friendCount + firstPage.friends.size - 1) / firstPage.friends.size).coerceAtLeast(1) else 1, + ) + if (totalPages <= 1 || firstPage.friends.size >= firstPage.friendCount) return firstPage + + val allFriends = firstPage.friends.toMutableList() + var currentPage = firstPage.currentPage + while (currentPage < totalPages) { + val previousPage = currentPage + val nextPage = parseFriendsPage(fetchPage("/friend/pages/?idx=$currentPage&type=next", cookie)) + allFriends += nextPage.friends + currentPage = nextPage.currentPage + if (currentPage <= previousPage || nextPage.friends.isEmpty()) break + } + + val deduped = allFriends.distinctBy { friend -> + friend.idx.ifBlank { "${friend.name}|${friend.title}|${friend.rating}" } + } + return firstPage.copy(friends = deduped, totalPages = totalPages) + } suspend fun fetchFriendDetail(idx: String, cookie: String = currentCookie()): MaimaiUserCard? = parseFriendDetailPage(fetchPage("/friend/friendDetail/?idx=${encodeUrl(idx)}", cookie)) diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt index 03f1970..966b39a 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt @@ -531,6 +531,8 @@ data class FriendsPage( val favoriteMax: Int = 20, val rivalCount: Int = 0, val rivalMax: Int = 3, + val currentPage: Int = 1, + val totalPages: Int = 1, ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt index 585ae5b..89964cb 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt @@ -704,10 +704,19 @@ private fun parseCount(html: String, label: String, defaultMax: Int): Pair { + val match = Regex("""name="idx" value="(\d+)" class="pager"[\s\S]*?/\s*(\d+)""").find(html) + ?: return 1 to 1 + val current = match.groupValues[1].toIntOrNull() ?: 1 + val total = match.groupValues[2].toIntOrNull() ?: current + return current to total.coerceAtLeast(current) +} + fun parseFriendsPage(html: String): FriendsPage { val (friends, friendsMax) = parseCount(html, "FRIENDS", 100) val (favorites, favoritesMax) = parseCount(html, "FAVORITE FRIENDS", 20) val (rivals, rivalsMax) = parseCount(html, "RIVAL", 3) + val (currentPage, totalPages) = parsePager(html) return FriendsPage( friends = parseUserCards(html), friendCount = friends, @@ -716,6 +725,8 @@ fun parseFriendsPage(html: String): FriendsPage { favoriteMax = favoritesMax, rivalCount = rivals, rivalMax = rivalsMax, + currentPage = currentPage, + totalPages = totalPages, ) } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/PlayerDataTab.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/PlayerDataTab.kt index 02e06d9..f3f5222 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/PlayerDataTab.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/PlayerDataTab.kt @@ -49,8 +49,10 @@ fun PlayerDataTab(client: MaimaiClient, cookie: String, profile: Profile? = null LaunchedEffect(Unit) { client.songDb.ensureLoaded() songDbReady = true - archivedCount = playStore?.getCounts()?.second?.toInt() ?: 0 - archivedPlays = playStore?.getAllPlays().orEmpty().dedupeArchivedPlays() + archivedPlays = playStore?.getAllPlays().orEmpty() + .dedupeArchivedPlays() + .filterNot { it.isUtagePlay() } + archivedCount = archivedPlays.size } var data by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } @@ -565,7 +567,7 @@ private data class WeekBucketKey(val year: Int, val month: Int, val week: Int) : private fun List.averageOrNull(): Double? = if (isEmpty()) null else average() private fun List.difficultyAverages(): List> { - val order = listOf("basic", "advanced", "expert", "master", "remaster", "utage") + val order = listOf("basic", "advanced", "expert", "master", "remaster") return groupBy { it.difficulty.lowercase() } .mapValues { (_, plays) -> plays.map { it.achievement }.average() } .toList() @@ -720,3 +722,5 @@ private fun List.dedupeArchivedPlays(): List, selected: String, onSelect: (String) -> Unit) { if (options.isEmpty()) return - Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, bottom = 4.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.take(8).forEach { opt -> - RankingExpressiveFilterChip(selected = opt.value == selected, onClick = { onSelect(opt.value) }) { Text(opt.label, fontSize = 11.sp, maxLines = 1) } - } - if (options.size > 8) { - // Show dropdown for too many options - var expanded by remember { mutableStateOf(false) } - Box { - ExpressiveTextButton(onClick = { expanded = true }, modifier = Modifier.height(32.dp)) { - Text("More...", fontSize = 11.sp, color = MaterialTheme.colorScheme.primary) - } - ExpressiveDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - val extraOptions = options.drop(8) - extraOptions.forEachIndexed { index, opt -> - val isSelected = opt.value == selected - ExpressiveDropdownMenuItem( - selected = isSelected, - text = { Text(opt.label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, - leadingIcon = if (isSelected) ({ Text("✓", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) }) else null, - index = index, - lastIndex = extraOptions.lastIndex, - onClick = { - onSelect(opt.value) - expanded = false - }, - ) - } - } - } + CompactFilterDropdown(label, options, selected, onSelect) +} + +@Composable +private fun CompactFilterDropdown(label: String, options: List, selected: String, onSelect: (String) -> Unit, modifier: Modifier = Modifier) { + if (options.isEmpty()) return + var expanded by remember { mutableStateOf(false) } + val selectedLabel = options.firstOrNull { it.value == selected }?.label ?: label + Box(modifier = modifier) { + ExpressiveTextButton(onClick = { expanded = true }, modifier = Modifier.height(36.dp)) { + Text( + "$label: $selectedLabel ▾", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (options.any { it.value == selected }) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + ExpressiveDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEachIndexed { index, opt -> + val isSelected = opt.value == selected + ExpressiveDropdownMenuItem( + selected = isSelected, + text = { Text(opt.label, fontSize = 12.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal) }, + leadingIcon = if (isSelected) ({ Text("✓", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) }) else null, + index = index, + lastIndex = options.lastIndex, + onClick = { + onSelect(opt.value) + expanded = false + }, + ) } } } @@ -307,35 +309,7 @@ private fun RankingExpressiveFilterChip( @Composable private fun DiffChipRow(options: List, selected: String, onSelect: (String) -> Unit) { if (options.isEmpty()) return - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - options.forEach { opt -> - val chipColor = when (opt.value) { - "0" -> MaterialTheme.colorScheme.primary - - // Basic - blue - "1" -> DiffBasic - - // Advanced - green - "2" -> DiffAdvanced - - // Expert - yellow - "3" -> DiffExpert - - // Master - red - "4" -> DiffMaster - - // Re:MASTER - purple - "10" -> DiffRemaster - - // Utage - pink - "99" -> MaterialTheme.colorScheme.outline - - // All - gray - else -> MaterialTheme.colorScheme.primary - } - RankingExpressiveFilterChip(selected = opt.value == selected, selectedColor = chipColor, onClick = { onSelect(opt.value) }) { Text(opt.label, fontSize = 11.sp, maxLines = 1) } - } - } + CompactFilterDropdown("Difficulty", options, selected, onSelect) } // ---- Ranking Type Chips ---- @@ -356,7 +330,7 @@ private fun ScoreTypeChipRow(options: List, selected: Strin @Composable private fun ApplyButton(onClick: () -> Unit) { - ExpressiveButton(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)) { + ExpressiveButton(onClick = onClick, modifier = Modifier.height(36.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)) { Text("Apply Filters", fontWeight = FontWeight.SemiBold) } } @@ -433,30 +407,32 @@ private fun SongRankingSection( onApply: () -> Unit, ) { // Filter area - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { - if (index != null) { - // Search filters (genre, title, level — mutually exclusive via same search param) - val genreOptions = index.searchOptions.filter { it.value.startsWith("G") } - val titleOptions = index.searchOptions.filter { it.value.startsWith("W") } - val levelOptions = index.searchOptions.filter { it.value.startsWith("L") } - if (genreOptions.isNotEmpty()) FilterChipRow("Genre", genreOptions, search, onSearch) - if (titleOptions.isNotEmpty()) FilterChipRow("Title", titleOptions, search, onSearch) - if (levelOptions.isNotEmpty()) FilterChipRow("Level", levelOptions, search, onSearch) - - // Score type - if (index.scoreTypeOptions.isNotEmpty()) { - ScoreTypeChipRow(index.scoreTypeOptions, scoreType, onScoreType) - } + ExpressiveGlassCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), cornerRadius = 24.dp) { + FlowRow(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (index != null) { + // Search filters (genre, title, level — mutually exclusive via same search param) + val genreOptions = index.searchOptions.filter { it.value.startsWith("G") } + val titleOptions = index.searchOptions.filter { it.value.startsWith("W") } + val levelOptions = index.searchOptions.filter { it.value.startsWith("L") } + if (genreOptions.isNotEmpty()) FilterChipRow("Genre", genreOptions, search, onSearch) + if (titleOptions.isNotEmpty()) FilterChipRow("Title", titleOptions, search, onSearch) + if (levelOptions.isNotEmpty()) FilterChipRow("Level", levelOptions, search, onSearch) + + // Score type + if (index.scoreTypeOptions.isNotEmpty()) { + ScoreTypeChipRow(index.scoreTypeOptions, scoreType, onScoreType) + } - // Ranking type - if (index.rankingTypeOptions.isNotEmpty()) { - RankingTypeChipRow(index.rankingTypeOptions, rankingType, onRankingType) - } + // Ranking type + if (index.rankingTypeOptions.isNotEmpty()) { + RankingTypeChipRow(index.rankingTypeOptions, rankingType, onRankingType) + } - // Difficulty buttons - DiffChipRow(index.difficulties, diff, onDiff) + // Difficulty buttons + DiffChipRow(index.difficulties, diff, onDiff) - ApplyButton(onApply) + ApplyButton(onApply) + } } } @@ -530,32 +506,34 @@ private fun CourseRankingSection( onRankingType: (String) -> Unit, onApply: () -> Unit, ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { - if (index != null) { - // Course filter with dropdown (30 options) - val courseGroups = index.courseOptions.groupBy { opt -> - when { - opt.value.startsWith("601") -> "Dan" + ExpressiveGlassCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), cornerRadius = 24.dp) { + FlowRow(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (index != null) { + // Course filter with dropdown (30 options) + val courseGroups = index.courseOptions.groupBy { opt -> + when { + opt.value.startsWith("601") -> "Dan" - opt.value.startsWith("602") -> "Expert" + opt.value.startsWith("602") -> "Expert" - opt.value.startsWith("603") -> "Master" + opt.value.startsWith("603") -> "Master" - // Not in HAR but just in case - else -> "Other" + // Not in HAR but just in case + else -> "Other" + } } - } - FilterChipRow("Course - Dan", courseGroups["Dan"] ?: emptyList(), course, onCourse) - FilterChipRow("Course - Expert/Master", courseGroups["Expert"] ?: emptyList(), course, onCourse) + FilterChipRow("Course - Dan", courseGroups["Dan"] ?: emptyList(), course, onCourse) + FilterChipRow("Course - Expert/Master", courseGroups["Expert"] ?: emptyList(), course, onCourse) - if (index.scoreTypeOptions.isNotEmpty()) { - ScoreTypeChipRow(index.scoreTypeOptions, scoreType, onScoreType) - } - if (index.rankingTypeOptions.isNotEmpty()) { - RankingTypeChipRow(index.rankingTypeOptions, rankingType, onRankingType) - } + if (index.scoreTypeOptions.isNotEmpty()) { + ScoreTypeChipRow(index.scoreTypeOptions, scoreType, onScoreType) + } + if (index.rankingTypeOptions.isNotEmpty()) { + RankingTypeChipRow(index.rankingTypeOptions, rankingType, onRankingType) + } - ApplyButton(onApply) + ApplyButton(onApply) + } } } @@ -573,10 +551,12 @@ private fun SeasonRankingSection( onSeason: (String) -> Unit, onApply: () -> Unit, ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { - if (data != null && data.seasonOptions.isNotEmpty()) { - FilterChipRow("Season", data.seasonOptions, season, onSeason) - ApplyButton(onApply) + ExpressiveGlassCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), cornerRadius = 24.dp) { + FlowRow(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (data != null && data.seasonOptions.isNotEmpty()) { + FilterChipRow("Season", data.seasonOptions, season, onSeason) + ApplyButton(onApply) + } } } @@ -594,10 +574,12 @@ private fun DeluxeRatingSection( onRankingType: (String) -> Unit, onApply: () -> Unit, ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { - if (data != null && data.rankingTypeOptions.isNotEmpty()) { - RankingTypeChipRow(data.rankingTypeOptions, rankingType, onRankingType) - ApplyButton(onApply) + ExpressiveGlassCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), cornerRadius = 24.dp) { + FlowRow(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (data != null && data.rankingTypeOptions.isNotEmpty()) { + RankingTypeChipRow(data.rankingTypeOptions, rankingType, onRankingType) + ApplyButton(onApply) + } } } @@ -633,15 +615,17 @@ private fun TotalAchievementSection( onDiff: (String) -> Unit, onApply: () -> Unit, ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { - if (data != null) { - if (data.rankingTypeOptions.isNotEmpty()) { - RankingTypeChipRow(data.rankingTypeOptions, rankingType, onRankingType) - } - if (data.difficulties.isNotEmpty()) { - DiffChipRow(data.difficulties, diff, onDiff) + ExpressiveGlassCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), cornerRadius = 24.dp) { + FlowRow(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (data != null) { + if (data.rankingTypeOptions.isNotEmpty()) { + RankingTypeChipRow(data.rankingTypeOptions, rankingType, onRankingType) + } + if (data.difficulties.isNotEmpty()) { + DiffChipRow(data.difficulties, diff, onDiff) + } + ApplyButton(onApply) } - ApplyButton(onApply) } } @@ -659,10 +643,12 @@ private fun PartnerRankingSection( onCharacter: (String) -> Unit, onApply: () -> Unit, ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { - if (data != null && data.characterOptions.isNotEmpty()) { - FilterChipRow("Partner", data.characterOptions, character, onCharacter) - ApplyButton(onApply) + ExpressiveGlassCard(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), cornerRadius = 24.dp) { + FlowRow(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (data != null && data.characterOptions.isNotEmpty()) { + FilterChipRow("Partner", data.characterOptions, character, onCharacter) + ApplyButton(onApply) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/RatingTab.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/RatingTab.kt index cedac9d..35be202 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/RatingTab.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/RatingTab.kt @@ -126,6 +126,7 @@ private fun RatingSongRow(song: RatingSong) { scoreRank = song.scoreRank, albumArtUrl = song.albumArtUrl, artSize = 44.dp, + isDx = song.isDx, usePlaylogRanks = false, ) } -- 2.51.2