commit 5c36fda011c236e045da41484e48189f99a1b0f4 Author: Pazzetif Date: Fri Aug 21 08:32:55 2026 +0300 Initial import of DurakScore diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..a302801 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "durakscore" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef5ef48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Android Studio / IntelliJ +.idea/ +*.iml + +# Gradle +.gradle/ +**/build/ + +# Local machine settings +local.properties + +# Signing / secrets +release-signing.properties +keystore.properties +*.jks +*.keystore + +# Build outputs +*.apk +*.aab +app/debug/ +app/release/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Archives +*.rar + +# Firebase local cache +.firebase/ + +# Old manual backups +.v110-production-backup/ diff --git a/V1_10_PRODUCTION_NOTES.txt b/V1_10_PRODUCTION_NOTES.txt new file mode 100644 index 0000000..f05269f --- /dev/null +++ b/V1_10_PRODUCTION_NOTES.txt @@ -0,0 +1,58 @@ +DurakScore V1.10 — production switch / final zero-Firebase finish + +ЦЕЛЬ +- TEST_MODE=false. +- Android versionCode=25, versionName=0.9.15. +- ZERO FIREBASE на Android и VPS. +- Системный сервис больше не содержит старую GOOGLE_APPLICATION_CREDENTIALS. +- Обновления только через HTTPS VPS updater. +- Публикация APK через существующий durak-release. + +ФАЙЛЫ + +1. prepare_v110_production.ps1 + Запускается из корня Android Studio проекта. + Сам: + - находит РОВНО ОДНО объявление TEST_MODE=true; + - делает его false; + - поднимает версию до 25 / 0.9.15; + - убирает app/google-services.json в .v110-production-backup; + - проверяет Kotlin/Gradle на реальные Firebase SDK-хвосты; + - делает резервные копии изменяемых файлов. + +2. finalize_v110_vps.sh + Один финальный VPS preflight: + - убирает старый GOOGLE_APPLICATION_CREDENTIALS из systemd service; + - рестартует backend; + - проверяет health; + - проверяет отсутствие firebase-admin/firestore/key; + - показывает PostgreSQL baseline; + - проверяет HTTPS updater и durak-release. + +3. publish_v110_release.sh + После сборки signed release APK: + - загрузи его на VPS как /root/durakometr-0.9.15.apk; + - скрипт положит его в /var/www/html/updates; + - вызовет durak-release 25; + - проверит latest.json и APK по HTTPS. + +ВАЖНО +После TEST_MODE=false классическая партия — боевая и её проигравший получает +1 +в основном счёте. Для проверки интерфейса без изменения основного счёта используй +CUSTOM-партию, а не CLASSIC. + +Базовый основной счёт перед production: +- denis 18 +- dmitry 1 +- masha 7 +- rybka 3 +- games 8 + +Auth accounts: +- Денис +- Дмитрий +- Маша +- Рыбка + +Firebase уже не является частью production-архитектуры. +Старые миграционные дампы/архивы можно хранить как офлайн-исторический backup. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fb01856 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,176 @@ +import java.util.Properties + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +val releaseSigningPropertiesFile = + rootProject.file("release-signing.properties") + +val releaseSigningProperties = + Properties().apply { + if (releaseSigningPropertiesFile.exists()) { + releaseSigningPropertiesFile + .reader(Charsets.UTF_8) + .use { load(it) } + } + } + +android { + namespace = "ru.durakscore.app" + + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = "ru.durakscore.app" + + minSdk = 26 + targetSdk = 37 + + versionCode = 29 + versionName = "0.9.19" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + create("release") { + if (releaseSigningPropertiesFile.exists()) { + storeFile = + file( + releaseSigningProperties.getProperty( + "storeFile" + ) + ) + + storePassword = + releaseSigningProperties.getProperty( + "storePassword" + ) + + keyAlias = + releaseSigningProperties.getProperty( + "keyAlias" + ) + + keyPassword = + releaseSigningProperties.getProperty( + "keyPassword" + ) + } + } + } + + buildTypes { + release { + if (releaseSigningPropertiesFile.exists()) { + signingConfig = + signingConfigs.getByName( + "release" + ) + } + + optimization { + enable = false + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildFeatures { + compose = true + } +} + +val releaseVersionName = + android.defaultConfig.versionName + ?: error("versionName is not set") + +val releaseVersionCode = + android.defaultConfig.versionCode + +tasks.register("publishRelease") { + notCompatibleWithConfigurationCache("External PowerShell SSH release task") + group = "release" + description = + "Build signed APK and publish Durakometr update to VPS" + + dependsOn("assembleRelease") + + doFirst { + check( + releaseSigningPropertiesFile.exists() + ) { + "Missing release-signing.properties in project root" + } + + val requiredKeys = + listOf( + "storeFile", + "storePassword", + "keyAlias", + "keyPassword" + ) + + requiredKeys.forEach { key -> + check( + !releaseSigningProperties + .getProperty(key) + .isNullOrBlank() + ) { + "Missing '$key' in release-signing.properties" + } + } + } + + workingDir = + rootProject.projectDir + + commandLine( + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + rootProject + .file("publish-release.ps1") + .absolutePath, + "-VersionName", + releaseVersionName, + "-VersionCode", + releaseVersionCode.toString() + ) +} + +dependencies { + + implementation(platform(libs.androidx.compose.bom)) + + implementation("androidx.appcompat:appcompat:1.7.1") + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + + testImplementation(libs.junit) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/app/src/androidTest/java/ru/durakscore/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/ru/durakscore/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f223572 --- /dev/null +++ b/app/src/androidTest/java/ru/durakscore/app/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package ru.durakscore.app + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ru.durakscore.app", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2f35df0 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..a86af31 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/ru/durakscore/app/AchievementsScreen.kt b/app/src/main/java/ru/durakscore/app/AchievementsScreen.kt new file mode 100644 index 0000000..4a748a6 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/AchievementsScreen.kt @@ -0,0 +1,1767 @@ +package ru.durakscore.app + +import androidx.activity.compose.BackHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.background +import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Date + +private data class AchievementGame( + val id: String, + val targetScore: Int, + val scores: Map, + val history: List, + val loserId: String?, + val finishedAt: Timestamp? +) + +private data class PlayerAchievementStats( + val gamesPlayed: Int, + val losses: Int, + val totalPoints: Int, + val bestRoundStreak: Int, + val maxLossStreak: Int, + val cleanGames: Int, + val nearEscapeGames: Int, + val bestSurvivedScore: Int, + val bestSurvivedTarget: Int +) + +private data class AchievementItem( + val icon: String, + val title: String, + val description: String, + val unlocked: Boolean, + val progress: String +) + +private data class TableRecord( + val icon: String, + val title: String, + val holder: String, + val value: String +) + +private val corePlayers = + listOf( + "dmitry" to "Дмитрий", + "denis" to "Денис", + "rybka" to "Рыбка", + "masha" to "Маша" + ) + +private fun parseAchievementGame( + game: ServerFinishedGame +): AchievementGame? { + + if ( + game.isTest != + TEST_MODE + ) { + return null + } + + if ( + game.gameType != + "classic" + ) { + return null + } + + return AchievementGame( + id = + game.id, + targetScore = + game.targetScore, + scores = + game.finalScores, + history = + game.history, + loserId = + game.loserId + .takeIf { + it.isNotBlank() + }, + finishedAt = + game.finishedAtEpochMillis + .takeIf { + it > 0L + } + ?.let { + Timestamp( + Date( + it + ) + ) + } + ) +} + +private fun bestNoPointStreak( + game: AchievementGame, + playerId: String +): Int { + + var current = + 0 + + var best = + 0 + + game.history.forEach { + loserId -> + + if ( + loserId == + playerId + ) { + current = + 0 + } else { + current++ + + if ( + current > + best + ) { + best = + current + } + } + } + + return best +} + +private fun calculatePlayerStats( + games: List, + playerId: String +): PlayerAchievementStats { + + val playerGames = + games + .filter { + it.scores + .containsKey( + playerId + ) + } + .sortedBy { + it.finishedAt + ?.seconds + ?: 0L + } + + var currentLossStreak = + 0 + + var maxLossStreak = + 0 + + playerGames.forEach { + game -> + + if ( + game.loserId == + playerId + ) { + currentLossStreak++ + + if ( + currentLossStreak > + maxLossStreak + ) { + maxLossStreak = + currentLossStreak + } + } else { + currentLossStreak = + 0 + } + } + + var bestSurvivedScore = + 0 + + var bestSurvivedTarget = + 0 + + playerGames + .filter { + it.loserId != + playerId + } + .forEach { + game -> + + val score = + game.scores[playerId] + ?: 0 + + val oldDistance = + if ( + bestSurvivedTarget > + 0 + ) { + bestSurvivedTarget - + bestSurvivedScore + } else { + Int.MAX_VALUE + } + + val newDistance = + game.targetScore - + score + + if ( + newDistance < + oldDistance + ) { + bestSurvivedScore = + score + + bestSurvivedTarget = + game.targetScore + } + } + + return PlayerAchievementStats( + gamesPlayed = + playerGames.size, + losses = + playerGames.count { + it.loserId == + playerId + }, + totalPoints = + playerGames.sumOf { + game -> + game.history.count { + it == + playerId + } + }, + bestRoundStreak = + playerGames + .maxOfOrNull { + bestNoPointStreak( + game = + it, + playerId = + playerId + ) + } + ?: 0, + maxLossStreak = + maxLossStreak, + cleanGames = + playerGames.count { + (it.scores[playerId] ?: 0) == + 0 + }, + nearEscapeGames = + playerGames.count { game -> + + game.loserId != + playerId && + ( + game.scores[playerId] + ?: 0 + ) == + game.targetScore - 1 + }, + bestSurvivedScore = + bestSurvivedScore, + bestSurvivedTarget = + bestSurvivedTarget + ) +} + +private fun buildAchievements( + stats: PlayerAchievementStats +): List { + + val survivedOneAway = + stats.bestSurvivedTarget > + 0 && + stats.bestSurvivedScore >= + stats.bestSurvivedTarget - 1 + + return listOf( + AchievementItem( + icon = "🧱", + title = "Хуй пробьёшь", + description = + "Продержаться 10 раздач подряд без +1.", + unlocked = + stats.bestRoundStreak >= + 10, + progress = + "${stats.bestRoundStreak}/10" + ), + + AchievementItem( + icon = "💩", + title = "Серийный дурак", + description = + "Проиграть пять классических каток подряд.", + unlocked = + stats.maxLossStreak >= + 5, + progress = + "${stats.maxLossStreak}/5 подряд" + ), + + AchievementItem( + icon = "🧲", + title = "Ебаный пылесос +1", + description = + "Собрать 100 очков за всё время.", + unlocked = + stats.totalPoints >= + 100, + progress = + "${stats.totalPoints}/100" + ), + + AchievementItem( + icon = "🪳", + title = "Живучая тварь", + description = + "Оказаться в одном очке от проигрыша и всё-таки выжить.", + unlocked = + survivedOneAway, + progress = + if ( + stats.bestSurvivedTarget > + 0 + ) { + "${stats.bestSurvivedScore}/${stats.bestSurvivedTarget}" + } else { + "—" + } + ), + + AchievementItem( + icon = "🧼", + title = "Сухой, аж бесит", + description = + "Трижды закончить катку вообще без +1.", + unlocked = + stats.cleanGames >= + 3, + progress = + "${stats.cleanGames}/3" + ), + + AchievementItem( + icon = "☠️", + title = "Смерть его не берёт", + description = + "Трижды закончить катку в одном очке от поражения и всё-таки не проиграть.", + unlocked = + stats.nearEscapeGames >= + 3, + progress = + "${stats.nearEscapeGames}/3" + ), + + AchievementItem( + icon = "👑", + title = "Заслуженный долбоёб", + description = + "Набрать 10 официальных поражений в приложении.", + unlocked = + stats.losses >= + 10, + progress = + "${stats.losses}/10" + ) + ) +} + +private fun buildRecords( + games: List +): List { + + if ( + games.isEmpty() + ) { + return emptyList() + } + + val statsByPlayer = + corePlayers.associate { + (id, _) -> + id to + calculatePlayerStats( + games = + games, + playerId = + id + ) + } + + val maxLosses = + statsByPlayer + .values + .maxOfOrNull { + it.losses + } + ?: 0 + + val lossLeaders = + if ( + maxLosses > + 0 + ) { + statsByPlayer + .filterValues { + it.losses == + maxLosses + } + .keys + .toList() + } else { + emptyList() + } + + val longestStreak = + statsByPlayer + .maxByOrNull { + it.value.bestRoundStreak + } + + val mostClean = + statsByPlayer + .maxByOrNull { + it.value.cleanGames + } + + data class Escape( + val playerId: String, + val score: Int, + val target: Int, + val distance: Int + ) + + val closestEscape = + games.flatMap { + game -> + + corePlayers + .mapNotNull { + (playerId, _) -> + + if ( + game.loserId == + playerId || + !game.scores + .containsKey( + playerId + ) + ) { + null + } else { + + val score = + game.scores[playerId] + ?: 0 + + Escape( + playerId = + playerId, + score = + score, + target = + game.targetScore, + distance = + game.targetScore - + score + ) + } + } + } + .minByOrNull { + it.distance + } + + fun playerName( + id: String? + ): String = + corePlayers + .firstOrNull { + it.first == + id + } + ?.second + ?: "—" + + val result = + mutableListOf() + + fun lossWord( + value: Int + ): String { + + val mod100 = + value % 100 + + if ( + mod100 in + 11..14 + ) { + return "поражений" + } + + return when ( + value % 10 + ) { + 1 -> + "поражение" + + 2, 3, 4 -> + "поражения" + + else -> + "поражений" + } + } + + if ( + lossLeaders.isNotEmpty() + ) { + + val leaderNames = + lossLeaders + .map { + playerName( + it + ) + } + .sorted() + .joinToString( + separator = " и " + ) + + val title = + when { + maxLosses == 1 && + lossLeaders.size == 1 -> + "Пока главный дурак" + + maxLosses == 1 -> + "Пока главные дураки" + + lossLeaders.size > 1 -> + "Главные дураки" + + else -> + "Главный дурак" + } + + val value = + if ( + lossLeaders.size > + 1 + ) { + "по $maxLosses ${lossWord(maxLosses)}" + } else { + "$maxLosses ${lossWord(maxLosses)}" + } + + result.add( + TableRecord( + icon = "💩", + title = + title, + holder = + leaderNames, + value = + value + ) + ) + } + + if ( + longestStreak != null + ) { + result.add( + TableRecord( + icon = "🧱", + title = "Железная жопа", + holder = + playerName( + longestStreak.key + ), + value = + "${longestStreak.value.bestRoundStreak} без +1" + ) + ) + } + + if ( + mostClean != null + ) { + result.add( + TableRecord( + icon = "🧼", + title = "Самый сухой", + holder = + playerName( + mostClean.key + ), + value = + "${mostClean.value.cleanGames} каток с нулём" + ) + ) + } + + if ( + closestEscape != null + ) { + result.add( + TableRecord( + icon = "🪳", + title = "Выжил на соплях", + holder = + playerName( + closestEscape.playerId + ), + value = + "${closestEscape.score}/${closestEscape.target}" + ) + ) + } + + return result +} + +private fun achievementIconRes( + title: String +): Int = + when (title) { + "Хуй пробьёшь" -> + R.drawable.gothic_spikes + + "Серийный дурак" -> + R.drawable.gothic_serial_jester + + "Ебаный пылесос +1" -> + R.drawable.gothic_vacuum + + "Живучая тварь" -> + R.drawable.gothic_bandaged_heart + + "Сухой, аж бесит" -> + R.drawable.gothic_goblet + + "Смерть его не берёт" -> + R.drawable.gothic_burning_card + + "Заслуженный долбоёб" -> + R.drawable.gothic_crown_skull + + else -> + R.drawable.gothic_jester_face + } + +private fun recordIconRes( + title: String +): Int = + when { + title.contains( + "дурак", + ignoreCase = + true + ) -> + R.drawable.gothic_crown_skull + + title.contains( + "Железная", + ignoreCase = + true + ) -> + R.drawable.gothic_armor + + title.contains( + "сух", + ignoreCase = + true + ) -> + R.drawable.gothic_goblet + + title.contains( + "сопл", + ignoreCase = + true + ) -> + R.drawable.gothic_bandaged_heart + + else -> + R.drawable.gothic_skull_jester + } + +private fun achievementProgressFraction( + title: String, + stats: PlayerAchievementStats +): Float { + + val raw = + when (title) { + "Хуй пробьёшь" -> + stats.bestRoundStreak / 10f + + "Серийный дурак" -> + stats.maxLossStreak / 5f + + "Ебаный пылесос +1" -> + stats.totalPoints / 100f + + "Живучая тварь" -> + if ( + stats.bestSurvivedTarget > + 0 && + stats.bestSurvivedScore >= + stats.bestSurvivedTarget - 1 + ) { + 1f + } else { + 0f + } + + "Сухой, аж бесит" -> + stats.cleanGames / 3f + + "Смерть его не берёт" -> + stats.nearEscapeGames / 3f + + "Заслуженный долбоёб" -> + stats.losses / 10f + + else -> + 0f + } + + return raw.coerceIn( + 0f, + 1f + ) +} + +@Composable +fun AchievementsScreen( + access: UserAccess, + onBack: () -> Unit +) { + + BackHandler( + onBack = + onBack + ) + + var games by remember { + mutableStateOf>( + emptyList() + ) + } + + var isLoading by remember { + mutableStateOf(true) + } + + var errorText by remember { + mutableStateOf( + null + ) + } + + var selectedPlayerId by remember( + access.playerId + ) { + mutableStateOf( + access.playerId + .takeIf { + id -> + corePlayers.any { + it.first == + id + } + } + ?: "denis" + ) + } + + LaunchedEffect(Unit) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + isLoading = + false + + errorText = + "Пользователь не авторизован" + + return@LaunchedEffect + } + + DurakServerApi + .loadFinishedGames( + user = + user, + onSuccess = { + serverGames -> + + games = + serverGames + .mapNotNull { + parseAchievementGame( + it + ) + } + + isLoading = + false + + errorText = + null + }, + onError = { + message -> + + isLoading = + false + + errorText = + message.ifBlank { + "Не удалось загрузить достижения" + } + } + ) + } + + val selectedName = + corePlayers + .firstOrNull { + it.first == + selectedPlayerId + } + ?.second + ?: selectedPlayerId + + val selectedStats = + remember( + games, + selectedPlayerId + ) { + calculatePlayerStats( + games = + games, + playerId = + selectedPlayerId + ) + } + + val achievements = + remember( + selectedStats + ) { + buildAchievements( + selectedStats + ) + } + + val records = + remember( + games + ) { + buildRecords( + games + ) + } + + PokerScreen { + + PokerBackTextButton( + text = + "← Назад", + onClick = + onBack + ) + + Spacer( + modifier = + Modifier.height( + 4.dp + ) + ) + + Text( + text = + "ДОСТИЖЕНИЯ", + color = + PokerPalette.TextPrimary, + fontSize = + 31.sp, + fontFamily = + PokerDisplayFont, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + AchievementHero() + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + PlayerSelector( + selectedPlayerId = + selectedPlayerId, + onSelect = { + selectedPlayerId = + it + } + ) + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + when { + + isLoading -> { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.Center + ) { + + CircularProgressIndicator( + color = + PokerPalette.CrimsonBright + ) + } + } + + errorText != null -> { + + PokerPanel { + + Text( + text = + errorText!!, + color = + PokerPalette.Danger + ) + } + } + + else -> { + + GothicSectionTitle( + text = + "РЕКОРДЫ СТОЛА" + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + RecordsPanel( + records = + records + ) + + Spacer( + modifier = + Modifier.height( + 20.dp + ) + ) + + GothicSectionTitle( + text = + "ДОСТИЖЕНИЯ • $selectedName" + ) + + Spacer( + modifier = + Modifier.height( + 5.dp + ) + ) + + Text( + text = + "Открыто ${achievements.count { it.unlocked }} из ${achievements.size}", + color = + PokerPalette.TextSecondary, + fontSize = + 12.sp + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth() + ) { + val adaptiveSingleColumn = + maxWidth < 350.dp || + LocalDensity.current.fontScale >= 1.25f + + if (adaptiveSingleColumn) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy(9.dp) + ) { + achievements.forEach { item -> + AchievementGothicCard( + achievement = item, + progress = + achievementProgressFraction( + title = item.title, + stats = selectedStats + ), + modifier = Modifier.fillMaxWidth() + ) + } + } + } else { + Column( + modifier = Modifier.fillMaxWidth() + ) { + achievements + .chunked(2) + .forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(9.dp) + ) { + rowItems.forEach { item -> + AchievementGothicCard( + achievement = item, + progress = + achievementProgressFraction( + title = item.title, + stats = selectedStats + ), + modifier = + if (rowItems.size == 1) { + Modifier.fillMaxWidth() + } else { + Modifier.weight(1f) + } + ) + } + } + + Spacer( + modifier = Modifier.height(9.dp) + ) + } + } + } + } + } + } + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + PokerBottomBackButton( + onClick = + onBack + ) + } +} + +@Composable +private fun AchievementHero() { + val fontScale = LocalDensity.current.fontScale + val heroMinHeight = + when { + fontScale >= 1.30f -> 214.dp + fontScale >= 1.15f -> 194.dp + else -> 176.dp + } + val textEndPadding = + if (fontScale >= 1.25f) { + 74.dp + } else { + 140.dp + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight), + color = Color(0xF2070908), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = RoundedCornerShape(18.dp) + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight) + ) { + Image( + painter = + painterResource( + id = R.drawable.gothic_achievements_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(190.dp) + ) + + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(220.dp) + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + Color(0xF2070908), + Color(0xB0070908), + Color.Transparent + ) + ) + ) + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = 17.dp, + top = 20.dp, + end = textEndPadding, + bottom = 16.dp + ) + ) { + Text( + text = "ДУРАКОМЕТР", + color = PokerPalette.CrimsonBright, + fontSize = + if (fontScale >= 1.30f) { + 18.sp + } else { + 21.sp + }, + lineHeight = 24.sp, + fontFamily = PokerDisplayFont, + fontWeight = FontWeight.Black, + maxLines = 2, + softWrap = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Слава, позор\nи сомнительные заслуги", + color = PokerPalette.Gold, + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 4, + softWrap = true + ) + } + } + } +} + +@Composable +private fun PlayerSelector( + selectedPlayerId: String, + onSelect: (String) -> Unit +) { + + Column( + verticalArrangement = + Arrangement.spacedBy( + 7.dp + ) + ) { + + corePlayers + .chunked( + 2 + ) + .forEach { + row -> + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy( + 7.dp + ) + ) { + + row.forEach { + (id, name) -> + + val selected = + id == + selectedPlayerId + + Surface( + modifier = + Modifier + .weight( + 1f + ) + .clickable { + onSelect( + id + ) + }, + color = + if ( + selected + ) { + Color( + 0xFF260C0A + ) + } else { + PokerPalette.Panel + }, + border = + BorderStroke( + if ( + selected + ) { + 1.5.dp + } else { + 1.dp + }, + if ( + selected + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.GoldDark + } + ), + shape = + RoundedCornerShape( + 14.dp + ) + ) { + + Text( + text = + name, + modifier = + Modifier.padding( + horizontal = + 10.dp, + vertical = + 9.dp + ), + color = + if ( + selected + ) { + PokerPalette.GoldBright + } else { + PokerPalette.TextPrimary + }, + fontSize = + 13.sp, + fontWeight = + FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +private fun GothicSectionTitle( + text: String +) { + + Row( + modifier = + Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + + Box( + modifier = + Modifier + .weight( + 1f + ) + .height( + 1.dp + ) + .background( + PokerPalette.GoldDark + ) + ) + + Text( + text = + text, + modifier = + Modifier.padding( + horizontal = + 10.dp + ), + color = + PokerPalette.CrimsonBright, + fontSize = + 14.sp, + fontWeight = + FontWeight.Black, + letterSpacing = + 1.3.sp + ) + + Box( + modifier = + Modifier + .weight( + 1f + ) + .height( + 1.dp + ) + .background( + PokerPalette.GoldDark + ) + ) + } +} + +@Composable +private fun RecordsPanel( + records: List +) { + + Surface( + modifier = + Modifier.fillMaxWidth(), + color = + Color( + 0xF2070B09 + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = + RoundedCornerShape( + 16.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 12.dp + ) + ) { + + if ( + records.isEmpty() + ) { + + Text( + text = + "Пока рекордов нет. Значит, ещё недостаточно натворили.", + color = + PokerPalette.TextSecondary, + fontSize = + 13.sp + ) + + } else { + + records.forEachIndexed { + index, + record -> + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + vertical = + 8.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + Image( + painter = + painterResource( + id = + recordIconRes( + record.title + ) + ), + contentDescription = + null, + modifier = + Modifier.size( + 50.dp + ), + contentScale = + ContentScale.Fit + ) + + Spacer( + modifier = + Modifier.width( + 9.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + record.title.uppercase(), + color = + PokerPalette.TextPrimary, + fontSize = + 12.sp, + fontWeight = + FontWeight.Black, + maxLines = + 2, + softWrap = + true, + overflow = + TextOverflow.Ellipsis + ) + + Spacer( + modifier = + Modifier.height( + 2.dp + ) + ) + + Text( + text = + record.holder, + color = + PokerPalette.CrimsonBright, + fontSize = + 12.sp, + fontWeight = + FontWeight.SemiBold + ) + } + + Text( + text = + record.value, + color = + PokerPalette.GoldBright, + fontSize = + 13.sp, + fontWeight = + FontWeight.Black + ) + } + + if ( + index != + records.lastIndex + ) { + + Box( + modifier = + Modifier + .fillMaxWidth() + .height( + 1.dp + ) + .background( + Color( + 0xFF2A2419 + ) + ) + ) + } + } + } + + Spacer( + modifier = + Modifier.height( + 5.dp + ) + ) + + Text( + text = + "Рекорды считаются по классическим партиям, записанным Дуракометром.", + color = + PokerPalette.TextSecondary, + fontSize = + 10.sp + ) + } + } +} + +@Composable +private fun AchievementGothicCard( + achievement: AchievementItem, + progress: Float, + modifier: Modifier = Modifier +) { + val fontScale = LocalDensity.current.fontScale + + val activeColor = + if (achievement.unlocked) { + PokerPalette.GoldBright + } else { + PokerPalette.CrimsonBright + } + + val minimumHeight = + when { + fontScale >= 1.30f -> 246.dp + fontScale >= 1.15f -> 214.dp + else -> 184.dp + } + + Surface( + modifier = + modifier.heightIn( + min = minimumHeight + ), + color = Color(0xF2070B09), + border = + BorderStroke( + 1.dp, + if (achievement.unlocked) { + PokerPalette.Gold + } else { + PokerPalette.GoldDark + } + ), + shape = RoundedCornerShape(15.dp) + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = + painterResource( + id = achievementIconRes(achievement.title) + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier.size( + if (fontScale >= 1.25f) { + 48.dp + } else { + 56.dp + } + ) + ) + + Spacer(modifier = Modifier.width(7.dp)) + + Text( + text = achievement.title.uppercase(), + color = + if (achievement.unlocked) { + PokerPalette.GoldBright + } else { + PokerPalette.TextPrimary + }, + fontSize = 11.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Black, + maxLines = 4, + softWrap = true, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = achievement.description, + color = PokerPalette.TextSecondary, + fontSize = 9.5.sp, + lineHeight = 13.sp, + maxLines = 6, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.weight(1f)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(5.dp) + .background( + Color(0xFF241D13), + RoundedCornerShape(100.dp) + ) + ) { + Box( + modifier = + Modifier + .fillMaxWidth(progress) + .fillMaxHeight() + .background( + activeColor, + RoundedCornerShape(100.dp) + ) + ) + } + + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = achievement.progress, + color = PokerPalette.Gold, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + maxLines = 2 + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = + if (achievement.unlocked) { + "✓ ОТКРЫТО" + } else { + "ЗАКРЫТО" + }, + color = activeColor, + fontSize = 9.sp, + fontWeight = FontWeight.Black, + maxLines = 2 + ) + } + } + } +} + diff --git a/app/src/main/java/ru/durakscore/app/AdminUsersScreen.kt b/app/src/main/java/ru/durakscore/app/AdminUsersScreen.kt new file mode 100644 index 0000000..006829f --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/AdminUsersScreen.kt @@ -0,0 +1,703 @@ +package ru.durakscore.app + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +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.sp +import java.util.Date + +private data class PendingAccessRequest( + val uid: String, + val name: String, + val email: String, + val createdAt: Timestamp? +) + +private data class ActiveMember( + val uid: String, + val name: String, + val playerId: String, + val role: String, + val active: Boolean +) + +private data class UserSlot( + val playerId: String, + val name: String, + val role: String +) + +private val userSlots = + listOf( + UserSlot("denis", "Денис", "admin"), + UserSlot("dmitry", "Дмитрий", "scorekeeper"), + UserSlot("rybka", "Рыбка", "viewer"), + UserSlot("masha", "Маша", "viewer") + ) + +@Composable +fun AdminUsersScreen( + access: UserAccess, + onBack: () -> Unit +) { + BackHandler(onBack = onBack) + + var requests by remember { + mutableStateOf>(emptyList()) + } + + var members by remember { + mutableStateOf>(emptyList()) + } + + var requestsLoaded by remember { + mutableStateOf(false) + } + + var membersLoaded by remember { + mutableStateOf(false) + } + + var errorText by remember { + mutableStateOf(null) + } + + var actionUid by remember { + mutableStateOf(null) + } + + var actionError by remember { + mutableStateOf(null) + } + + var reloadKey by remember { + mutableStateOf(0) + } + + val isAdmin = + access.role == "admin" + + LaunchedEffect( + isAdmin, + reloadKey + ) { + + if ( + !isAdmin + ) { + requestsLoaded = + true + + membersLoaded = + true + + errorText = + "Этот раздел доступен только администратору" + + return@LaunchedEffect + } + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + requestsLoaded = + true + + membersLoaded = + true + + errorText = + "Пользователь не авторизован" + + return@LaunchedEffect + } + + DurakServerApi + .loadAdminUsers( + user = + user, + onSuccess = { + loaded -> + + requests = + loaded.requests + .map { + request -> + + PendingAccessRequest( + uid = + request.uid, + name = + request.name, + email = + request.email, + createdAt = + request + .createdAtEpochMillis + ?.let { + Timestamp( + Date( + it + ) + ) + } + ) + } + + members = + loaded.members + .map { + member -> + + ActiveMember( + uid = + member.uid, + name = + member.name, + playerId = + member.playerId, + role = + member.role, + active = + member.active + ) + } + + requestsLoaded = + true + + membersLoaded = + true + + errorText = + null + }, + onError = { + message -> + + requestsLoaded = + true + + membersLoaded = + true + + if ( + requests.isEmpty() && + members.isEmpty() + ) { + errorText = + message.ifBlank { + "Не удалось загрузить пользователей" + } + } + } + ) + } + + fun approve( + request: PendingAccessRequest, + slot: UserSlot + ) { + + if ( + actionUid != + null + ) { + return + } + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + actionError = + "Пользователь не авторизован" + + return + } + + actionUid = + request.uid + + actionError = + null + + DurakServerApi + .approveAdminUser( + user = + user, + requestUid = + request.uid, + playerId = + slot.playerId, + onSuccess = { + actionUid = + null + + reloadKey++ + }, + onError = { + message -> + + actionUid = + null + + actionError = + message.ifBlank { + "Не удалось выдать доступ" + } + } + ) + } + + PokerScreen { + PokerBackTextButton(onClick = onBack) + + Spacer(modifier = Modifier.height(3.dp)) + + UsersHero() + + Spacer(modifier = Modifier.height(18.dp)) + + when { + !requestsLoaded || !membersLoaded -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + color = PokerPalette.CrimsonBright + ) + } + } + + errorText != null -> { + PokerPanel { + Text( + text = errorText!!, + color = PokerPalette.Danger, + fontSize = 14.sp + ) + } + } + + else -> { + UsersSectionTitle("ЗАЯВКИ НА ДОСТУП") + + Spacer(modifier = Modifier.height(10.dp)) + + if (requests.isEmpty()) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xF2070B09), + border = BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = RoundedCornerShape(15.dp) + ) { + Text( + text = + "Тишина. Новых желающих ворваться за стол нет.", + modifier = Modifier.padding(14.dp), + color = PokerPalette.TextSecondary, + fontSize = 12.sp + ) + } + } else { + requests.forEach { request -> + PendingRequestCard( + request = request, + members = members, + busy = actionUid == request.uid, + onApprove = { slot -> + approve(request, slot) + } + ) + + Spacer(modifier = Modifier.height(10.dp)) + } + } + + if (actionError != null) { + Text( + text = actionError!!, + color = PokerPalette.Danger, + fontSize = 12.sp + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + UsersSectionTitle("УЖЕ ЗА СТОЛОМ") + + Spacer(modifier = Modifier.height(10.dp)) + + if (members.isEmpty()) { + Text( + text = "Допущенных пользователей не найдено", + color = PokerPalette.TextSecondary, + fontSize = 12.sp + ) + } else { + members.forEach { member -> + ActiveMemberCard(member) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + PokerBottomBackButton(onClick = onBack) + } +} + +@Composable +private fun UsersHero() { + Card( + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xF2070908) + ), + border = BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = RoundedCornerShape(18.dp) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Image( + painter = painterResource( + id = R.drawable.gothic_users_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(170.dp) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = 16.dp, + top = 16.dp, + end = 125.dp, + bottom = 12.dp + ) + ) { + Text( + text = "ПОЛЬЗОВАТЕЛИ", + color = PokerPalette.TextPrimary, + fontFamily = PokerDisplayFont, + fontSize = 20.sp, + fontWeight = FontWeight.Black, + maxLines = 1 + ) + + Spacer(modifier = Modifier.height(5.dp)) + + Text( + text = "КТО ДОПУЩЕН\nК ЭТОМУ БАЛАГАНУ", + color = PokerPalette.CrimsonBright, + fontSize = 10.5.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "Админ решает, кто кем будет.", + color = PokerPalette.Gold, + fontSize = 9.5.sp + ) + } + } + } +} + +@Composable +private fun UsersSectionTitle( + text: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + color = PokerPalette.CrimsonBright, + fontSize = 11.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.width(9.dp)) + + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(PokerPalette.GoldDark) + ) + } +} + +@Composable +private fun PendingRequestCard( + request: PendingAccessRequest, + members: List, + busy: Boolean, + onApprove: (UserSlot) -> Unit +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xF2070B09), + border = BorderStroke( + 1.dp, + PokerPalette.Crimson + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier.padding(13.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource( + id = R.drawable.gothic_user_badge + ), + contentDescription = null, + modifier = Modifier.size(48.dp), + contentScale = ContentScale.Fit + ) + + Spacer(modifier = Modifier.width(9.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = request.name, + color = PokerPalette.TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = request.email, + color = PokerPalette.TextSecondary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + PokerBadge( + text = if (busy) "..." else "ЖДЁТ" + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "КЕМ ПУСКАЕМ:", + color = PokerPalette.Gold, + fontSize = 9.5.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.height(7.dp)) + + userSlots.chunked(2).forEach { row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(7.dp) + ) { + row.forEach { slot -> + val occupied = + members.any { + it.active && + it.playerId == slot.playerId + } + + Column( + modifier = Modifier.weight(1f) + ) { + PokerSecondaryButton( + text = + if (occupied) { + "${slot.name} ✓" + } else { + slot.name + }, + enabled = !busy && !occupied, + onClick = { + onApprove(slot) + } + ) + + Text( + text = + when (slot.role) { + "admin" -> "админ" + "scorekeeper" -> "счётовод" + else -> "наблюдатель" + }, + color = PokerPalette.TextSecondary, + fontSize = 8.5.sp, + modifier = Modifier.padding( + start = 4.dp, + top = 2.dp + ) + ) + } + } + } + + Spacer(modifier = Modifier.height(7.dp)) + } + } + } +} + +@Composable +private fun ActiveMemberCard( + member: ActiveMember +) { + val roleText = + when (member.role) { + "admin" -> "АДМИН" + "scorekeeper" -> "СЧЁТОВОД" + else -> "НАБЛЮДАТЕЛЬ" + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color(0xF2070B09), + border = BorderStroke( + 1.dp, + if (member.active) { + PokerPalette.GoldDark + } else { + PokerPalette.CrimsonDark + } + ), + shape = RoundedCornerShape(14.dp) + ) { + Row( + modifier = Modifier.padding( + horizontal = 13.dp, + vertical = 10.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = + when (member.playerId) { + "denis" -> "♠" + "dmitry" -> "♣" + "rybka" -> "♥" + "masha" -> "♦" + else -> "☠" + }, + color = + if ( + member.playerId == "rybka" || + member.playerId == "masha" + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.Gold + }, + fontSize = 25.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = member.name, + color = PokerPalette.TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + + Text( + text = member.playerId, + color = PokerPalette.TextSecondary, + fontSize = 9.sp + ) + } + + Text( + text = roleText, + color = + if (member.role == "admin") { + PokerPalette.CrimsonBright + } else { + PokerPalette.Gold + }, + fontSize = 9.5.sp, + fontWeight = FontWeight.Black + ) + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/AppConfig.kt b/app/src/main/java/ru/durakscore/app/AppConfig.kt new file mode 100644 index 0000000..a0bcd60 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/AppConfig.kt @@ -0,0 +1,3 @@ +package ru.durakscore.app + +const val TEST_MODE = false diff --git a/app/src/main/java/ru/durakscore/app/AuditLogScreen.kt b/app/src/main/java/ru/durakscore/app/AuditLogScreen.kt new file mode 100644 index 0000000..194a1cf --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/AuditLogScreen.kt @@ -0,0 +1,363 @@ +package ru.durakscore.app + +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Card +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.background +import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Date + +private data class AuditLogItem( + val id: String, + val type: String, + val eventAt: Timestamp?, + val actorName: String, + val title: String, + val details: List, + val isTest: Boolean = false +) + +@Composable +fun AuditLogScreen( + access: UserAccess, + onBack: () -> Unit +) { + var logs by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorText by remember { mutableStateOf(null) } + + val canView = + access.role == "admin" || + access.role == "scorekeeper" + + LaunchedEffect( + canView + ) { + + if ( + !canView + ) { + isLoading = + false + + errorText = + "Нет доступа к журналу действий" + + return@LaunchedEffect + } + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + isLoading = + false + + errorText = + "Пользователь не авторизован" + + return@LaunchedEffect + } + + DurakServerApi + .loadAuditLogs( + user = + user, + onSuccess = { + serverLogs -> + + logs = + serverLogs + .map { + log -> + + AuditLogItem( + id = + log.id, + type = + log.type, + eventAt = + log.eventAtEpochMillis + ?.let { + Timestamp( + Date( + it + ) + ) + }, + actorName = + log.actorName, + title = + log.title, + details = + log.details, + isTest = + log.isTest + ) + } + + errorText = + null + + isLoading = + false + }, + onError = { + message -> + + if ( + logs.isEmpty() + ) { + errorText = + message.ifBlank { + "Не удалось загрузить журнал действий" + } + } + + isLoading = + false + } + ) + } + + PokerScreen { + PokerBackTextButton(onClick = onBack) + + Spacer(modifier = Modifier.height(3.dp)) + + GothicAuditHeader() + + Spacer(modifier = Modifier.height(18.dp)) + + when { + isLoading -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(color = PokerPalette.Gold) + } + } + + errorText != null -> { + PokerPanel { + Text( + text = errorText!!, + color = PokerPalette.Danger, + fontSize = 17.sp + ) + } + } + + logs.isEmpty() -> { + PokerPanel { + Text( + text = "В журнале пока нет записей", + color = PokerPalette.TextPrimary, + fontSize = 18.sp + ) + } + } + + else -> { + logs.forEach { log -> + AuditPokerCard(log) + Spacer(modifier = Modifier.height(14.dp)) + } + } + } + } +} + +@Composable +private fun GothicAuditHeader() { + val fontScale = LocalDensity.current.fontScale + val heroMinHeight = + when { + fontScale >= 1.30f -> 150.dp + fontScale >= 1.15f -> 132.dp + else -> 112.dp + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight), + colors = + CardDefaults.cardColors( + containerColor = Color(0xF2070908) + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = RoundedCornerShape(16.dp) + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight) + ) { + Image( + painter = + painterResource( + id = R.drawable.gothic_audit_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .align(Alignment.CenterEnd) + .size( + width = 105.dp, + height = heroMinHeight + ) + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = 14.dp, + top = 14.dp, + end = + if (fontScale >= 1.25f) { + 62.dp + } else { + 95.dp + }, + bottom = 10.dp + ) + ) { + Text( + text = "ЖУРНАЛ ДЕЙСТВИЙ", + color = PokerPalette.TextPrimary, + fontFamily = PokerDisplayFont, + fontSize = + if (fontScale >= 1.25f) { + 16.sp + } else { + 18.sp + }, + lineHeight = 22.sp, + fontWeight = FontWeight.Black, + maxLines = 3, + softWrap = true + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Box( + modifier = + Modifier + .width(48.dp) + .height(2.dp) + .background(PokerPalette.CrimsonBright) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = "Изменения счёта и служебные события", + color = PokerPalette.TextSecondary, + fontSize = 10.sp, + lineHeight = 13.sp, + maxLines = 4, + softWrap = true + ) + } + } + } +} + +@Composable +private fun AuditPokerCard( + log: AuditLogItem +) { + PokerPanel { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = log.title, + color = PokerPalette.TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + + if (log.isTest) { + PokerBadge(text = "ТЕСТ") + } + } + + if (log.eventAt != null) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = formatMoscowDateTime(log.eventAt), + color = PokerPalette.TextSecondary, + fontSize = 13.sp + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + + log.details.forEach { line -> + Text( + text = line, + color = PokerPalette.TextPrimary, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(5.dp)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Выполнил: ${log.actorName}", + color = PokerPalette.TextSecondary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/AuthScreen.kt b/app/src/main/java/ru/durakscore/app/AuthScreen.kt new file mode 100644 index 0000000..41b2a8d --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/AuthScreen.kt @@ -0,0 +1,781 @@ +package ru.durakscore.app + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + + +@Composable +fun AuthScreen( + onAuthSuccess: (DurakSession) -> Unit +) { + + val focusManager = + LocalFocusManager.current + + var isRegisterMode by remember { + mutableStateOf(false) + } + + var name by remember { + mutableStateOf("") + } + + var email by remember { + mutableStateOf("") + } + + var password by remember { + mutableStateOf("") + } + + var confirmPassword by remember { + mutableStateOf("") + } + + var showPassword by remember { + mutableStateOf(false) + } + + var isLoading by remember { + mutableStateOf(false) + } + + var errorText by remember { + mutableStateOf(null) + } + + + fun clearError() { + errorText = + null + } + + + fun login() { + + if ( + isLoading + ) { + return + } + + val cleanEmail = + email.trim() + + if ( + cleanEmail.isBlank() + ) { + errorText = + "Введите email" + + return + } + + if ( + password.isBlank() + ) { + errorText = + "Введите пароль" + + return + } + + focusManager + .clearFocus() + + isLoading = + true + + errorText = + null + + DurakServerApi.login( + email = + cleanEmail, + password = + password, + onSuccess = { + session -> + + isLoading = + false + + onAuthSuccess( + session + ) + }, + onError = { + message -> + + isLoading = + false + + errorText = + message.ifBlank { + "Не удалось выполнить вход" + } + } + ) + } + + + fun register() { + + if ( + isLoading + ) { + return + } + + val cleanName = + name.trim() + + val cleanEmail = + email.trim() + + when { + + cleanName.isBlank() -> { + + errorText = + "Введите имя" + + return + } + + cleanEmail.isBlank() -> { + + errorText = + "Введите email" + + return + } + + password.length < 8 -> { + + errorText = + "Пароль должен содержать минимум 8 символов" + + return + } + + password != + confirmPassword -> { + + errorText = + "Пароли не совпадают" + + return + } + } + + focusManager + .clearFocus() + + isLoading = + true + + errorText = + null + + DurakServerApi.register( + name = + cleanName, + email = + cleanEmail, + password = + password, + onSuccess = { + session -> + + isLoading = + false + + onAuthSuccess( + session + ) + }, + onError = { + message -> + + isLoading = + false + + errorText = + message.ifBlank { + "Не удалось создать аккаунт" + } + } + ) + } + + + PokerScreen { + + Spacer( + modifier = + Modifier.height( + 34.dp + ) + ) + + Text( + text = + "♠ ♥ ♦ ♣", + color = + PokerPalette.Gold, + fontSize = + 20.sp, + fontWeight = + FontWeight.Bold, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + Text( + text = + "ДУРАК", + color = + PokerPalette.TextPrimary, + fontSize = + 48.sp, + fontWeight = + FontWeight.ExtraBold, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = + Modifier.height( + 6.dp + ) + ) + + Text( + text = + if ( + isRegisterMode + ) { + "Регистрация за столом" + } else { + "Вход за игровой стол" + }, + color = + PokerPalette.TextSecondary, + fontSize = + 18.sp, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = + Modifier.height( + 28.dp + ) + ) + + PokerPanel { + + Text( + text = + if ( + isRegisterMode + ) { + "Создать аккаунт" + } else { + "Войти" + }, + color = + PokerPalette.Gold, + fontSize = + 22.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + if ( + isRegisterMode + ) { + + PokerAuthTextField( + value = + name, + onValueChange = { + name = + it + clearError() + }, + label = + "Имя", + keyboardType = + KeyboardType.Text, + imeAction = + ImeAction.Next, + onNext = { + focusManager + .moveFocus( + FocusDirection.Down + ) + } + ) + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + } + + PokerAuthTextField( + value = + email, + onValueChange = { + email = + it + clearError() + }, + label = + "Email", + keyboardType = + KeyboardType.Email, + imeAction = + ImeAction.Next, + onNext = { + focusManager + .moveFocus( + FocusDirection.Down + ) + } + ) + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + PokerAuthTextField( + value = + password, + onValueChange = { + password = + it + clearError() + }, + label = + "Пароль", + keyboardType = + KeyboardType.Password, + imeAction = + if ( + isRegisterMode + ) { + ImeAction.Next + } else { + ImeAction.Done + }, + isPassword = + true, + showPassword = + showPassword, + onNext = { + if ( + isRegisterMode + ) { + focusManager + .moveFocus( + FocusDirection.Down + ) + } else { + login() + } + } + ) + + if ( + isRegisterMode + ) { + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + PokerAuthTextField( + value = + confirmPassword, + onValueChange = { + confirmPassword = + it + clearError() + }, + label = + "Повторите пароль", + keyboardType = + KeyboardType.Password, + imeAction = + ImeAction.Done, + isPassword = + true, + showPassword = + showPassword, + onNext = { + register() + } + ) + } + + TextButton( + onClick = { + showPassword = + !showPassword + }, + enabled = + !isLoading + ) { + + Text( + text = + if ( + showPassword + ) { + "Скрыть пароль" + } else { + "Показать пароль" + }, + color = + PokerPalette.TextSecondary + ) + } + + if ( + errorText != + null + ) { + + Spacer( + modifier = + Modifier.height( + 6.dp + ) + ) + + PokerAuthError( + text = + errorText!! + ) + } + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + if ( + isLoading + ) { + + CircularProgressIndicator( + color = + PokerPalette.Gold, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + Text( + text = + if ( + isRegisterMode + ) { + "Создаю аккаунт..." + } else { + "Выполняю вход..." + }, + color = + PokerPalette.TextSecondary, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + } else { + + PokerPrimaryButton( + text = + if ( + isRegisterMode + ) { + "Зарегистрироваться" + } else { + "Войти" + }, + onClick = { + if ( + isRegisterMode + ) { + register() + } else { + login() + } + } + ) + } + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + PokerSecondaryButton( + text = + if ( + isRegisterMode + ) { + "У меня уже есть аккаунт" + } else { + "Нет аккаунта? Регистрация" + }, + onClick = { + + if ( + !isLoading + ) { + + isRegisterMode = + !isRegisterMode + + errorText = + null + + password = + "" + + confirmPassword = + "" + + showPassword = + false + } + }, + enabled = + !isLoading + ) + } + + Spacer( + modifier = + Modifier.height( + 22.dp + ) + ) + + Text( + text = + if ( + isRegisterMode + ) { + "После регистрации администратор должен подтвердить доступ." + } else { + "Доступ только для участников игры." + }, + color = + PokerPalette.TextSecondary, + fontSize = + 14.sp, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = + Modifier.height( + 24.dp + ) + ) + } +} + + +@Composable +private fun PokerAuthTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + keyboardType: KeyboardType, + imeAction: ImeAction, + onNext: () -> Unit, + isPassword: Boolean = false, + showPassword: Boolean = false +) { + + OutlinedTextField( + value = + value, + onValueChange = + onValueChange, + modifier = + Modifier.fillMaxWidth(), + label = { + Text( + label + ) + }, + singleLine = + true, + shape = + RoundedCornerShape( + 16.dp + ), + keyboardOptions = + KeyboardOptions( + keyboardType = + keyboardType, + imeAction = + imeAction + ), + keyboardActions = + KeyboardActions( + onNext = { + onNext() + }, + onDone = { + onNext() + } + ), + visualTransformation = + if ( + isPassword && + !showPassword + ) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedTextColor = + PokerPalette.TextPrimary, + unfocusedTextColor = + PokerPalette.TextPrimary, + focusedBorderColor = + PokerPalette.Gold, + unfocusedBorderColor = + PokerPalette.GoldDark, + focusedLabelColor = + PokerPalette.Gold, + unfocusedLabelColor = + PokerPalette.TextSecondary, + cursorColor = + PokerPalette.Gold, + focusedContainerColor = + Color.Transparent, + unfocusedContainerColor = + Color.Transparent + ) + ) +} + + +@Composable +private fun PokerAuthError( + text: String +) { + + PokerPanel { + + Text( + text = + "Ошибка", + color = + PokerPalette.Danger, + fontWeight = + FontWeight.Bold, + fontSize = + 16.sp + ) + + Spacer( + modifier = + Modifier.height( + 5.dp + ) + ) + + Text( + text = + text, + color = + PokerPalette.TextPrimary, + fontSize = + 15.sp + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/AuthSession.kt b/app/src/main/java/ru/durakscore/app/AuthSession.kt new file mode 100644 index 0000000..c503a0c --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/AuthSession.kt @@ -0,0 +1,436 @@ +package ru.durakscore.app + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.nio.ByteBuffer +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + + +data class DurakSession( + val uid: String, + val name: String, + val email: String, + val token: String, + val expiresAtEpochMillis: Long +) + + +object DurakAuthSession { + + private const val PREFS_NAME = + "durakometr_auth_v1" + + private const val KEY_ALIAS = + "durakometr_session_key_v1" + + private const val PREF_UID = + "uid" + + private const val PREF_NAME = + "name" + + private const val PREF_EMAIL = + "email" + + private const val PREF_TOKEN = + "token_enc" + + private const val PREF_EXPIRES = + "expires" + + @Volatile + private var cached: + DurakSession? = + null + + private lateinit var appContext: + Context + + + fun initialize( + context: Context + ) { + + appContext = + context.applicationContext + + cached = + loadFromDisk() + } + + + fun current(): + DurakSession? { + + val session = + cached + + if ( + session != null && + session.expiresAtEpochMillis > + System.currentTimeMillis() + ) { + return session + } + + if ( + session != null + ) { + clear() + } + + return null + } + + + fun save( + session: DurakSession + ) { + + check( + ::appContext.isInitialized + ) { + "DurakAuthSession.initialize() must be called first" + } + + val encryptedToken = + encrypt( + session.token + ) + + appContext + .getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + .edit() + .putString( + PREF_UID, + session.uid + ) + .putString( + PREF_NAME, + session.name + ) + .putString( + PREF_EMAIL, + session.email + ) + .putString( + PREF_TOKEN, + encryptedToken + ) + .putLong( + PREF_EXPIRES, + session.expiresAtEpochMillis + ) + .apply() + + cached = + session + } + + + fun clear() { + + if ( + ::appContext.isInitialized + ) { + appContext + .getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + .edit() + .clear() + .apply() + } + + cached = + null + } + + + private fun loadFromDisk(): + DurakSession? { + + return try { + + val prefs = + appContext + .getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + val uid = + prefs + .getString( + PREF_UID, + null + ) + ?.trim() + .orEmpty() + + val name = + prefs + .getString( + PREF_NAME, + null + ) + ?.trim() + .orEmpty() + + val email = + prefs + .getString( + PREF_EMAIL, + null + ) + ?.trim() + .orEmpty() + + val encryptedToken = + prefs + .getString( + PREF_TOKEN, + null + ) + .orEmpty() + + val expires = + prefs + .getLong( + PREF_EXPIRES, + 0L + ) + + if ( + uid.isBlank() || + encryptedToken.isBlank() || + expires <= + System.currentTimeMillis() + ) { + return null + } + + val token = + decrypt( + encryptedToken + ) + + if ( + token.isBlank() + ) { + return null + } + + DurakSession( + uid = + uid, + name = + name, + email = + email, + token = + token, + expiresAtEpochMillis = + expires + ) + + } catch ( + error: Exception + ) { + + appContext + .getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + .edit() + .clear() + .apply() + + null + } + } + + + private fun key(): + SecretKey { + + val keyStore = + KeyStore + .getInstance( + "AndroidKeyStore" + ) + .apply { + load( + null + ) + } + + val existing = + keyStore + .getKey( + KEY_ALIAS, + null + ) + as? SecretKey + + if ( + existing != null + ) { + return existing + } + + val generator = + KeyGenerator + .getInstance( + KeyProperties + .KEY_ALGORITHM_AES, + "AndroidKeyStore" + ) + + generator.init( + KeyGenParameterSpec + .Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or + KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes( + KeyProperties + .BLOCK_MODE_GCM + ) + .setEncryptionPaddings( + KeyProperties + .ENCRYPTION_PADDING_NONE + ) + .build() + ) + + return generator + .generateKey() + } + + + private fun encrypt( + value: String + ): String { + + val cipher = + Cipher.getInstance( + "AES/GCM/NoPadding" + ) + + cipher.init( + Cipher.ENCRYPT_MODE, + key() + ) + + val encrypted = + cipher.doFinal( + value.toByteArray( + Charsets.UTF_8 + ) + ) + + val iv = + cipher.iv + + val packed = + ByteBuffer + .allocate( + 4 + + iv.size + + encrypted.size + ) + .putInt( + iv.size + ) + .put( + iv + ) + .put( + encrypted + ) + .array() + + return Base64.encodeToString( + packed, + Base64.NO_WRAP + ) + } + + + private fun decrypt( + packedValue: String + ): String { + + val packed = + Base64.decode( + packedValue, + Base64.NO_WRAP + ) + + val buffer = + ByteBuffer.wrap( + packed + ) + + val ivLength = + buffer.int + + require( + ivLength in + 12..32 + ) + + val iv = + ByteArray( + ivLength + ) + + buffer.get( + iv + ) + + val encrypted = + ByteArray( + buffer.remaining() + ) + + buffer.get( + encrypted + ) + + val cipher = + Cipher.getInstance( + "AES/GCM/NoPadding" + ) + + cipher.init( + Cipher.DECRYPT_MODE, + key(), + GCMParameterSpec( + 128, + iv + ) + ) + + return cipher + .doFinal( + encrypted + ) + .toString( + Charsets.UTF_8 + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/DataAuditScreen.kt b/app/src/main/java/ru/durakscore/app/DataAuditScreen.kt new file mode 100644 index 0000000..2488d61 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/DataAuditScreen.kt @@ -0,0 +1,1249 @@ +package ru.durakscore.app + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Date +import java.util.Locale +import kotlin.math.max + +private val auditCorePlayers = + listOf( + "dmitry" to "Дмитрий", + "denis" to "Денис", + "rybka" to "Рыбка", + "masha" to "Маша" + ) + +private enum class AuditSeverity { + OK, + WARNING, + ERROR +} + +private data class AuditIssue( + val severity: AuditSeverity, + val title: String, + val details: String? = null +) + +private data class AuditGame( + val id: String, + val type: String, + val isTest: Boolean, + val affectsMainScore: Boolean, + val mainScoreApplied: Boolean, + val targetScore: Int, + val playerIds: List, + val finalScores: Map, + val roundHistory: List, + val loserId: String?, + val finishedAt: Timestamp?, + val hasFinalScores: Boolean +) + +private data class AuditLogEvent( + val id: String, + val type: String, + val playerId: String?, + val oldValue: Int?, + val newValue: Int?, + val gameId: String?, + val values: Map, + val eventAt: Timestamp? +) + +private data class AuditAchievement( + val title: String, + val unlocked: Boolean, + val progress: String +) + +private data class AuditPlayerStats( + val id: String, + val name: String, + val gamesPlayed: Int, + val appLosses: Int, + val currentMainScore: Int?, + val expectedMainScoreFromAudit: Int?, + val totalPoints: Int, + val bestNoPointStreak: Int, + val maxLossStreak: Int, + val cleanGames: Int, + val nearEscapeGames: Int, + val bestSurvivedScore: Int, + val bestSurvivedTarget: Int, + val achievements: List +) + +private data class DataAuditReport( + val finishedGames: Int, + val classicGamesCurrentMode: Int, + val productionClassicGames: Int, + val customGames: Int, + val testGames: Int, + val currentMainScores: Map, + val expectedMainScores: Map, + val players: List, + val issues: List, + val checkedGames: Int, + val leaderText: String, + val worstText: String, + val longestLossStreakText: String, + val bestRateText: String +) { + val errorCount: Int + get() = issues.count { it.severity == AuditSeverity.ERROR } + + val warningCount: Int + get() = issues.count { it.severity == AuditSeverity.WARNING } +} + +@Composable +fun DataAuditScreen( + access: UserAccess, + onBack: () -> Unit +) { + BackHandler(onBack = onBack) + + var reloadKey by remember { mutableIntStateOf(0) } + var isLoading by remember { mutableStateOf(true) } + var errorText by remember { mutableStateOf(null) } + var report by remember { mutableStateOf(null) } + + LaunchedEffect(reloadKey) { + isLoading = true + errorText = null + + loadDataAudit( + onSuccess = { loadedReport -> + report = loadedReport + isLoading = false + }, + onError = { message -> + errorText = message + isLoading = false + } + ) + } + + PokerScreen { + PokerBackTextButton( + text = "← Назад", + onClick = onBack + ) + + Spacer(modifier = Modifier.height(4.dp)) + + PokerHeader( + title = "Проверка данных", + subtitle = "Независимый пересчёт PostgreSQL • только чтение" + ) + + Spacer(modifier = Modifier.height(14.dp)) + + if (access.role != "admin") { + PokerPanel { + Text( + text = "Этот экран доступен только администратору.", + color = PokerPalette.Danger, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + PokerBottomBackButton(onClick = onBack) + return@PokerScreen + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = PokerPalette.Panel, + shape = androidx.compose.foundation.shape.RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, PokerPalette.GoldDark) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = if (TEST_MODE) "РЕЖИМ: TEST" else "РЕЖИМ: БОЕВОЙ", + color = PokerPalette.Gold, + fontSize = 14.sp, + fontWeight = FontWeight.Black + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Экран ничего не исправляет и не записывает. Он только сверяет партии, общий счёт, журнал и условия достижений в PostgreSQL.", + color = PokerPalette.TextSecondary, + fontSize = 13.sp, + lineHeight = 17.sp + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + when { + isLoading -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = PokerPalette.Gold) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Пересчитываю всю статистику...", + color = PokerPalette.TextSecondary, + fontSize = 14.sp, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + + errorText != null -> { + PokerPanel { + Text( + text = "Не удалось выполнить проверку", + color = PokerPalette.Danger, + fontSize = 17.sp, + fontWeight = FontWeight.Black + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = errorText!!, + color = PokerPalette.TextSecondary, + fontSize = 14.sp + ) + } + } + + report != null -> { + val value = report!! + + AuditStatusCard(value) + Spacer(modifier = Modifier.height(12.dp)) + + AuditSectionTitle("ПАРТИИ") + Spacer(modifier = Modifier.height(8.dp)) + AuditGamesSummary(value) + + Spacer(modifier = Modifier.height(14.dp)) + AuditSectionTitle("ОБЩИЙ СЧЁТ") + Spacer(modifier = Modifier.height(8.dp)) + AuditMainScoreSummary(value) + + Spacer(modifier = Modifier.height(14.dp)) + AuditSectionTitle("СТАТИСТИКА ЭКРАНА «ОБЩИЙ СЧЁТ»") + Spacer(modifier = Modifier.height(8.dp)) + PokerPanel { + AuditValueLine("Лидер", value.leaderText) + AuditValueLine("Больше поражений", value.worstText) + AuditValueLine("Самая длинная серия", value.longestLossStreakText) + AuditValueLine("Самая стабильная игра", value.bestRateText) + } + + Spacer(modifier = Modifier.height(14.dp)) + AuditSectionTitle("ИГРОКИ И ДОСТИЖЕНИЯ") + Spacer(modifier = Modifier.height(8.dp)) + + value.players.forEach { player -> + AuditPlayerCard(player) + Spacer(modifier = Modifier.height(10.dp)) + } + + Spacer(modifier = Modifier.height(4.dp)) + AuditSectionTitle("НАЙДЕННЫЕ РАСХОЖДЕНИЯ") + Spacer(modifier = Modifier.height(8.dp)) + + val visibleIssues = + value.issues.filter { + it.severity != AuditSeverity.OK + } + + if (visibleIssues.isEmpty()) { + PokerPanel { + Text( + text = "✓ Расхождений не найдено", + color = PokerPalette.Good, + fontSize = 16.sp, + fontWeight = FontWeight.Black + ) + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = "Проверено завершённых партий: ${value.checkedGames}", + color = PokerPalette.TextSecondary, + fontSize = 13.sp + ) + } + } else { + visibleIssues.forEach { issue -> + AuditIssueCard(issue) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + PokerSecondaryButton( + text = "↻ Проверить заново", + onClick = { + reloadKey++ + } + ) + + Spacer(modifier = Modifier.height(10.dp)) + PokerBottomBackButton(onClick = onBack) + } +} + +@Composable +private fun AuditStatusCard(report: DataAuditReport) { + val allGood = report.errorCount == 0 && report.warningCount == 0 + val title = + when { + report.errorCount > 0 -> "✕ ЕСТЬ РАСХОЖДЕНИЯ" + report.warningCount > 0 -> "! ЕСТЬ ПРЕДУПРЕЖДЕНИЯ" + else -> "✓ ВСЁ СХОДИТСЯ" + } + + val accent = + when { + report.errorCount > 0 -> PokerPalette.Danger + report.warningCount > 0 -> PokerPalette.Gold + else -> PokerPalette.Good + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = PokerPalette.Panel, + shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp), + border = BorderStroke(if (allGood) 1.dp else 1.5.dp, accent) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + color = accent, + fontSize = 18.sp, + fontWeight = FontWeight.Black + ) + Spacer(modifier = Modifier.height(7.dp)) + Text( + text = "Ошибок: ${report.errorCount} • предупреждений: ${report.warningCount}", + color = PokerPalette.TextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "Проверено finished-партий: ${report.checkedGames}", + color = PokerPalette.TextSecondary, + fontSize = 13.sp + ) + } + } +} + +@Composable +private fun AuditGamesSummary(report: DataAuditReport) { + PokerPanel { + AuditValueLine("Всего finished", report.finishedGames.toString()) + AuditValueLine( + if (TEST_MODE) "Classic в текущем TEST_MODE" else "Classic в приложении", + report.classicGamesCurrentMode.toString() + ) + AuditValueLine("Боевых classic", report.productionClassicGames.toString()) + AuditValueLine("Custom", report.customGames.toString()) + AuditValueLine("Тестовых finished", report.testGames.toString()) + } +} + +@Composable +private fun AuditMainScoreSummary(report: DataAuditReport) { + PokerPanel { + auditCorePlayers.forEach { (id, fallbackName) -> + val current = report.currentMainScores[id] + val expected = report.expectedMainScores[id] + val matches = current != null && expected != null && current == expected + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = fallbackName, + color = PokerPalette.TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "auditLogs ожидание: ${expected?.toString() ?: "—"}", + color = PokerPalette.TextSecondary, + fontSize = 12.sp + ) + } + + Text( + text = "${current?.toString() ?: "—"} ${if (matches) "✓" else "!"}", + color = if (matches) PokerPalette.Good else PokerPalette.Danger, + fontSize = 17.sp, + fontWeight = FontWeight.Black + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun AuditPlayerCard(player: AuditPlayerStats) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = PokerPalette.Panel, + shape = androidx.compose.foundation.shape.RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, PokerPalette.GoldDark) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + text = player.name, + color = PokerPalette.Gold, + fontSize = 19.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.height(8.dp)) + + AuditValueLine("Classic-партий", player.gamesPlayed.toString()) + AuditValueLine("Поражений в приложении", player.appLosses.toString()) + AuditValueLine("Общий /scores", player.currentMainScore?.toString() ?: "—") + AuditValueLine("Ожидание по auditLogs", player.expectedMainScoreFromAudit?.toString() ?: "—") + AuditValueLine("Всего получено +1", player.totalPoints.toString()) + AuditValueLine("Макс. раздач без +1", player.bestNoPointStreak.toString()) + AuditValueLine("Макс. поражений подряд", player.maxLossStreak.toString()) + AuditValueLine("Сухих каток", player.cleanGames.toString()) + AuditValueLine("Финишей на target−1", player.nearEscapeGames.toString()) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "ДОСТИЖЕНИЯ", + color = PokerPalette.CrimsonBright, + fontSize = 12.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.height(5.dp)) + + player.achievements.forEach { achievement -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top + ) { + Text( + text = if (achievement.unlocked) "✓" else "○", + color = if (achievement.unlocked) PokerPalette.Good else PokerPalette.TextSecondary, + fontSize = 14.sp, + fontWeight = FontWeight.Black + ) + Text( + text = "${achievement.title} — ${achievement.progress}", + color = if (achievement.unlocked) PokerPalette.TextPrimary else PokerPalette.TextSecondary, + fontSize = 13.sp, + lineHeight = 17.sp, + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + } + } + } +} + +@Composable +private fun AuditIssueCard(issue: AuditIssue) { + val color = + when (issue.severity) { + AuditSeverity.ERROR -> PokerPalette.Danger + AuditSeverity.WARNING -> PokerPalette.Gold + AuditSeverity.OK -> PokerPalette.Good + } + + val prefix = + when (issue.severity) { + AuditSeverity.ERROR -> "✕" + AuditSeverity.WARNING -> "!" + AuditSeverity.OK -> "✓" + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = PokerPalette.Panel, + shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, color) + ) { + Column(modifier = Modifier.padding(13.dp)) { + Text( + text = "$prefix ${issue.title}", + color = color, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp + ) + if (!issue.details.isNullOrBlank()) { + Spacer(modifier = Modifier.height(5.dp)) + Text( + text = issue.details, + color = PokerPalette.TextSecondary, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + } + } +} + +@Composable +private fun AuditSectionTitle(text: String) { + Text( + text = text, + color = PokerPalette.CrimsonBright, + fontSize = 13.sp, + fontWeight = FontWeight.Black + ) +} + +@Composable +private fun AuditValueLine( + label: String, + value: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = label, + color = PokerPalette.TextSecondary, + fontSize = 13.sp, + lineHeight = 17.sp, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.padding(horizontal = 4.dp)) + Text( + text = value, + color = PokerPalette.TextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + lineHeight = 17.sp + ) + } + Spacer(modifier = Modifier.height(5.dp)) +} + +private fun loadDataAudit( + onSuccess: (DataAuditReport) -> Unit, + onError: (String) -> Unit +) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + onError( + "Пользователь не авторизован" + ) + return + } + + DurakServerApi + .loadDataAuditSource( + user = + user, + onSuccess = { + source -> + + runCatching { + buildDataAuditReport( + source + ) + } + .onSuccess( + onSuccess + ) + .onFailure { + error -> + + onError( + error.message + ?: "Ошибка пересчёта данных" + ) + } + }, + onError = + onError + ) +} + +private fun buildDataAuditReport( + source: ServerDataAuditSource +): DataAuditReport { + val issues = mutableListOf() + + val playerNames = + source.players.associate { + it.id to + it.name.ifBlank { + it.id + } + } + + val currentScores = + source.scores + + auditCorePlayers.forEach { (id, fallbackName) -> + if (!playerNames.containsKey(id)) { + issues += AuditIssue( + AuditSeverity.ERROR, + "В /players отсутствует $fallbackName ($id)" + ) + } + if (!currentScores.containsKey(id)) { + issues += AuditIssue( + AuditSeverity.ERROR, + "В /scores отсутствует $fallbackName ($id)" + ) + } + } + + val games = + source.games.map { + parseAuditGame( + it + ) + } + + val auditEvents = + source.auditEvents.map { + parseAuditLogEvent( + it + ) + } + + games.forEach { game -> + validateGame(game, auditEvents, issues) + } + + val expectedScores = rebuildMainScoresFromAudit(auditEvents, issues) + + auditCorePlayers.forEach { (id, fallbackName) -> + val current = currentScores[id] + val expected = expectedScores[id] + + if (expected == null) { + issues += AuditIssue( + AuditSeverity.ERROR, + "Не удалось восстановить общий счёт $fallbackName по auditLogs", + "Нет достаточной цепочки initial_score_import / корректировок / game_result." + ) + } else if (current != expected) { + issues += AuditIssue( + AuditSeverity.ERROR, + "Общий счёт $fallbackName не сходится", + "/scores = ${current ?: "—"}, по auditLogs должно быть $expected" + ) + } + } + + val gameResultEvents = auditEvents.filter { it.type == "game_result" } + val productionGamesById = + games.filter { it.affectsMainScore }.associateBy { it.id } + + gameResultEvents.forEach { event -> + val gameId = event.gameId + if (gameId.isNullOrBlank()) { + issues += AuditIssue( + AuditSeverity.ERROR, + "game_result ${event.id} без gameId" + ) + } else if (!productionGamesById.containsKey(gameId)) { + issues += AuditIssue( + AuditSeverity.WARNING, + "game_result ссылается на неизвестную боевую партию", + "Лог ${event.id}, gameId=$gameId" + ) + } + } + + val modeClassicGames = + games + .filter { + it.type == "classic" && + it.isTest == TEST_MODE + } + .sortedBy { it.finishedAt?.seconds ?: 0L } + + val players = + auditCorePlayers.map { (id, fallbackName) -> + calculateAuditPlayerStats( + games = modeClassicGames, + playerId = id, + playerName = playerNames[id] ?: fallbackName, + currentMainScore = currentScores[id], + expectedMainScore = expectedScores[id], + issues = issues + ) + } + + val sortedMainPlayers = + auditCorePlayers + .mapNotNull { (id, fallbackName) -> + currentScores[id]?.let { score -> + Triple(id, playerNames[id] ?: fallbackName, score) + } + } + + val minLosses = sortedMainPlayers.minOfOrNull { it.third } + val maxLosses = sortedMainPlayers.maxOfOrNull { it.third } + + val leaderText = + if (minLosses == null) { + "—" + } else { + val names = sortedMainPlayers.filter { it.third == minLosses }.joinToString(" и ") { it.second } + "$names — $minLosses" + } + + val worstText = + if (maxLosses == null) { + "—" + } else { + val names = sortedMainPlayers.filter { it.third == maxLosses }.joinToString(" и ") { it.second } + "$names — $maxLosses" + } + + val longest = players.maxByOrNull { it.maxLossStreak } + val longestLossStreakText = + if (longest == null || longest.maxLossStreak <= 0) { + "—" + } else { + "${longest.name} — ${longest.maxLossStreak} подряд" + } + + val bestRate = + if (modeClassicGames.isEmpty()) { + null + } else { + players.minByOrNull { + it.appLosses.toDouble() / modeClassicGames.size.toDouble() + } + } + + val bestRateText = + if (bestRate == null || modeClassicGames.isEmpty()) { + "—" + } else { + val rate = bestRate.appLosses.toDouble() / modeClassicGames.size.toDouble() + "${bestRate.name} — ${String.format(Locale.US, "%.2f", rate)}" + } + + return DataAuditReport( + finishedGames = games.size, + classicGamesCurrentMode = modeClassicGames.size, + productionClassicGames = games.count { it.type == "classic" && !it.isTest }, + customGames = games.count { it.type != "classic" }, + testGames = games.count { it.isTest }, + currentMainScores = currentScores, + expectedMainScores = expectedScores, + players = players, + issues = issues, + checkedGames = games.size, + leaderText = leaderText, + worstText = worstText, + longestLossStreakText = longestLossStreakText, + bestRateText = bestRateText + ) +} + +private fun parseAuditGame( + game: ServerDataAuditGame +): AuditGame { + + return AuditGame( + id = + game.id, + type = + game.gameType, + isTest = + game.isTest, + affectsMainScore = + game.affectsMainScore, + mainScoreApplied = + game.mainScoreApplied, + targetScore = + game.targetScore, + playerIds = + game.playerIds, + finalScores = + game.finalScores, + roundHistory = + game.roundHistory, + loserId = + game.loserId, + finishedAt = + game.finishedAtEpochMillis + ?.let { + Timestamp( + Date( + it + ) + ) + }, + hasFinalScores = + game.hasFinalScores + ) +} + + +private fun parseAuditLogEvent( + event: ServerDataAuditEvent +): AuditLogEvent { + + return AuditLogEvent( + id = + event.id, + type = + event.type, + playerId = + event.playerId, + oldValue = + event.oldValue, + newValue = + event.newValue, + gameId = + event.gameId, + values = + event.values, + eventAt = + event.eventAtEpochMillis + ?.let { + Timestamp( + Date( + it + ) + ) + } + ) +} + + +private fun validateGame( + game: AuditGame, + auditEvents: List, + issues: MutableList +) { + val prefix = "Партия ${shortId(game.id)}" + + if (game.finishedAt == null) { + issues += AuditIssue(AuditSeverity.ERROR, "$prefix: нет finishedAt") + } + + if (game.targetScore <= 0) { + issues += AuditIssue(AuditSeverity.ERROR, "$prefix: некорректный targetScore=${game.targetScore}") + } + + if (game.playerIds.isEmpty()) { + issues += AuditIssue(AuditSeverity.ERROR, "$prefix: пустой список игроков") + } + + if (!game.hasFinalScores) { + issues += AuditIssue( + AuditSeverity.WARNING, + "$prefix: нет finalScores", + "Для проверки использовано поле scores." + ) + } + + val expectedAffectsMainScore = game.type == "classic" && !game.isTest + if (game.affectsMainScore != expectedAffectsMainScore) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: affectsMainScore=${game.affectsMainScore}", + "Для type=${game.type}, isTest=${game.isTest} ожидалось $expectedAffectsMainScore." + ) + } + + if (game.mainScoreApplied != game.affectsMainScore) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: mainScoreApplied=${game.mainScoreApplied}", + "affectsMainScore=${game.affectsMainScore}" + ) + } + + if (game.type == "classic") { + val expectedIds = auditCorePlayers.map { it.first }.toSet() + val actualIds = game.playerIds.toSet() + if (actualIds != expectedIds || game.playerIds.size != 4) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: неверный состав classic", + "Получено: ${game.playerIds.joinToString()}, ожидалось: ${expectedIds.joinToString()}" + ) + } + } + + val unknownHistoryIds = game.roundHistory.filter { it !in game.playerIds }.distinct() + if (unknownHistoryIds.isNotEmpty()) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: roundHistory содержит неизвестных игроков", + unknownHistoryIds.joinToString() + ) + } + + val historyCounts = game.roundHistory.groupingBy { it }.eachCount() + + game.playerIds.forEach { playerId -> + val finalScore = game.finalScores[playerId] + val historyScore = historyCounts[playerId] ?: 0 + if (finalScore == null) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: в finalScores нет $playerId" + ) + } else if (finalScore != historyScore) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: счёт $playerId не сходится", + "finalScores=$finalScore, roundHistory=$historyScore" + ) + } + } + + val sumScores = game.finalScores.values.sum() + if (sumScores != game.roundHistory.size) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: сумма очков не равна roundHistory", + "sum(finalScores)=$sumScores, roundHistory=${game.roundHistory.size}" + ) + } + + val loserId = game.loserId + if (loserId.isNullOrBlank()) { + issues += AuditIssue(AuditSeverity.ERROR, "$prefix: нет loserId") + } else if (loserId !in game.playerIds) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: loserId отсутствует среди игроков", + "loserId=$loserId" + ) + } else { + val loserScore = game.finalScores[loserId] ?: 0 + if (loserScore < game.targetScore) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: проигравший не достиг лимита", + "$loserId: $loserScore/${game.targetScore}" + ) + } else if (loserScore > game.targetScore) { + issues += AuditIssue( + AuditSeverity.WARNING, + "$prefix: проигравший выше лимита", + "$loserId: $loserScore/${game.targetScore}. Обычно финиш должен быть ровно на лимите." + ) + } + } + + val gameResultLogs = + auditEvents.filter { + it.type == "game_result" && it.gameId == game.id + } + + if (game.affectsMainScore && gameResultLogs.size != 1) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: неверное число game_result логов", + "Найдено ${gameResultLogs.size}, ожидался ровно 1." + ) + } + + if (!game.affectsMainScore && gameResultLogs.isNotEmpty()) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: не должна менять общий счёт", + "Но найдено game_result логов: ${gameResultLogs.size}." + ) + } + + if (game.affectsMainScore && gameResultLogs.size == 1 && loserId != null) { + val log = gameResultLogs.first() + if (log.playerId != loserId) { + issues += AuditIssue( + AuditSeverity.ERROR, + "$prefix: loserId не совпадает с game_result", + "game loser=$loserId, log player=${log.playerId ?: "—"}" + ) + } + } +} + +private fun rebuildMainScoresFromAudit( + events: List, + issues: MutableList +): Map { + val important = + events.filter { + it.type == "initial_score_import" || + it.type == "manual_score_correction" || + it.type == "game_result" + } + + important.filter { it.eventAt == null }.forEach { event -> + issues += AuditIssue( + AuditSeverity.WARNING, + "Audit log ${shortId(event.id)} без времени", + "Тип: ${event.type}. Порядок восстановления счёта может быть неточным." + ) + } + + val sorted = + important.sortedWith( + compareBy { it.eventAt?.seconds ?: Long.MAX_VALUE } + .thenBy { it.id } + ) + + val result = mutableMapOf() + var importCount = 0 + + sorted.forEach { event -> + when (event.type) { + "initial_score_import" -> { + importCount++ + if (event.values.isEmpty()) { + issues += AuditIssue( + AuditSeverity.ERROR, + "initial_score_import ${shortId(event.id)} без values" + ) + } else { + event.values.forEach { (id, losses) -> + result[id] = losses + } + } + } + + "manual_score_correction", "game_result" -> { + val playerId = event.playerId + val newValue = event.newValue + + if (playerId.isNullOrBlank() || newValue == null) { + issues += AuditIssue( + AuditSeverity.ERROR, + "Audit log ${shortId(event.id)} неполный", + "type=${event.type}, playerId=${playerId ?: "—"}, newValue=${newValue ?: "—"}" + ) + return@forEach + } + + val previous = result[playerId] + val oldValue = event.oldValue + + if (previous != null && oldValue != null && previous != oldValue) { + issues += AuditIssue( + AuditSeverity.ERROR, + "Разрыв цепочки auditLogs для $playerId", + "Перед ${event.type} ожидалось oldValue=$previous, а в логе oldValue=$oldValue. Лог ${shortId(event.id)}." + ) + } + + if (event.type == "game_result" && oldValue != null && newValue != oldValue + 1) { + issues += AuditIssue( + AuditSeverity.ERROR, + "game_result не добавляет ровно +1", + "$playerId: $oldValue → $newValue, лог ${shortId(event.id)}" + ) + } + + result[playerId] = newValue + } + } + } + + if (importCount == 0) { + issues += AuditIssue( + AuditSeverity.ERROR, + "Не найден initial_score_import", + "Без него невозможно надёжно подтвердить историческую часть общего счёта." + ) + } else if (importCount > 1) { + issues += AuditIssue( + AuditSeverity.WARNING, + "Найдено несколько initial_score_import: $importCount" + ) + } + + return result.toMap() +} + +private fun calculateAuditPlayerStats( + games: List, + playerId: String, + playerName: String, + currentMainScore: Int?, + expectedMainScore: Int?, + issues: MutableList +): AuditPlayerStats { + val playerGames = + games + .filter { it.finalScores.containsKey(playerId) } + .sortedBy { it.finishedAt?.seconds ?: 0L } + + var currentLossStreak = 0 + var maxLossStreak = 0 + + playerGames.forEach { game -> + if (game.loserId == playerId) { + currentLossStreak++ + maxLossStreak = max(maxLossStreak, currentLossStreak) + } else { + currentLossStreak = 0 + } + } + + var bestSurvivedScore = 0 + var bestSurvivedTarget = 0 + + playerGames + .filter { it.loserId != playerId } + .forEach { game -> + val score = game.finalScores[playerId] ?: 0 + val oldDistance = + if (bestSurvivedTarget > 0) { + bestSurvivedTarget - bestSurvivedScore + } else { + Int.MAX_VALUE + } + val newDistance = game.targetScore - score + if (newDistance < oldDistance) { + bestSurvivedScore = score + bestSurvivedTarget = game.targetScore + } + } + + val totalPointsFromHistory = + playerGames.sumOf { game -> + game.roundHistory.count { it == playerId } + } + + val totalPointsFromScores = + playerGames.sumOf { it.finalScores[playerId] ?: 0 } + + if (totalPointsFromHistory != totalPointsFromScores) { + issues += AuditIssue( + AuditSeverity.ERROR, + "Суммарные +1 $playerName не сходятся", + "По roundHistory=$totalPointsFromHistory, по finalScores=$totalPointsFromScores" + ) + } + + val bestRoundStreak = + playerGames.maxOfOrNull { game -> + bestNoPointStreakAudit(game, playerId) + } ?: 0 + + val losses = playerGames.count { it.loserId == playerId } + val cleanGames = playerGames.count { (it.finalScores[playerId] ?: 0) == 0 } + val nearEscapeGames = + playerGames.count { game -> + game.loserId != playerId && + (game.finalScores[playerId] ?: 0) == game.targetScore - 1 + } + + val survivedOneAway = + bestSurvivedTarget > 0 && + bestSurvivedScore >= bestSurvivedTarget - 1 + + val achievements = + listOf( + AuditAchievement("Хуй пробьёшь", bestRoundStreak >= 10, "$bestRoundStreak/10"), + AuditAchievement("Серийный дурак", maxLossStreak >= 5, "$maxLossStreak/5 подряд"), + AuditAchievement("Ебаный пылесос +1", totalPointsFromHistory >= 100, "$totalPointsFromHistory/100"), + AuditAchievement( + "Живучая тварь", + survivedOneAway, + if (bestSurvivedTarget > 0) "$bestSurvivedScore/$bestSurvivedTarget" else "—" + ), + AuditAchievement("Сухой, аж бесит", cleanGames >= 3, "$cleanGames/3"), + AuditAchievement("Смерть его не берёт", nearEscapeGames >= 3, "$nearEscapeGames/3"), + AuditAchievement("Заслуженный долбоёб", losses >= 10, "$losses/10") + ) + + return AuditPlayerStats( + id = playerId, + name = playerName, + gamesPlayed = playerGames.size, + appLosses = losses, + currentMainScore = currentMainScore, + expectedMainScoreFromAudit = expectedMainScore, + totalPoints = totalPointsFromHistory, + bestNoPointStreak = bestRoundStreak, + maxLossStreak = maxLossStreak, + cleanGames = cleanGames, + nearEscapeGames = nearEscapeGames, + bestSurvivedScore = bestSurvivedScore, + bestSurvivedTarget = bestSurvivedTarget, + achievements = achievements + ) +} + +private fun bestNoPointStreakAudit( + game: AuditGame, + playerId: String +): Int { + var current = 0 + var best = 0 + + game.roundHistory.forEach { loserId -> + if (loserId == playerId) { + current = 0 + } else { + current++ + best = max(best, current) + } + } + + return best +} + +private fun shortId(id: String): String = + if (id.length <= 10) id else id.take(8) + "…" diff --git a/app/src/main/java/ru/durakscore/app/DateTimeUtils.kt b/app/src/main/java/ru/durakscore/app/DateTimeUtils.kt new file mode 100644 index 0000000..f4bc405 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/DateTimeUtils.kt @@ -0,0 +1,79 @@ +package ru.durakscore.app + +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val moscowZone = + ZoneId.of("Europe/Moscow") + +private val moscowDateTimeFormatter = + DateTimeFormatter.ofPattern( + "dd.MM.yyyy, HH:mm" + ) + +fun formatMoscowDateTime( + timestamp: Timestamp +): String { + + return timestamp + .toDate() + .toInstant() + .atZone(moscowZone) + .format(moscowDateTimeFormatter) + + " МСК" +} + +fun formatMoscowHistoryPeriod( + startedAt: Timestamp, + finishedAt: Timestamp +): String { + + val start = + startedAt.toDate() + .toInstant() + .atZone(moscowZone) + + val finish = + finishedAt.toDate() + .toInstant() + .atZone(moscowZone) + + return if ( + start.toLocalDate() == + finish.toLocalDate() + ) { + + val date = + start.format( + DateTimeFormatter.ofPattern( + "dd.MM.yyyy" + ) + ) + + val startTime = + start.format( + DateTimeFormatter.ofPattern( + "HH:mm" + ) + ) + + val finishTime = + finish.format( + DateTimeFormatter.ofPattern( + "HH:mm" + ) + ) + + "$date • $startTime–$finishTime МСК" + + } else { + + val formatter = + DateTimeFormatter.ofPattern( + "dd.MM.yyyy HH:mm" + ) + + "${start.format(formatter)} → " + + "${finish.format(formatter)} МСК" + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/durakscore/app/FinalGameScreen.kt b/app/src/main/java/ru/durakscore/app/FinalGameScreen.kt new file mode 100644 index 0000000..92a3aea --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/FinalGameScreen.kt @@ -0,0 +1,2223 @@ +package ru.durakscore.app + +import android.content.Context +import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Date +import kotlinx.coroutines.delay +import kotlin.math.absoluteValue +import kotlin.math.roundToInt +import kotlin.random.Random + +data class FinalGamePreview( + val id: String, + val type: String, + val targetScore: Int, + val players: List, + val scores: Map, + val history: List, + val startedAt: Timestamp?, + val loserId: String +) + +private data class FinalGameData( + val id: String, + val type: String, + val status: String, + val targetScore: Int, + val players: List, + val scores: Map, + val history: List, + val startedAt: Timestamp?, + val finishedAt: Timestamp?, + val loserId: String?, + val loserName: String?, + val loserGender: String? +) + +private data class FinalReactionRecord( + val emoji: String, + val senderName: String? +) + +private data class FinalGameStats( + val cleanestNames: String, + val cleanestScore: Int, + val longestStreakName: String?, + val longestStreak: Int, + val totalReactions: Int, + val emotionalNames: String?, + val emotionalCount: Int, + val favoriteReaction: String?, + val favoriteReactionCount: Int, + val calmestNames: String?, + val durationText: String, + val pointsGiven: Int +) + +private data class FinalGameTitle( + val icon: String, + val title: String, + val playerNames: String, + val detail: String +) + +private fun ServerLiveGame.toFinalGameData(): FinalGameData { + + val effectiveScores = + if ( + status == + "finished" && + finalScores.isNotEmpty() + ) { + finalScores + } else { + scores + } + + return FinalGameData( + id = + id, + type = + gameType, + status = + status, + targetScore = + targetScore, + players = + players.map { + player -> + + GamePlayer( + id = + player.id, + name = + player.name, + gender = + player.gender, + isCore = + player.isCore, + isGuest = + player.isGuest + ) + }, + scores = + effectiveScores, + history = + history, + startedAt = + startedAtEpochMillis + .takeIf { + it > 0L + } + ?.let { + Timestamp( + Date( + it + ) + ) + }, + finishedAt = + finishedAtEpochMillis + ?.takeIf { + it > 0L + } + ?.let { + Timestamp( + Date( + it + ) + ) + }, + loserId = + loserId, + loserName = + loserName, + loserGender = + loserGender + ) +} + + +private fun ServerFinishedGame.toFinalGameData(): FinalGameData { + + return FinalGameData( + id = + id, + type = + gameType, + status = + "finished", + targetScore = + targetScore, + players = + players.map { + player -> + + GamePlayer( + id = + player.id, + name = + player.name, + gender = + player.gender, + isCore = + player.isCore, + isGuest = + player.isGuest + ) + }, + scores = + finalScores, + history = + history, + startedAt = + startedAtEpochMillis + .takeIf { + it > 0L + } + ?.let { + Timestamp( + Date( + it + ) + ) + }, + finishedAt = + finishedAtEpochMillis + .takeIf { + it > 0L + } + ?.let { + Timestamp( + Date( + it + ) + ) + }, + loserId = + loserId, + loserName = + loserName, + loserGender = + loserGender + ) +} + + +private fun FinalGamePreview.toData(): FinalGameData { + + val loser = + players.firstOrNull { + it.id == loserId + } + + return FinalGameData( + id = id, + type = type, + status = "active", + targetScore = targetScore, + players = players, + scores = scores, + history = history, + startedAt = startedAt, + finishedAt = null, + loserId = loserId, + loserName = loser?.name, + loserGender = loser?.gender + ) +} + +private fun calculateFinalStats( + game: FinalGameData, + reactions: List +): FinalGameStats { + + val minScore = + game.players + .minOfOrNull { + game.scores[it.id] + ?: 0 + } + ?: 0 + + val cleanestNames = + game.players + .filter { + (game.scores[it.id] ?: 0) == + minScore + } + .joinToString(" и ") { + it.name + } + .ifBlank { + "—" + } + + val currentStreak = + game.players.associate { + it.id to 0 + } + .toMutableMap() + + val bestStreak = + game.players.associate { + it.id to 0 + } + .toMutableMap() + + game.history.forEach { loserId -> + + game.players.forEach { player -> + + if ( + player.id == + loserId + ) { + currentStreak[player.id] = + 0 + } else { + + val next = + (currentStreak[player.id] + ?: 0) + 1 + + currentStreak[player.id] = + next + + if ( + next > + (bestStreak[player.id] + ?: 0) + ) { + bestStreak[player.id] = + next + } + } + } + } + + val longestEntry = + bestStreak + .maxByOrNull { + it.value + } + + val longestPlayer = + longestEntry + ?.key + ?.let { id -> + game.players + .firstOrNull { + it.id == id + } + } + + val emojiCounts = + reactions + .groupingBy { + it.emoji + } + .eachCount() + + val favoriteReaction = + GAME_REACTION_EMOJIS + .maxByOrNull { + emojiCounts[it] + ?: 0 + } + ?.takeIf { + (emojiCounts[it] ?: 0) > 0 + } + + val senderCounts = + reactions + .mapNotNull { + it.senderName + ?.trim() + ?.takeIf { name -> + name.isNotBlank() + } + } + .groupingBy { + it + } + .eachCount() + + val maxSenderCount = + senderCounts.values + .maxOrNull() + ?: 0 + + val emotionalNames = + senderCounts + .filterValues { + it == maxSenderCount + } + .keys + .sorted() + .joinToString(" и ") + .takeIf { + it.isNotBlank() + } + + val playerReactionCounts = + game.players.associate { player -> + player.name to + ( + senderCounts[player.name] + ?: 0 + ) + } + + val calmestCount = + playerReactionCounts.values + .minOrNull() + ?: 0 + + val calmestNames = + if ( + reactions.isNotEmpty() && + playerReactionCounts.isNotEmpty() + ) { + playerReactionCounts + .filterValues { + it == calmestCount + } + .keys + .joinToString(" и ") + .takeIf { + it.isNotBlank() + } + } else { + null + } + + val durationText = + formatGameDuration( + startedAt = + game.startedAt, + finishedAt = + game.finishedAt + ) + + return FinalGameStats( + cleanestNames = + cleanestNames, + cleanestScore = + minScore, + longestStreakName = + longestPlayer?.name, + longestStreak = + longestEntry?.value + ?: 0, + totalReactions = + reactions.size, + emotionalNames = + emotionalNames, + emotionalCount = + maxSenderCount, + favoriteReaction = + favoriteReaction, + favoriteReactionCount = + favoriteReaction + ?.let { + emojiCounts[it] + } + ?: 0, + calmestNames = + calmestNames, + durationText = + durationText, + pointsGiven = + game.history.size + ) +} + +private fun formatGameDuration( + startedAt: Timestamp?, + finishedAt: Timestamp? +): String { + + if (startedAt == null) { + return "—" + } + + val endMillis = + finishedAt + ?.toDate() + ?.time + ?: System.currentTimeMillis() + + val totalMinutes = + ( + (endMillis - + startedAt.toDate().time) + .coerceAtLeast(0L) / + 60_000L + ) + .toInt() + + val hours = + totalMinutes / 60 + + val minutes = + totalMinutes % 60 + + return when { + hours > 0 && + minutes > 0 -> + "$hours ч $minutes мин" + + hours > 0 -> + "$hours ч" + + else -> + "$minutes мин" + } +} + +private val CLASSIC_LOSER_ROASTS = + listOf( + "{name} забрал {target} очков и заслуженно отправляется нахуй со званием дурака.", + + "{name} всю катку старательно собирал +1. Собрал {target}. Пиздец какая целеустремлённость.", + + "{name} первым добрался до {target}. Жаль, что это единственная гонка, которую стоило проиграть.", + + "Сегодня колода выбрала жертву: {name}. Итог — {target} очков и почётное звание дурака.", + + "{name} тащил катку как мог. Правда, строго в сторону собственного поражения. {target} из {target} — безупречно.", + + "{name} пришёл с планом. Судя по {target} очкам, план был обосраться максимально убедительно.", + + "{name} официально закрыл сбор очков: {target} штук. Остальные играли в карты, а тут шёл накопительный счёт.", + + "{name} дошёл до {target} быстрее, чем до понимания, что происходит. Дурак определён, расходимся.", + + "Финальный ответ стола: {name}. {target} очков — улики железобетонные, адвокат уже не поможет.", + + "{name} сегодня доказал главное: проигрывать тоже можно стабильно. {target} очков — аплодисменты этому пиздецу." + ) + +private fun loserRoast( + loserName: String, + targetScore: Int, + gameType: String, + classicRoastIndex: Int +): String { + + if ( + gameType != + "classic" + ) { + return "$loserName забрал $targetScore очков и заслуженно отправляется нахуй со званием дурака." + } + + val template = + CLASSIC_LOSER_ROASTS[ + classicRoastIndex + .mod( + CLASSIC_LOSER_ROASTS.size + ) + ] + + return template + .replace( + "{name}", + loserName + ) + .replace( + "{target}", + targetScore.toString() + ) +} + +private fun buildResultFact( + game: FinalGameData, + stats: FinalGameStats +): String { + + return when { + + stats.emotionalNames != null && + stats.emotionalCount >= 8 -> + + "${stats.emotionalNames} отправил(а) ${stats.emotionalCount} реакций. Человек играл не только картами, но и нервной системой." + + stats.longestStreakName != null && + stats.longestStreak >= 7 -> + + "${stats.longestStreakName} продержался(ась) ${stats.longestStreak} раздач без +1. Стол пытался, но не очень успешно." + + stats.totalReactions >= 20 -> + + "За катку прилетело ${stats.totalReactions} реакций. Спокойной эту компанию назвать сложно." + + else -> { + + val loserName = + game.loserName + ?: game.players + .firstOrNull { + it.id == + game.loserId + } + ?.name + ?: "Кто-то" + + "Очков раздали ${stats.pointsGiven}. Больше всего эта статистика почему-то не радует $loserName." + } + } +} + + +private fun buildGameTitles( + game: FinalGameData, + stats: FinalGameStats, + loserName: String +): List { + + val titles = + mutableListOf() + + titles.add( + FinalGameTitle( + icon = "💩", + title = "Почётный долбоёб катки", + playerNames = loserName, + detail = + "${game.targetScore} очков. Старался как мог — получилось хуже всех." + ) + ) + + if ( + stats.longestStreakName != null && + stats.longestStreak > + 0 + ) { + titles.add( + FinalGameTitle( + icon = "🧱", + title = "Хуй пробьёшь", + playerNames = + stats.longestStreakName, + detail = + "${stats.longestStreak} раздач без +1." + ) + ) + } + + if ( + stats.emotionalNames != null && + stats.emotionalCount > + 0 + ) { + titles.add( + FinalGameTitle( + icon = "🤬", + title = "Ебаный пульт эмоций", + playerNames = + stats.emotionalNames, + detail = + "${stats.emotionalCount} реакций за одну катку." + ) + ) + } + + if ( + stats.cleanestNames.isNotBlank() + ) { + titles.add( + FinalGameTitle( + icon = "👑", + title = "Чистый, аж бесит", + playerNames = + stats.cleanestNames, + detail = + "Всего ${stats.cleanestScore} очков." + ) + ) + } + + if ( + stats.calmestNames != null && + stats.totalReactions > + 0 + ) { + titles.add( + FinalGameTitle( + icon = "🗿", + title = "Похуй на происходящее", + playerNames = + stats.calmestNames, + detail = + "Меньше всех дёргал кнопки реакций." + ) + ) + } + + return titles +} + +private fun shareFinalResult( + context: Context, + game: FinalGameData, + loserName: String, + roast: String, + stats: FinalGameStats, + titles: List +) { + + val scoreText = + game.players + .joinToString( + separator = "\n" + ) { + player -> + + val score = + game.scores[player.id] + ?: 0 + + val loserMark = + if ( + player.id == + game.loserId + ) { + " 💩" + } else { + "" + } + + "${player.name}: $score${loserMark}" + } + + val titlesText = + titles + .take(5) + .joinToString( + separator = "\n" + ) { + title -> + "${title.icon} ${title.title}: ${title.playerNames}" + } + + val reactionText = + if ( + stats.totalReactions > + 0 + ) { + "\n💥 Реакций: ${stats.totalReactions}" + } else { + "" + } + + val shareText = + """ + ♠ ДУРАКОМЕТР — ИТОГИ КАТКИ + + 💩 $loserName — ДУРАК + $roast + + Итоговый счёт: + $scoreText + + Титулы: + $titlesText + + ⏱ ${stats.durationText}$reactionText + """.trimIndent() + + val intent = + Intent( + Intent.ACTION_SEND + ).apply { + type = + "text/plain" + + putExtra( + Intent.EXTRA_TEXT, + shareText + ) + } + + context.startActivity( + Intent.createChooser( + intent, + "Поделиться итогами" + ) + ) +} + +@Composable +fun FinalGameScreen( + gameId: String, + preview: FinalGamePreview? = null, + showResultControls: Boolean, + canConfirmResult: Boolean, + isFinishingResult: Boolean, + onConfirmResult: () -> Unit, + onUndoLastPoint: () -> Unit, + onResultRolledBack: () -> Unit, + onBackToMenu: () -> Unit +) { + + val context = + LocalContext.current + + var serverGame by remember( + gameId + ) { + mutableStateOf( + null + ) + } + + var reactions by remember( + gameId + ) { + mutableStateOf>( + emptyList() + ) + } + + var loadError by remember( + gameId + ) { + mutableStateOf( + null + ) + } + + /* + * Нужен только для последовательного выбора подъёба: + * 1-я классическая катка -> фраза 1, + * 2-я -> фраза 2, + * ... + * 10-я -> фраза 10, + * 11-я снова -> фраза 1. + * + * Считаем только завершённые classic-игры текущего TEST_MODE. + */ + var finishedClassicGames by remember( + gameId + ) { + mutableStateOf>( + emptyList() + ) + } + + val currentUser = + DurakAuthSession.current() + + DisposableEffect( + gameId, + currentUser?.uid + ) { + + var active = + true + + val user = + currentUser + + if ( + user == + null + ) { + loadError = + "Пользователь не авторизован" + + onDispose { + active = + false + } + + } else { + + /* + * V1.7.1: + * Финальный экран больше не читает Firestore. + * + * Пока партия ещё active и последнее очко уже достигло лимита, + * берём её из live snapshot. + * После подтверждения finish тот же gameId приходит как + * lastFinishedGame — экран плавно переключается на сохранённый + * результат без перезапуска. + */ + val gamePolling = + DurakServerApi + .startLivePolling( + user = + user, + isTest = + TEST_MODE, + intervalMs = + 450L, + onSnapshot = { + snapshot -> + + if ( + !active + ) { + return@startLivePolling + } + + val matchingGame = + when { + + snapshot + .activeGame + ?.id == + gameId -> + + snapshot + .activeGame + + snapshot + .lastFinishedGame + ?.id == + gameId -> + + snapshot + .lastFinishedGame + + else -> + null + } + + if ( + matchingGame != + null + ) { + serverGame = + matchingGame + .toFinalGameData() + + loadError = + null + } + }, + onError = { + message -> + + if ( + active && + serverGame == + null + ) { + loadError = + message.ifBlank { + "Не удалось загрузить результат" + } + } + } + ) + + val reactionsPolling = + DurakServerApi + .startReactionPolling( + user = + user, + gameId = + gameId, + intervalMs = + 450L, + onSnapshot = { + serverReactions -> + + if ( + active + ) { + reactions = + serverReactions + .map { + reaction -> + + FinalReactionRecord( + emoji = + reaction.emoji, + senderName = + reaction.senderName + ) + } + } + }, + onError = { + /* + * Реакции вторичны для финального экрана: + * ошибка их загрузки не должна оставлять + * весь экран на вечном спиннере. + */ + } + ) + + DurakServerApi + .loadFinishedGames( + user = + user, + onSuccess = { + games -> + + if ( + active + ) { + finishedClassicGames = + games + .asSequence() + .filter { + it.isTest == + TEST_MODE + } + .filter { + it.gameType == + "classic" + } + .map { + it.toFinalGameData() + } + .toList() + } + }, + onError = { + /* + * Список старых игр нужен только для последовательной + * шуточной фразы. Сам итог текущей партии от него + * не зависит. + */ + } + ) + + onDispose { + active = + false + + gamePolling.remove() + reactionsPolling.remove() + } + } + } + + val serverHasResult = + serverGame + ?.let { game -> + + game.status == + "finished" || + game.scores + .values + .any { + it >= + game.targetScore + } + } + ?: false + + val game = + when { + serverHasResult -> + serverGame + + preview != null -> + preview.toData() + + else -> + serverGame + } + + /* + * Наблюдатель уже перешёл на финальный экран, + * но ведущий отменил последнее очко. + * Возвращаем наблюдателя обратно к live-столу. + */ + LaunchedEffect( + serverGame?.status, + serverGame?.scores, + serverGame?.targetScore, + preview + ) { + + val current = + serverGame + ?: return@LaunchedEffect + + val stillHasLoser = + current.scores + .values + .any { + it >= + current.targetScore + } + + if ( + preview == null && + current.status != "finished" && + !stillHasLoser + ) { + delay(500L) + onResultRolledBack() + } + } + + if ( + game == null + ) { + + PokerScreen { + + PokerHeader( + title = + "Итоги катки", + subtitle = + "Загружаю результат" + ) + + Spacer( + modifier = + Modifier.height(26.dp) + ) + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.Center + ) { + CircularProgressIndicator( + color = + PokerPalette.Gold + ) + } + + if ( + loadError != null + ) { + Spacer( + modifier = + Modifier.height(18.dp) + ) + + Text( + text = + loadError!!, + color = + PokerPalette.Danger + ) + } + } + + return + } + + val loser = + game.players + .firstOrNull { + it.id == + ( + game.loserId + ?: game.scores + .entries + .firstOrNull { + it.value >= + game.targetScore + } + ?.key + ) + } + + val loserName = + game.loserName + ?: loser?.name + ?: "Игрок" + + val stats = + remember( + game, + reactions + ) { + calculateFinalStats( + game = + game, + reactions = + reactions + ) + } + + /* + * Берём количество завершённых classic-игр, + * которые начались РАНЬШЕ текущей. + * + * Это даёт одинаковую последовательность на всех телефонах + * даже в момент, когда наблюдатель уже увидел финал, + * а ведущий ещё только подтверждает завершение. + */ + val classicRoastIndex = + remember( + game.id, + game.type, + game.startedAt, + finishedClassicGames + ) { + + if ( + game.type != + "classic" + ) { + 0 + } else { + + val currentStartedMillis = + game.startedAt + ?.toDate() + ?.time + ?: Long.MAX_VALUE + + finishedClassicGames + .count { previous -> + + previous.id != + game.id && + ( + previous.startedAt + ?.toDate() + ?.time + ?: Long.MIN_VALUE + ) < + currentStartedMillis + } + } + } + + val roast = + remember( + game.id, + game.type, + loserName, + game.targetScore, + classicRoastIndex + ) { + loserRoast( + loserName = + loserName, + targetScore = + game.targetScore, + gameType = + game.type, + classicRoastIndex = + classicRoastIndex + ) + } + + val resultFact = + remember( + game, + stats + ) { + buildResultFact( + game = + game, + stats = + stats + ) + } + + val gameTitles = + remember( + game, + stats, + loserName + ) { + buildGameTitles( + game = + game, + stats = + stats, + loserName = + loserName + ) + } + + var celebrationPlayed by remember( + gameId + ) { + mutableStateOf(false) + } + + var showCelebration by remember( + gameId + ) { + mutableStateOf(false) + } + + LaunchedEffect( + loserName + ) { + + if ( + loserName != "Игрок" && + !celebrationPlayed + ) { + celebrationPlayed = true + showCelebration = true + + delay(10_000L) + + showCelebration = false + } + } + + Box( + modifier = + Modifier.fillMaxSize() + ) { + + PokerScreen { + + PokerHeader( + title = + "Итоги катки", + subtitle = + if ( + game.status == + "finished" + ) { + "Результат сохранён" + } else { + "Фиксируем последнее очко" + } + ) + + Spacer( + modifier = + Modifier.height(16.dp) + ) + + PokerPanel { + + PokerBadge( + text = + if ( + game.status == + "finished" + ) { + "ПАРТИЯ ОКОНЧЕНА" + } else { + "ЕСТЬ ДУРАК" + } + ) + + Spacer( + modifier = + Modifier.height(14.dp) + ) + + Text( + text = + "$loserName — ДУРАК", + color = + PokerPalette.Gold, + fontSize = + 32.sp, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + + Text( + text = + roast, + color = + PokerPalette.TextPrimary, + fontSize = + 18.sp, + fontWeight = + FontWeight.SemiBold + ) + } + + Spacer( + modifier = + Modifier.height(18.dp) + ) + + Text( + text = + "Итоговый счёт", + color = + PokerPalette.Gold, + fontSize = + 20.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + + game.players.forEach { player -> + + FinalScoreRow( + player = + player, + score = + game.scores[player.id] + ?: 0, + isLoser = + player.id == + ( + game.loserId + ?: loser?.id + ) + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + } + + Spacer( + modifier = + Modifier.height(8.dp) + ) + + Text( + text = + "Титулы катки", + color = + PokerPalette.Gold, + fontSize = + 20.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + PokerPanel { + + gameTitles.forEach { + title -> + + FinalStatLine( + icon = + title.icon, + title = + title.title, + value = + title.playerNames + ) + + Text( + text = + title.detail, + color = + PokerPalette.TextSecondary, + fontSize = + 13.sp + ) + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + } + } + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + Text( + text = + "Что было за катку", + color = + PokerPalette.Gold, + fontSize = + 20.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + + PokerPanel { + + FinalStatLine( + icon = "🏆", + title = "Самый чистый", + value = + "${stats.cleanestNames} — ${stats.cleanestScore}" + ) + + if ( + stats.longestStreakName != null + ) { + FinalStatLine( + icon = "🔥", + title = "Самая длинная серия", + value = + "${stats.longestStreakName} — ${stats.longestStreak} без +1" + ) + } + + if ( + stats.emotionalNames != null + ) { + FinalStatLine( + icon = "🤬", + title = "Самый эмоциональный", + value = + "${stats.emotionalNames} — ${stats.emotionalCount} реакций" + ) + } + + if ( + stats.favoriteReaction != null + ) { + FinalStatLine( + icon = + stats.favoriteReaction, + title = + "Реакция катки", + value = + "${stats.favoriteReaction} × ${stats.favoriteReactionCount}" + ) + } + + if ( + stats.calmestNames != null + ) { + FinalStatLine( + icon = "🧘", + title = "Самый спокойный", + value = + stats.calmestNames + ) + } + + FinalStatLine( + icon = "💥", + title = "Всего реакций", + value = + stats.totalReactions + .toString() + ) + + FinalStatLine( + icon = "🎯", + title = "Очков раздали", + value = + stats.pointsGiven + .toString() + ) + + FinalStatLine( + icon = "⏱", + title = "Длительность", + value = + stats.durationText + ) + } + + Spacer( + modifier = + Modifier.height(14.dp) + ) + + PokerPanel { + + Text( + text = + resultFact, + color = + PokerPalette.TextPrimary, + fontSize = + 17.sp, + fontWeight = + FontWeight.SemiBold + ) + } + + Spacer( + modifier = + Modifier.height(18.dp) + ) + + if ( + game.status == + "finished" + ) { + + PokerSecondaryButton( + text = + "📤 Поделиться итогами", + onClick = { + shareFinalResult( + context = + context, + game = + game, + loserName = + loserName, + roast = + roast, + stats = + stats, + titles = + gameTitles + ) + } + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + } + + if ( + game.status != "finished" && + showResultControls + ) { + + PokerPrimaryButton( + text = + if ( + isFinishingResult + ) { + "Сохраняю результат..." + } else if ( + !canConfirmResult + ) { + "Сохраняю последнее очко..." + } else { + "Завершить партию" + }, + onClick = + onConfirmResult, + enabled = + canConfirmResult && + !isFinishingResult + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + + PokerSecondaryButton( + text = + "↶ Отменить последнее очко", + onClick = + onUndoLastPoint, + enabled = + !isFinishingResult + ) + + } else if ( + game.status != "finished" + ) { + + PokerPanel { + + Text( + text = + "Ведущий фиксирует результат…", + color = + PokerPalette.Gold, + fontSize = + 16.sp, + fontWeight = + FontWeight.Bold + ) + } + + } else { + + PokerPrimaryButton( + text = + "В меню", + onClick = + onBackToMenu + ) + } + + Spacer( + modifier = + Modifier.height(20.dp) + ) + + Text( + text = + "♠ ♥ ♦ ♣", + color = + PokerPalette.GoldDark, + fontSize = + 20.sp, + fontWeight = + FontWeight.Bold, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + } + + AnimatedVisibility( + visible = + showCelebration, + enter = + fadeIn( + tween(120) + ) + + scaleIn( + initialScale = 0.96f, + animationSpec = + tween(180) + ), + exit = + fadeOut( + tween(320) + ) + + scaleOut( + targetScale = 1.03f, + animationSpec = + tween(320) + ) + ) { + + FinalCelebrationOverlay( + gameId = + game.id, + loserName = + loserName, + roast = + roast + ) + } + } +} + +@Composable +private fun FinalScoreRow( + player: GamePlayer, + score: Int, + isLoser: Boolean +) { + + PokerPanel { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + + Column { + + Text( + text = + if ( + player.isGuest + ) { + "${player.name} · гость" + } else { + player.name + }, + color = + if ( + isLoser + ) { + PokerPalette.Gold + } else { + PokerPalette.TextPrimary + }, + fontSize = + 21.sp, + fontWeight = + FontWeight.Bold + ) + + if ( + isLoser + ) { + Spacer( + modifier = + Modifier.height(5.dp) + ) + + PokerBadge( + text = + "ДУРАК" + ) + } + } + + Text( + text = + score.toString(), + color = + if ( + isLoser + ) { + PokerPalette.Gold + } else { + PokerPalette.TextPrimary + }, + fontSize = + 34.sp, + fontWeight = + FontWeight.Black + ) + } + } +} + +@Composable +private fun FinalStatLine( + icon: String, + title: String, + value: String +) { + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + vertical = 7.dp + ), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + + Text( + text = + "$icon $title", + color = + PokerPalette.TextSecondary, + fontSize = + 15.sp, + modifier = + Modifier.weight(1f) + ) + + Text( + text = + value, + color = + PokerPalette.TextPrimary, + fontSize = + 15.sp, + fontWeight = + FontWeight.Bold, + textAlign = + TextAlign.End, + modifier = + Modifier.weight(1f) + ) + } +} + +private data class CelebrationParticle( + val symbol: String, + val startX: Float, + val drift: Float, + val delayMs: Long, + val durationMs: Int, + val fontSize: Int, + val rotation: Float +) + +@Composable +private fun FinalCelebrationOverlay( + gameId: String, + loserName: String, + roast: String +) { + + val titleScale = + remember { + Animatable(0.72f) + } + + val titleAlpha = + remember { + Animatable(0f) + } + + val blockInteractionSource = + remember { + MutableInteractionSource() + } + + LaunchedEffect(gameId) { + + titleAlpha.animateTo( + targetValue = 1f, + animationSpec = + tween(180) + ) + + titleScale.animateTo( + targetValue = 1.08f, + animationSpec = + tween(260) + ) + + titleScale.animateTo( + targetValue = 1f, + animationSpec = + tween(160) + ) + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Color( + 0xEE020705 + ) + ) + .clickable( + interactionSource = + blockInteractionSource, + indication = + null + ) { + // Первые 10 секунд финал не пропускаем. + } + ) { + + FinalConfettiStorm( + seed = + gameId.hashCode() + ) + + Column( + modifier = + Modifier + .align( + Alignment.Center + ) + .padding( + horizontal = 24.dp + ) + .graphicsLayer { + scaleX = + titleScale.value + scaleY = + titleScale.value + alpha = + titleAlpha.value + }, + horizontalAlignment = + Alignment.CenterHorizontally + ) { + + Text( + text = + "ПАРТИЯ ОКОНЧЕНА", + color = + PokerPalette.Gold, + fontSize = + 18.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(14.dp) + ) + + Text( + text = + "$loserName\nДУРАК", + color = + PokerPalette.TextPrimary, + fontSize = + 46.sp, + lineHeight = + 50.sp, + textAlign = + TextAlign.Center, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height(18.dp) + ) + + Text( + text = + roast, + color = + PokerPalette.Gold, + fontSize = + 19.sp, + lineHeight = + 25.sp, + textAlign = + TextAlign.Center, + fontWeight = + FontWeight.SemiBold + ) + + Spacer( + modifier = + Modifier.height(24.dp) + ) + + Text( + text = + "итоги появятся через несколько секунд", + color = + PokerPalette.TextSecondary, + fontSize = + 12.sp + ) + } + } +} + +@Composable +private fun FinalConfettiStorm( + seed: Int +) { + + val symbols = + listOf( + "🎉", + "✨", + "♠", + "♥", + "♦", + "♣" + ) + + val particles = + remember(seed) { + + val random = + Random(seed) + + List(48) { + + CelebrationParticle( + symbol = + symbols[ + random.nextInt( + symbols.size + ) + ], + startX = + random.nextFloat(), + drift = + ( + random.nextFloat() - + 0.5f + ) * 0.28f, + delayMs = + random.nextLong( + 0L, + 2_500L + ), + durationMs = + random.nextInt( + 5_000, + 8_000 + ), + fontSize = + random.nextInt( + 18, + 36 + ), + rotation = + if ( + random.nextBoolean() + ) { + 540f + } else { + -540f + } + ) + } + } + + BoxWithConstraints( + modifier = + Modifier.fillMaxSize() + ) { + + val width = + constraints.maxWidth + .toFloat() + + val height = + constraints.maxHeight + .toFloat() + + particles.forEachIndexed { + index, + particle -> + + val progress = + remember( + seed, + index + ) { + Animatable(0f) + } + + LaunchedEffect( + seed, + index + ) { + + delay( + particle.delayMs + ) + + progress.animateTo( + targetValue = + 1f, + animationSpec = + tween( + durationMillis = + particle.durationMs, + easing = + LinearEasing + ) + ) + } + + val x = + ( + particle.startX + + particle.drift * + progress.value + ) * + width + + val y = + -80f + + ( + height + + 160f + ) * + progress.value + + Text( + text = + particle.symbol, + fontSize = + particle.fontSize.sp, + modifier = + Modifier + .offset { + IntOffset( + x = + x.roundToInt(), + y = + y.roundToInt() + ) + } + .graphicsLayer { + rotationZ = + particle.rotation * + progress.value + + alpha = + if ( + progress.value > + 0.90f + ) { + ( + 1f - + progress.value + ) / 0.10f + } else { + 1f + } + .coerceIn( + 0f, + 1f + ) + } + ) + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/GameReactions.kt b/app/src/main/java/ru/durakscore/app/GameReactions.kt new file mode 100644 index 0000000..8e38de9 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/GameReactions.kt @@ -0,0 +1,1168 @@ +package ru.durakscore.app + +import android.graphics.Bitmap +import android.graphics.Paint +import android.view.HapticFeedbackConstants +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import java.util.Date +import java.util.UUID +import kotlin.random.Random + +val GAME_REACTION_EMOJIS = + listOf( + "😂", + "💩", + "🔥", + "👏" + ) + +private fun reactionIconRes(emoji: String): Int = + when (emoji) { + "😂" -> R.drawable.reaction_laugh_jester + "💩" -> R.drawable.reaction_poop_jester + "🔥" -> R.drawable.reaction_fire_jester + "👏" -> R.drawable.reaction_clap_jester + else -> R.drawable.reaction_laugh_jester + } + +private const val REACTION_RECENT_WINDOW_MS = + 10_000L + +private const val REACTION_SEND_COOLDOWN_MS = + 650L + +private const val REACTION_PARTICLE_COUNT = + 10 + +/* + * Максимальное падение сейчас до 7.5 сек + случайная задержка старта. + * Через 9 сек конкретный дождь уже можно безопасно убрать из Compose. + */ +private const val REACTION_RAIN_LIFETIME_MS = + 9_000L + +data class GameReaction( + val id: String, + val emoji: String, + val senderUid: String, + val senderName: String? = null, + val createdAt: Timestamp? +) + +private data class LocalReactionEvent( + val gameId: String, + val reaction: GameReaction +) + +private object GameReactionLocalBus { + + val events = + MutableSharedFlow( + extraBufferCapacity = 64 + ) + + fun emit( + gameId: String, + reaction: GameReaction + ) { + events.tryEmit( + LocalReactionEvent( + gameId = gameId, + reaction = reaction + ) + ) + } +} + +object GameReactionRepository { + + fun sendReaction( + gameId: String, + emoji: String, + senderName: String, + onSuccess: () -> Unit = {}, + onError: (String) -> Unit = {} + ) { + + if ( + emoji !in + GAME_REACTION_EMOJIS + ) { + onError( + "Неизвестная реакция" + ) + return + } + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + onError( + "Пользователь не авторизован" + ) + return + } + + val cleanSenderName = + senderName + .trim() + .ifBlank { + "Игрок" + } + .take( + 40 + ) + + /* + * UUID создаём на Android заранее. + * Тот же id уходит на VPS, поэтому optimistic-дождь + * и подтверждённая серверная реакция не дублируются. + */ + val requestId = + UUID.randomUUID() + .toString() + + val localReaction = + GameReaction( + id = + requestId, + emoji = + emoji, + senderUid = + user.uid, + senderName = + cleanSenderName, + createdAt = + null + ) + + GameReactionLocalBus.emit( + gameId = + gameId, + reaction = + localReaction + ) + + DurakServerApi + .sendReaction( + user = + user, + gameId = + gameId, + requestId = + requestId, + emoji = + emoji, + onSuccess = { + onSuccess() + }, + onError = + onError + ) + } + + fun listenReactions( + gameId: String, + onReaction: (GameReaction) -> Unit, + onError: (String) -> Unit = {}, + skipInitialSnapshot: Boolean = false + ): ServerPollingHandle { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + onError( + "Пользователь не авторизован" + ) + + return ServerPollingHandle { + } + } + + val seenIds = + mutableSetOf() + + var firstSuccessfulSnapshot = + true + + return DurakServerApi + .startReactionPolling( + user = + user, + gameId = + gameId, + onSnapshot = { + serverReactions -> + + if ( + skipInitialSnapshot && + firstSuccessfulSnapshot + ) { + serverReactions + .forEach { + seenIds.add( + it.id + ) + } + + firstSuccessfulSnapshot = + false + + return@startReactionPolling + } + + firstSuccessfulSnapshot = + false + + val now = + System.currentTimeMillis() + + serverReactions + .asReversed() + .forEach { + reaction -> + + if ( + reaction.id in + seenIds + ) { + return@forEach + } + + seenIds.add( + reaction.id + ) + + val createdAtMillis = + reaction + .createdAtEpochMillis + + if ( + createdAtMillis != + null && + now - + createdAtMillis > + REACTION_RECENT_WINDOW_MS + ) { + return@forEach + } + + onReaction( + GameReaction( + id = + reaction.id, + emoji = + reaction.emoji, + senderUid = + reaction.senderUid, + senderName = + reaction.senderName, + createdAt = + createdAtMillis + ?.let { + Timestamp( + Date( + it + ) + ) + } + ) + ) + } + }, + onError = + onError + ) + } +} + +private data class ReactionParticle( + val startX: Float, + val endX: Float, + val delayMs: Long, + val durationMs: Int, + val sizeSp: Int, + val startRotation: Float, + val rotations: Float +) + +private data class ActiveReactionRain( + val reaction: GameReaction, + val startedAtNanos: Long, + val particles: List +) + +private fun createReactionParticles( + reactionId: String +): List { + + val random = + Random( + reactionId.hashCode() + ) + + return List( + REACTION_PARTICLE_COUNT + ) { + + val startX = + random.nextFloat() + + val drift = + ( + random.nextFloat() - + 0.5f + ) * 0.38f + + ReactionParticle( + startX = + startX, + endX = + (startX + drift) + .coerceIn( + 0.02f, + 0.94f + ), + delayMs = + random.nextLong( + from = 0L, + until = 650L + ), + durationMs = + random.nextInt( + from = 4_350, + until = 7_500 + ), + sizeSp = + random.nextInt( + from = 34, + until = 55 + ), + startRotation = + random.nextFloat() * + 80f - + 40f, + rotations = + if ( + random.nextBoolean() + ) { + random.nextFloat() * + 1.4f + + 0.35f + } else { + -( + random.nextFloat() * + 1.4f + + 0.35f + ) + } + ) + } +} + +private fun createActiveReactionRain( + reaction: GameReaction +): ActiveReactionRain = + ActiveReactionRain( + reaction = + reaction, + startedAtNanos = + System.nanoTime(), + particles = + createReactionParticles( + reaction.id + ) + ) + +private data class EmojiSprite( + val bitmap: Bitmap, + val paddingPx: Int +) + +private class EmojiSpriteCache { + + private val textPaint = + Paint( + Paint.ANTI_ALIAS_FLAG + ).apply { + textAlign = + Paint.Align.LEFT + color = + android.graphics.Color.WHITE + isDither = + true + isSubpixelText = + true + } + + private val sprites = + HashMap() + + fun get( + emoji: String, + textSizePx: Float + ): EmojiSprite { + + val sizePx = + textSizePx + .toInt() + .coerceAtLeast( + 1 + ) + + val key = + "$emoji@$sizePx" + + return sprites.getOrPut( + key + ) { + + textPaint.textSize = + sizePx.toFloat() + + val metrics = + textPaint.fontMetrics + + val paddingPx = + ( + sizePx * + 0.20f + ) + .toInt() + .coerceAtLeast( + 4 + ) + + val contentWidth = + textPaint + .measureText( + emoji + ) + .toInt() + .coerceAtLeast( + 1 + ) + + val contentHeight = + ( + metrics.descent - + metrics.ascent + ) + .toInt() + .coerceAtLeast( + 1 + ) + + val bitmap = + Bitmap.createBitmap( + contentWidth + + paddingPx * + 2, + contentHeight + + paddingPx * + 2, + Bitmap.Config.ARGB_8888 + ) + + val bitmapCanvas = + android.graphics.Canvas( + bitmap + ) + + bitmapCanvas.drawText( + emoji, + paddingPx.toFloat(), + paddingPx - + metrics.ascent, + textPaint + ) + + EmojiSprite( + bitmap = + bitmap, + paddingPx = + paddingPx + ) + } + } + + fun clear() { + + sprites + .values + .forEach { sprite -> + + if ( + !sprite.bitmap.isRecycled + ) { + sprite.bitmap.recycle() + } + } + + sprites.clear() + } +} + +private fun DrawScope.drawReactionRains( + rains: List, + frameTimeNanos: Long, + spriteCache: EmojiSpriteCache, + bitmapPaint: Paint +) { + + if ( + rains.isEmpty() + ) { + return + } + + val widthPx = + size.width + + val heightPx = + size.height + + val canvas = + drawContext + .canvas + .nativeCanvas + + rains.forEach rainLoop@ { rain -> + + val rainElapsedMs = + ( + frameTimeNanos - + rain.startedAtNanos + ) / + 1_000_000f + + if ( + rainElapsedMs < 0f || + rainElapsedMs > + REACTION_RAIN_LIFETIME_MS + ) { + return@rainLoop + } + + rain.particles.forEach particleLoop@ { particle -> + + val particleElapsedMs = + rainElapsedMs - + particle.delayMs + + if ( + particleElapsedMs < 0f + ) { + return@particleLoop + } + + val progress = + ( + particleElapsedMs / + particle.durationMs + ) + .coerceIn( + 0f, + 1f + ) + + if ( + progress >= 1f + ) { + return@particleLoop + } + + val x = + ( + particle.startX + + ( + particle.endX - + particle.startX + ) * + progress + ) * + widthPx + + val y = + -100f + + ( + heightPx + + 220f + ) * + progress + + val alpha = + when { + progress < 0.05f -> + progress / + 0.05f + + progress > 0.90f -> + ( + 1f - + progress + ) / + 0.10f + + else -> + 1f + } + .coerceIn( + 0f, + 1f + ) + + val textSizePx = + particle.sizeSp * + density * + fontScale + + val sprite = + spriteCache.get( + emoji = + rain.reaction.emoji, + textSizePx = + textSizePx + ) + + bitmapPaint.alpha = + ( + alpha * + 255f + ) + .toInt() + .coerceIn( + 0, + 255 + ) + + val left = + x - + sprite.paddingPx + + val top = + y - + sprite.paddingPx + + val pivotX = + left + + sprite.bitmap.width / + 2f + + val pivotY = + top + + sprite.bitmap.height / + 2f + + val rotation = + particle.startRotation + + 360f * + particle.rotations * + progress + + canvas.save() + + canvas.rotate( + rotation, + pivotX, + pivotY + ) + + canvas.drawBitmap( + sprite.bitmap, + left, + top, + bitmapPaint + ) + + canvas.restore() + } + } +} + +@Composable +fun ReactionRainHost( + gameId: String, + modifier: Modifier = Modifier, + skipExistingOnStart: Boolean = false +) { + + val activeRains = + remember(gameId) { + mutableStateListOf() + } + + val seenReactionIds = + remember(gameId) { + HashSet() + } + + var frameTimeNanos by + remember(gameId) { + mutableLongStateOf( + System.nanoTime() + ) + } + + val spriteCache = + remember(gameId) { + EmojiSpriteCache() + } + + val bitmapPaint = + remember(gameId) { + Paint( + Paint.ANTI_ALIAS_FLAG or + Paint.FILTER_BITMAP_FLAG + ).apply { + isDither = + true + } + } + + LaunchedEffect(gameId) { + + GameReactionLocalBus + .events + .collect { event -> + + if ( + event.gameId == + gameId && + seenReactionIds.add( + event.reaction.id + ) + ) { + activeRains.add( + createActiveReactionRain( + event.reaction + ) + ) + } + } + } + + DisposableEffect( + gameId, + skipExistingOnStart + ) { + + val registration = + GameReactionRepository + .listenReactions( + gameId = + gameId, + skipInitialSnapshot = + skipExistingOnStart, + onReaction = { reaction -> + + if ( + seenReactionIds.add( + reaction.id + ) + ) { + activeRains.add( + createActiveReactionRain( + reaction + ) + ) + } + } + ) + + onDispose { + registration.remove() + activeRains.clear() + seenReactionIds.clear() + spriteCache.clear() + } + } + + val hasActiveRains = + activeRains.isNotEmpty() + + LaunchedEffect( + gameId, + hasActiveRains + ) { + + if ( + !hasActiveRains + ) { + return@LaunchedEffect + } + + var lastCleanupNanos = + 0L + + var lastRenderNanos = + 0L + + while ( + activeRains.isNotEmpty() + ) { + + withFrameNanos { frameNanos -> + + if ( + lastRenderNanos == + 0L || + frameNanos - + lastRenderNanos >= + 22_000_000L + ) { + lastRenderNanos = + frameNanos + frameTimeNanos = + frameNanos + } + } + + val currentFrame = + frameTimeNanos + + if ( + currentFrame - + lastCleanupNanos >= + 250_000_000L + ) { + lastCleanupNanos = + currentFrame + + activeRains.removeAll { rain -> + ( + currentFrame - + rain.startedAtNanos + ) / + 1_000_000L >= + REACTION_RAIN_LIFETIME_MS + } + } + } + } + + Canvas( + modifier = + modifier.fillMaxSize() + ) { + drawReactionRains( + rains = + activeRains, + frameTimeNanos = + frameTimeNanos, + spriteCache = + spriteCache, + bitmapPaint = + bitmapPaint + ) + } +} + +@Composable +fun ReactionRain( + reaction: GameReaction?, + modifier: Modifier = Modifier +) { + + val activeReaction = + reaction + ?: return + + val rain = + remember( + activeReaction.id + ) { + createActiveReactionRain( + activeReaction + ) + } + + var frameTimeNanos by + remember( + activeReaction.id + ) { + mutableLongStateOf( + System.nanoTime() + ) + } + + val spriteCache = + remember( + activeReaction.id + ) { + EmojiSpriteCache() + } + + val bitmapPaint = + remember( + activeReaction.id + ) { + Paint( + Paint.ANTI_ALIAS_FLAG or + Paint.FILTER_BITMAP_FLAG + ).apply { + isDither = + true + } + } + + LaunchedEffect( + activeReaction.id + ) { + + var lastRenderNanos = + 0L + + while ( + ( + frameTimeNanos - + rain.startedAtNanos + ) / + 1_000_000L < + REACTION_RAIN_LIFETIME_MS + ) { + + withFrameNanos { frameNanos -> + + if ( + lastRenderNanos == + 0L || + frameNanos - + lastRenderNanos >= + 22_000_000L + ) { + lastRenderNanos = + frameNanos + frameTimeNanos = + frameNanos + } + } + } + } + + Canvas( + modifier = + modifier.fillMaxSize() + ) { + drawReactionRains( + rains = + listOf( + rain + ), + frameTimeNanos = + frameTimeNanos, + spriteCache = + spriteCache, + bitmapPaint = + bitmapPaint + ) + } +} + +@Composable +fun ReactionBar( + gameId: String, + senderName: String +) { + + val view = + LocalView.current + + var lastSendAt by remember { + mutableLongStateOf(0L) + } + + var errorText by remember { + mutableStateOf( + null + ) + } + + LaunchedEffect( + errorText + ) { + if ( + errorText != null + ) { + delay(2_500L) + errorText = null + } + } + + PokerPanel { + + Text( + text = + "Кинуть реакцию", + color = + PokerPalette.Gold, + fontSize = + 15.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + Row( + modifier = + Modifier + .fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceEvenly, + verticalAlignment = + Alignment.CenterVertically + ) { + + GAME_REACTION_EMOJIS + .forEach { emoji -> + + Box( + modifier = + Modifier + .size( + 58.dp + ) + .border( + width = 1.dp, + color = + PokerPalette + .GoldDark, + shape = + CircleShape + ) + .clickable { + + val now = + System + .currentTimeMillis() + + if ( + now - + lastSendAt < + REACTION_SEND_COOLDOWN_MS + ) { + return@clickable + } + + lastSendAt = + now + + view.performHapticFeedback( + HapticFeedbackConstants + .CLOCK_TICK + ) + + GameReactionRepository + .sendReaction( + gameId = + gameId, + emoji = + emoji, + senderName = + senderName, + onError = { message -> + /* + * Пока тестируем реакции, + * показываем реальную причину сервер, + * а не безликое "не удалось". + */ + errorText = + message + } + ) + }, + contentAlignment = + Alignment.Center + ) { + + Image( + painter = + painterResource( + id = + reactionIconRes(emoji) + ), + contentDescription = + null, + contentScale = + ContentScale.Fit, + modifier = + Modifier.size( + 44.dp + ) + ) + } + } + } + + if ( + errorText != null + ) { + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + + Text( + text = + errorText!!, + color = + PokerPalette.Danger, + fontSize = + 13.sp + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/durakscore/app/GameRepository.kt b/app/src/main/java/ru/durakscore/app/GameRepository.kt new file mode 100644 index 0000000..6f4f5cb --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/GameRepository.kt @@ -0,0 +1,570 @@ +package ru.durakscore.app + +import java.util.Date + +enum class GameActionType { + ADD_POINT, + UNDO_LAST +} + +data class QueuedGameAction( + val type: GameActionType, + val playerId: String? = null +) + +data class LoadedGame( + val id: String, + val type: GameType, + val players: List, + val targetScore: Int, + val scores: Map, + val history: List, + val startedAt: Timestamp, + val status: String +) + +object GameRepository { + + private const val TAG = + "GameRepository" + + private fun currentUserOrError( + onError: (String) -> Unit + ) = + DurakAuthSession.current() + .also { + if ( + it == + null + ) { + onError( + "Пользователь не авторизован" + ) + } + } + + private fun toLoadedGame( + game: ServerLoadedGame + ): LoadedGame { + + val type = + if ( + game.gameType == + "classic" + ) { + GameType.CLASSIC + } else { + GameType.CUSTOM + } + + val players = + game.players + .map { + GamePlayer( + id = + it.id, + name = + it.name, + gender = + it.gender, + isCore = + it.isCore, + isGuest = + it.isGuest + ) + } + + val startedAt = + Timestamp( + Date( + game.startedAtEpochMillis + .coerceAtLeast( + 0L + ) + ) + ) + + return LoadedGame( + id = + game.id, + type = + type, + players = + players, + targetScore = + game.targetScore, + scores = + game.scores, + history = + game.history, + startedAt = + startedAt, + status = + game.status + ) + } + + // ======================================================== + // CREATE GAME + // ======================================================== + + fun createGame( + setup: GameSetup, + onSuccess: (LoadedGame) -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .createGame( + user = + user, + setup = + setup, + isTest = + TEST_MODE, + onSuccess = { + serverGame -> + + onSuccess( + toLoadedGame( + serverGame + ) + ) + }, + onError = + onError + ) + } + + // ======================================================== + // OPTIMISTIC ACTION QUEUE + // ======================================================== + + fun applyActions( + gameId: String, + actions: List, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + if ( + actions.isEmpty() + ) { + onSuccess() + return + } + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .applyGameActions( + user = + user, + gameId = + gameId, + actions = + actions, + onSuccess = { + onSuccess() + }, + onError = + onError + ) + } + + // ======================================================== + // LOAD CURRENT STATE — VPS FIRST, FIRESTORE FALLBACK + // ======================================================== + + fun loadState( + gameId: String, + onSuccess: ( + scores: Map, + history: List + ) -> Unit, + onError: (String) -> Unit + ) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + onError( + "Пользователь не авторизован" + ) + return + } + + DurakServerApi + .loadGameState( + user = + user, + gameId = + gameId, + onSuccess = { + state -> + + onSuccess( + state.scores, + state.history + ) + }, + onError = + onError + ) + } + + // ======================================================== + // LEGACY SINGLE ACTION METHODS — NOW WRAPPERS OVER VPS + // ======================================================== + + fun addPoint( + gameId: String, + playerId: String, + onSuccess: ( + scores: Map, + history: List + ) -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .applyGameActions( + user = + user, + gameId = + gameId, + actions = + listOf( + QueuedGameAction( + type = + GameActionType.ADD_POINT, + playerId = + playerId + ) + ), + onSuccess = { + state -> + + onSuccess( + state.scores, + state.history + ) + }, + onError = + onError + ) + } + + fun undoLastPoint( + gameId: String, + onSuccess: ( + scores: Map, + history: List + ) -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .applyGameActions( + user = + user, + gameId = + gameId, + actions = + listOf( + QueuedGameAction( + type = + GameActionType.UNDO_LAST + ) + ), + onSuccess = { + state -> + + onSuccess( + state.scores, + state.history + ) + }, + onError = + onError + ) + } + + // ======================================================== + // PAUSE / RESUME + // ======================================================== + + fun pauseGame( + gameId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .pauseGame( + user = + user, + gameId = + gameId, + onSuccess = + onSuccess, + onError = + onError + ) + } + + fun resumeGame( + gameId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .resumeGame( + user = + user, + gameId = + gameId, + onSuccess = + onSuccess, + onError = + onError + ) + } + + // ======================================================== + // STATUS — VPS FIRST, FIRESTORE FALLBACK + // ======================================================== + + fun loadGameStatus( + gameId: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit + ) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + onError( + "Пользователь не авторизован" + ) + return + } + + DurakServerApi + .loadGameStatus( + user = + user, + gameId = + gameId, + onSuccess = + onSuccess, + onError = + onError + ) + } + + // ======================================================== + // TARGET SCORE + // ======================================================== + + fun updateTargetScore( + gameId: String, + newTargetScore: Int, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + if ( + newTargetScore !in + 2..99 + ) { + onError( + "Лимит должен быть от 2 до 99" + ) + return + } + + val user = + currentUserOrError( + onError + ) + ?: return + + DurakServerApi + .updateTargetScore( + user = + user, + gameId = + gameId, + newTargetScore = + newTargetScore, + onSuccess = + onSuccess, + onError = + onError + ) + } + + // ======================================================== + // LATEST UNFINISHED — VPS FIRST, FIRESTORE FALLBACK + // ======================================================== + + fun loadLatestUnfinishedGame( + onSuccess: (LoadedGame?) -> Unit, + onError: (String) -> Unit + ) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + onError( + "Пользователь не авторизован" + ) + return + } + + DurakServerApi + .loadLatestUnfinishedGame( + user = + user, + isTest = + TEST_MODE, + onSuccess = { + game -> + + onSuccess( + game?.let { + toLoadedGame( + it + ) + } + ) + }, + onError = + onError + ) + } + + // ======================================================== + // FINISH — VPS, FIRESTORE MIRROR + POSTGRES + // ======================================================== + + fun finishGame( + gameId: String, + loserId: String, + finishedByUid: String, + finishedByName: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + /* + * finishedByUid / finishedByName оставлены в сигнатуре, + * чтобы не ломать существующий MainActivity. + * Сервер определяет пользователя по session token. + */ + DurakServerApi + .finishGame( + user = + user, + gameId = + gameId, + loserId = + loserId, + onSuccess = { + onSuccess() + }, + onError = + onError + ) + } + + // ======================================================== + // CANCEL — VPS, FIRESTORE AUDIT + POSTGRES + // ======================================================== + + fun cancelGame( + gameId: String, + cancelledByUid: String, + cancelledByName: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + val user = + currentUserOrError( + onError + ) + ?: return + + /* + * cancelledByUid / cancelledByName оставлены ради + * совместимости с существующим UI. Сервер берёт актёра + * из проверенного session token. + */ + DurakServerApi + .cancelGame( + user = + user, + gameId = + gameId, + onSuccess = + onSuccess, + onError = + onError + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/HistoryScreen.kt b/app/src/main/java/ru/durakscore/app/HistoryScreen.kt new file mode 100644 index 0000000..58f6d82 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/HistoryScreen.kt @@ -0,0 +1,1172 @@ +package ru.durakscore.app + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalDensity +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.sp +import java.util.Date +import kotlin.math.roundToInt + +data class HistoryGame( + val id: String, + val type: String, + val isTest: Boolean, + val targetScore: Int, + val players: List, + val finalScores: Map, + val loserId: String, + val loserName: String, + val loserGender: String, + val startedAt: Timestamp, + val finishedAt: Timestamp +) + +@Composable +fun HistoryScreen( + access: UserAccess, + onBack: () -> Unit +) { + + BackHandler( + onBack = + onBack + ) + + var games by remember { + mutableStateOf>( + emptyList() + ) + } + + var isLoading by remember { + mutableStateOf(true) + } + + var errorText by remember { + mutableStateOf( + null + ) + } + + LaunchedEffect(Unit) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + errorText = + "Пользователь не авторизован" + + isLoading = + false + + return@LaunchedEffect + } + + DurakServerApi + .loadFinishedGames( + user = + user, + onSuccess = { + serverGames -> + + val loadedGames = + serverGames + .mapNotNull { + game -> + + if ( + game.startedAtEpochMillis <= + 0L || + game.finishedAtEpochMillis <= + 0L + ) { + return@mapNotNull null + } + + HistoryGame( + id = + game.id, + type = + game.gameType, + isTest = + game.isTest, + targetScore = + game.targetScore, + players = + game.players.map { + player -> + + GamePlayer( + id = + player.id, + name = + player.name, + gender = + player.gender, + isCore = + player.isCore, + isGuest = + player.isGuest + ) + }, + finalScores = + game.finalScores, + loserId = + game.loserId, + loserName = + game.loserName, + loserGender = + game.loserGender, + startedAt = + Timestamp( + Date( + game.startedAtEpochMillis + ) + ), + finishedAt = + Timestamp( + Date( + game.finishedAtEpochMillis + ) + ) + ) + } + .sortedByDescending { + it.finishedAt.seconds + } + + games = + if ( + TEST_MODE + ) { + loadedGames + } else { + loadedGames + .filter { + !it.isTest + } + } + + errorText = + null + + isLoading = + false + }, + onError = { + message -> + + if ( + games.isEmpty() + ) { + errorText = + message.ifBlank { + "Не удалось загрузить историю" + } + } + + isLoading = + false + } + ) + } + + val myGames = + remember( + games, + access.playerId + ) { + games.filter { + game -> + game.players.any { + it.id == + access.playerId + } + } + } + + val myLosses = + remember( + myGames, + access.playerId + ) { + myGames.count { + it.loserId == + access.playerId + } + } + + val mySurvived = + ( + myGames.size - + myLosses + ) + .coerceAtLeast( + 0 + ) + + val survivedPercent = + if ( + myGames.isEmpty() + ) { + 0 + } else { + ( + mySurvived.toDouble() / + myGames.size.toDouble() * + 100.0 + ) + .roundToInt() + } + + PokerScreen { + + PokerBackTextButton( + text = + "← Назад", + onClick = + onBack + ) + + Spacer( + modifier = + Modifier.height( + 4.dp + ) + ) + + Text( + text = + "ИСТОРИЯ", + color = + PokerPalette.TextPrimary, + fontSize = + 31.sp, + fontFamily = + PokerDisplayFont, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + HistoryHero() + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + HistorySectionTitle( + text = + "НЕДАВНИЕ ПАРТИИ" + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + when { + + isLoading -> { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.Center + ) { + + CircularProgressIndicator( + color = + PokerPalette.CrimsonBright + ) + } + } + + errorText != null -> { + + PokerPanel { + + Text( + text = + errorText!!, + color = + PokerPalette.Danger, + fontSize = + 15.sp + ) + } + } + + games.isEmpty() -> { + + PokerPanel { + + Text( + text = + "Завершённых партий пока нет. Позор ещё не зафиксирован.", + color = + PokerPalette.TextPrimary, + fontSize = + 15.sp + ) + } + } + + else -> { + + games.forEach { + game -> + + HistoryGothicCard( + game = + game + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + } + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + HistorySectionTitle( + text = + "ТВОЯ ИСТОРИЯ В ЦИФРАХ" + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + HistorySummary( + played = + myGames.size, + survived = + mySurvived, + losses = + myLosses, + survivedPercent = + survivedPercent + ) + } + } + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + PokerBottomBackButton( + onClick = + onBack + ) + } +} + +@Composable +private fun HistoryHero() { + val fontScale = LocalDensity.current.fontScale + val heroMinHeight = + when { + fontScale >= 1.30f -> 220.dp + fontScale >= 1.15f -> 198.dp + else -> 180.dp + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight), + color = Color(0xF2070908), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = RoundedCornerShape(18.dp) + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight) + ) { + Image( + painter = + painterResource( + id = R.drawable.gothic_history_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(195.dp) + ) + + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(225.dp) + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + Color(0xF2070908), + Color(0xB0070908), + Color.Transparent + ) + ) + ) + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = 17.dp, + top = 22.dp, + end = + if (fontScale >= 1.25f) { + 76.dp + } else { + 140.dp + }, + bottom = 14.dp + ) + ) { + Text( + text = "ВСЕ ПАРТИИ —\nВСЕ ПОЗОРЫ", + color = PokerPalette.CrimsonBright, + fontSize = + if (fontScale >= 1.25f) { + 18.sp + } else { + 20.sp + }, + lineHeight = 24.sp, + fontFamily = PokerDisplayFont, + fontWeight = FontWeight.Black, + maxLines = 4, + softWrap = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Архив побед над судьбой\nи официальных поражений", + color = PokerPalette.Gold, + fontSize = 13.sp, + lineHeight = 18.sp, + maxLines = 4, + softWrap = true + ) + } + } + } +} + +@Composable +private fun HistorySectionTitle( + text: String +) { + + Row( + modifier = + Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + + Box( + modifier = + Modifier + .weight( + 1f + ) + .height( + 1.dp + ) + .background( + PokerPalette.GoldDark + ) + ) + + Text( + text = + text, + modifier = + Modifier.padding( + horizontal = + 10.dp + ), + color = + PokerPalette.CrimsonBright, + fontSize = + 13.sp, + fontWeight = + FontWeight.Black, + letterSpacing = + 1.2.sp + ) + + Box( + modifier = + Modifier + .weight( + 1f + ) + .height( + 1.dp + ) + .background( + PokerPalette.GoldDark + ) + ) + } +} + +@Composable +private fun HistoryGothicCard( + game: HistoryGame +) { + + val loserWord = + if ( + game.loserGender == + "female" + ) { + "проиграла" + } else { + "проиграл" + } + + Surface( + modifier = + Modifier.fillMaxWidth(), + color = + Color( + 0xF2070B09 + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = + RoundedCornerShape( + 16.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 13.dp + ) + ) { + + Row( + modifier = + Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + + Image( + painter = + painterResource( + id = + if ( + game.type == + "classic" + ) { + R.drawable.gothic_history_classic + } else { + R.drawable.gothic_history_cards + } + ), + contentDescription = + null, + contentScale = + ContentScale.Fit, + modifier = + Modifier.size( + 50.dp + ) + ) + + Spacer( + modifier = + Modifier.width( + 9.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + if ( + game.type == + "classic" + ) { + "КЛАССИЧЕСКАЯ ПАРТИЯ" + } else { + "ДРУГОЙ СОСТАВ" + }, + color = + PokerPalette.TextPrimary, + fontSize = + 13.sp, + fontWeight = + FontWeight.Black, + maxLines = + 2, + softWrap = + true, + overflow = + TextOverflow.Ellipsis + ) + + Spacer( + modifier = + Modifier.height( + 2.dp + ) + ) + + Text( + text = + formatMoscowHistoryPeriod( + game.startedAt, + game.finishedAt + ), + color = + PokerPalette.Gold, + fontSize = + 10.5.sp, + maxLines = + 2 + ) + } + + if ( + game.isTest + ) { + + PokerBadge( + text = + "ТЕСТ" + ) + } + } + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth() + ) { + val singleColumn = + maxWidth < 335.dp || + LocalDensity.current.fontScale >= 1.28f + + if (singleColumn) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy(6.dp) + ) { + game.players.forEach { player -> + HistoryScoreCell( + name = + if (player.isGuest) { + "${player.name} · гость" + } else { + player.name + }, + score = + game.finalScores[player.id] ?: 0, + isLoser = + player.id == game.loserId, + modifier = Modifier.fillMaxWidth() + ) + } + } + } else { + Column( + modifier = Modifier.fillMaxWidth() + ) { + game.players + .chunked(2) + .forEach { rowPlayers -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(7.dp) + ) { + rowPlayers.forEach { player -> + HistoryScoreCell( + name = + if (player.isGuest) { + "${player.name} · гость" + } else { + player.name + }, + score = + game.finalScores[player.id] ?: 0, + isLoser = + player.id == game.loserId, + modifier = Modifier.weight(1f) + ) + } + + if (rowPlayers.size == 1) { + Spacer( + modifier = Modifier.weight(1f) + ) + } + } + + Spacer( + modifier = Modifier.height(6.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height( + 1.dp + ) + .background( + Color( + 0xFF2A2419 + ) + ) + ) + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + + Row( + modifier = + Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + + Image( + painter = + painterResource( + id = + R.drawable.gothic_history_loser + ), + contentDescription = + null, + modifier = + Modifier.size( + 42.dp + ), + contentScale = + ContentScale.Fit + ) + + Spacer( + modifier = + Modifier.width( + 8.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + "ДУРАК: ${game.loserName}", + color = + PokerPalette.CrimsonBright, + fontSize = + 14.sp, + fontWeight = + FontWeight.Black + ) + + Text( + text = + "${game.loserName} $loserWord • лимит ${game.targetScore}", + color = + PokerPalette.TextSecondary, + fontSize = + 10.sp + ) + } + } + } + } +} + +@Composable +private fun HistoryScoreCell( + name: String, + score: Int, + isLoser: Boolean, + modifier: Modifier = Modifier +) { + + Surface( + modifier = + modifier, + color = + if ( + isLoser + ) { + Color( + 0xFF220A09 + ) + } else { + Color( + 0xFF080D0B + ) + }, + border = + BorderStroke( + 1.dp, + if ( + isLoser + ) { + PokerPalette.Crimson + } else { + Color( + 0xFF2A342D + ) + } + ), + shape = + RoundedCornerShape( + 11.dp + ) + ) { + + Row( + modifier = + Modifier.padding( + horizontal = + 9.dp, + vertical = + 7.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + Text( + text = + name, + color = + if ( + isLoser + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.TextPrimary + }, + fontSize = + 10.5.sp, + fontWeight = + FontWeight.SemiBold, + maxLines = + 2, + softWrap = + true, + overflow = + TextOverflow.Ellipsis, + modifier = + Modifier.weight( + 1f + ) + ) + + Text( + text = + score.toString(), + color = + if ( + isLoser + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.GoldBright + }, + fontSize = + 15.sp, + fontWeight = + FontWeight.Black + ) + } + } +} + +@Composable +private fun HistorySummary( + played: Int, + survived: Int, + losses: Int, + survivedPercent: Int +) { + + val items = + listOf( + Triple( + R.drawable.gothic_history_crown, + "ПАРТИЙ", + played.toString() + ), + Triple( + R.drawable.gothic_history_survivor, + "ВЫЖИЛ", + survived.toString() + ), + Triple( + R.drawable.gothic_history_skull, + "ПОРАЖЕНИЙ", + losses.toString() + ), + Triple( + R.drawable.gothic_history_card, + "НЕ БЫЛ ДУРАКОМ", + "$survivedPercent%" + ) + ) + + Surface( + modifier = + Modifier.fillMaxWidth(), + color = + Color( + 0xF2070B09 + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = + RoundedCornerShape( + 16.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 10.dp + ), + verticalArrangement = + Arrangement.spacedBy( + 8.dp + ) + ) { + + items + .chunked( + 2 + ) + .forEach { + rowItems -> + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy( + 8.dp + ) + ) { + + rowItems.forEach { + (icon, label, value) -> + + Surface( + modifier = + Modifier.weight( + 1f + ), + color = + Color( + 0xFF070B09 + ), + border = + BorderStroke( + 1.dp, + Color( + 0xFF2A2419 + ) + ), + shape = + RoundedCornerShape( + 12.dp + ) + ) { + + Row( + modifier = + Modifier.padding( + 9.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + Image( + painter = + painterResource( + id = + icon + ), + contentDescription = + null, + modifier = + Modifier.size( + 42.dp + ), + contentScale = + ContentScale.Fit + ) + + Spacer( + modifier = + Modifier.width( + 7.dp + ) + ) + + Column { + + Text( + text = + value, + color = + PokerPalette.GoldBright, + fontSize = + 20.sp, + fontWeight = + FontWeight.Black + ) + + Text( + text = + label, + color = + PokerPalette.TextSecondary, + fontSize = + 8.5.sp, + fontWeight = + FontWeight.Bold, + maxLines = + 2 + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/LiveGameScreen.kt b/app/src/main/java/ru/durakscore/app/LiveGameScreen.kt new file mode 100644 index 0000000..89b40c2 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/LiveGameScreen.kt @@ -0,0 +1,1160 @@ +package ru.durakscore.app + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Date + +data class LiveGame( + val id: String, + val type: String, + val status: String, + val targetScore: Int, + val players: List, + val scores: Map, + val history: List, + val startedAt: Timestamp, + val lastActionType: String? = null, + val lastActionAt: Timestamp? = null, + val finishedAt: Timestamp? = null, + val loserId: String? = null, + val loserName: String? = null, + val loserGender: String? = null +) + +private fun parseLiveGame( + game: ServerLiveGame? +): LiveGame? { + + game + ?: return null + + if ( + game.isTest != + TEST_MODE + ) { + return null + } + + if ( + game.startedAtEpochMillis <= + 0L + ) { + return null + } + + val scoreSource = + if ( + game.status == + "finished" && + game.finalScores + .isNotEmpty() + ) { + game.finalScores + } else { + game.scores + } + + return LiveGame( + id = + game.id, + type = + game.gameType, + status = + game.status, + targetScore = + game.targetScore, + players = + game.players.map { + player -> + + GamePlayer( + id = + player.id, + name = + player.name, + gender = + player.gender, + isCore = + player.isCore, + isGuest = + player.isGuest + ) + }, + scores = + scoreSource, + history = + game.history, + startedAt = + Timestamp( + Date( + game.startedAtEpochMillis + ) + ), + lastActionType = + game.lastActionType, + lastActionAt = + game.lastActionAtEpochMillis + ?.let { + Timestamp( + Date( + it + ) + ) + }, + finishedAt = + game.finishedAtEpochMillis + ?.let { + Timestamp( + Date( + it + ) + ) + }, + loserId = + game.loserId, + loserName = + game.loserName, + loserGender = + game.loserGender + ) +} + +@Composable +fun LiveGameScreen( + access: UserAccess, + onResultReached: (String) -> Unit, + onBack: () -> Unit +) { + + // Системная кнопка Android "Назад" + // возвращает в меню, а не закрывает приложение. + BackHandler( + enabled = true, + onBack = onBack + ) + + var activeGame by remember { + mutableStateOf(null) + } + + var lastFinishedGame by remember { + mutableStateOf(null) + } + + var activeLoaded by remember { + mutableStateOf(false) + } + + var finishedLoaded by remember { + mutableStateOf(false) + } + + var errorText by remember { + mutableStateOf(null) + } + + var observedGameId by remember { + mutableStateOf(null) + } + + var resultNavigationGameId by remember { + mutableStateOf(null) + } + + DisposableEffect(Unit) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + activeLoaded = + true + + finishedLoaded = + true + + errorText = + "Пользователь не авторизован" + + onDispose { + } + + } else { + + val registration = + DurakServerApi + .startLivePolling( + user = + user, + isTest = + TEST_MODE, + onSnapshot = { + snapshot -> + + val newActive = + parseLiveGame( + snapshot + .activeGame + ) + + val newFinished = + parseLiveGame( + snapshot + .lastFinishedGame + ) + + activeGame = + newActive + + lastFinishedGame = + newFinished + + if ( + newActive != + null + ) { + observedGameId = + newActive.id + } + + activeLoaded = + true + + finishedLoaded = + true + + errorText = + null + }, + onError = { + message -> + + activeLoaded = + true + + finishedLoaded = + true + + if ( + activeGame == + null && + lastFinishedGame == + null + ) { + errorText = + message.ifBlank { + "Не удалось получить текущую партию" + } + } + } + ) + + onDispose { + registration.remove() + } + } + } + + /* + * Как только в live-счёте кто-то достигает лимита, + * наблюдатель сразу уходит на общий экран результата. + * Не ждём, пока ведущий нажмёт "Завершить партию". + */ + LaunchedEffect( + activeGame?.id, + activeGame?.scores, + activeGame?.targetScore + ) { + + val game = + activeGame + ?: return@LaunchedEffect + + val reachedLimit = + game.scores + .values + .any { + it >= + game.targetScore + } + + if ( + reachedLimit && + resultNavigationGameId != + game.id + ) { + resultNavigationGameId = + game.id + + onResultReached( + game.id + ) + } + } + + /* + * Запасной путь: если сеть прислала finished раньше, + * чем мы успели увидеть последний active snapshot. + */ + LaunchedEffect( + activeGame?.id, + lastFinishedGame?.id, + observedGameId + ) { + + val watchedId = + observedGameId + ?: return@LaunchedEffect + + if ( + activeGame == null && + lastFinishedGame?.id == + watchedId && + resultNavigationGameId != + watchedId + ) { + resultNavigationGameId = + watchedId + + onResultReached( + watchedId + ) + } + } + + val isLoading = + !activeLoaded || + !finishedLoaded + + Box( + modifier = + Modifier.fillMaxSize() + ) { + + PokerScreen { + + // Верхнюю кнопку "Назад" убрали. + // Навигация — системная кнопка Android + // и большая кнопка "В меню" снизу. + + PokerHeader( + title = "Наблюдение за столом", + subtitle = "Живой счёт, последние очки и весь бардак в реальном времени" + ) + + Spacer( + modifier = + Modifier.height(16.dp) + ) + + LiveObservationHero( + activeGame = activeGame, + finishedGame = lastFinishedGame + ) + + Spacer( + modifier = + Modifier.height(18.dp) + ) + + when { + + isLoading -> { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.Center + ) { + + CircularProgressIndicator( + color = + PokerPalette.Gold + ) + } + } + + activeGame != null -> { + + CurrentLiveGameContent( + game = activeGame!!, + senderName = + access.name + ) + } + + lastFinishedGame != null -> { + + FinishedLiveGameContent( + game = + lastFinishedGame!! + ) + } + + errorText != null -> { + + PokerPanel { + + Text( + text = + errorText!!, + color = + PokerPalette.Danger, + fontSize = + 17.sp + ) + } + } + + else -> { + + PokerPanel { + + Text( + text = + "Сейчас активной партии нет", + color = + PokerPalette.TextPrimary, + fontSize = + 21.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(8.dp) + ) + + Text( + text = + "Когда партия начнётся, счёт появится здесь автоматически.", + color = + PokerPalette.TextSecondary, + fontSize = + 15.sp + ) + } + } + } + + Spacer( + modifier = + Modifier.height(22.dp) + ) + + PokerSecondaryButton( + text = "← В меню", + onClick = onBack + ) + } + + if (activeGame != null) { + ReactionRainHost( + gameId = + activeGame!!.id, + modifier = + Modifier.fillMaxSize() + ) + } + } +} + +@Composable +private fun LiveObservationHero( + activeGame: LiveGame?, + finishedGame: LiveGame? +) { + + val heroTitle = + when { + activeGame?.status == "paused" -> + "Стол замер. Джокер караулит." + + activeGame != null -> + "Джокер видит каждое очко" + + finishedGame != null -> + "Раздача добита. Джокер помнит всё." + + else -> + "Джокер ждёт новую жертву" + } + + val heroSubtitle = + when { + activeGame?.status == "paused" -> + "Партия на паузе, но стол никуда не делся. Как только движ пойдёт дальше, ты увидишь это первым." + + activeGame != null -> + "Следи, кому залетает очередное очко, кто уже начинает пылать и кому скоро будет совсем не до смеха." + + finishedGame != null -> + "Активной катки сейчас нет, но последняя раздача уже оставила после себя красивый финал и итоговый счёт." + + else -> + "Пока за столом тихо. Как только кто-то запустит новую игру, экран оживёт автоматически." + } + + val badgeText = + when { + activeGame?.status == "paused" -> + "СТОЛ НА ПАУЗЕ" + + activeGame != null -> + "ЖИВОЕ НАБЛЮДЕНИЕ" + + finishedGame != null -> + "ПОСЛЕДНИЙ РЕЗУЛЬТАТ" + + else -> + "ОЖИДАНИЕ НОВОЙ ИГРЫ" + } + + val borderColor = + when { + activeGame != null -> + PokerPalette.Crimson + + finishedGame != null -> + PokerPalette.GoldDark + + else -> + PokerPalette.PanelBorder + } + + Card( + modifier = + Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + Color(0xF2080A09) + ), + border = + BorderStroke( + width = 1.2.dp, + color = borderColor + ), + shape = + RoundedCornerShape(22.dp) + ) { + + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF070908), + Color(0xFF140708), + Color(0xFF24090C) + ) + ) + ) + .padding(18.dp) + ) { + + Image( + painter = + painterResource( + id = + R.drawable.watch_hero_jester + ), + contentDescription = + null, + contentScale = + ContentScale.Fit, + modifier = + Modifier + .fillMaxWidth() + .height(212.dp) + .align(Alignment.CenterEnd) + .alpha(0.90f) + ) + + Box( + modifier = + Modifier + .fillMaxWidth(0.74f) + .height(212.dp) + .background( + brush = + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF090A09), + Color(0xFF0B0909), + Color(0xE50C0808), + Color(0x700C0808), + Color.Transparent + ) + ) + ) + ) + + androidx.compose.foundation.layout.Column( + modifier = + Modifier + .fillMaxWidth(0.58f) + .padding(top = 4.dp, bottom = 4.dp), + verticalArrangement = + Arrangement.Center + ) { + + PokerBadge( + text = badgeText + ) + + Spacer( + modifier = + Modifier.height(14.dp) + ) + + Text( + text = heroTitle, + color = PokerPalette.TextPrimary, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + lineHeight = 28.sp + ) + + Spacer( + modifier = + Modifier.height(8.dp) + ) + + Text( + text = heroSubtitle, + color = PokerPalette.TextSecondary, + fontSize = 14.sp, + lineHeight = 19.sp + ) + } + } + } +} + +@Composable +private fun CurrentLiveGameContent( + game: LiveGame, + senderName: String +) { + + val lastPointPlayerId = + game.history.lastOrNull() + + val lastPointPlayer = + game.players.firstOrNull { + it.id == lastPointPlayerId + } + + + PokerPanel { + + Text( + text = + if (game.type == "classic") { + "Классическая партия" + } else { + "Другой состав" + }, + color = + PokerPalette.Gold, + fontSize = + 22.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(8.dp) + ) + + Text( + text = + "Начало: ${formatMoscowDateTime(game.startedAt)}", + color = + PokerPalette.TextSecondary, + fontSize = + 14.sp + ) + + Spacer( + modifier = + Modifier.height(6.dp) + ) + + Text( + text = + "Играем до ${game.targetScore}", + color = + PokerPalette.TextPrimary, + fontSize = + 18.sp, + fontWeight = + FontWeight.SemiBold + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + + PokerBadge( + text = + if (game.status == "paused") { + "ПАУЗА" + } else { + "ИДЁТ СЕЙЧАС" + } + ) + + if (lastPointPlayer != null) { + + Spacer( + modifier = + Modifier.height(12.dp) + ) + + Text( + text = + "Последнее очко — ${lastPointPlayer.name}", + color = + PokerPalette.Gold, + fontSize = + 16.sp, + fontWeight = + FontWeight.Bold + ) + } + } + + val latestActionAgeMs = + game.lastActionAt + ?.let { actionAt -> + ( + System.currentTimeMillis() - + actionAt.toDate().time + ).coerceAtLeast(0L) + } + + QueuedStreakPraiseBanner( + gameKey = + game.id, + players = + game.players.map { + it.id to it.name + }, + history = + game.history, + includeLatestOnStart = + game.lastActionType == + "add_point" && + latestActionAgeMs != null && + latestActionAgeMs < + STREAK_PRAISE_DURATION_MS, + topSpacingDp = + 14 + ) + + Spacer( + modifier = + Modifier.height(18.dp) + ) + + game.players.forEach { player -> + + val isLastPoint = + player.id == + lastPointPlayerId + + LiveScoreCard( + player = player, + score = + game.scores[player.id] + ?: 0, + targetScore = + game.targetScore, + isLastPoint = + isLastPoint + ) + + Spacer( + modifier = + Modifier.height(12.dp) + ) + } + + Spacer( + modifier = + Modifier.height(4.dp) + ) + + StyledReactionBar( + gameId = + game.id, + senderName = + senderName + ) + + Spacer( + modifier = + Modifier.height(12.dp) + ) + + Text( + text = + "Счёт обновляется автоматически", + color = + PokerPalette.TextSecondary, + fontSize = + 14.sp + ) +} + +@Composable +fun StyledReactionBar( + gameId: String, + senderName: String +) { + /* + * Только делегируем в настоящий ReactionBar. + * Вся логика отправки, cooldown и haptic + * остаётся внутри GameReactions.kt. + * Сеть теперь идёт через VPS/PostgreSQL. + */ + ReactionBar( + gameId = gameId, + senderName = senderName + ) +} + +@Composable +private fun FinishedLiveGameContent( + game: LiveGame +) { + + val loser = + game.players.firstOrNull { + it.id == game.loserId + } + + val loserName = + game.loserName + ?: loser?.name + ?: "Игрок" + + val loserGender = + game.loserGender + ?: loser?.gender + ?: "male" + + val loserWord = + if (loserGender == "female") { + "проиграла" + } else { + "проиграл" + } + + PokerPanel { + + PokerBadge( + text = + "ПАРТИЯ ОКОНЧЕНА" + ) + + Spacer( + modifier = + Modifier.height(14.dp) + ) + + Text( + text = + "$loserName $loserWord", + color = + PokerPalette.Gold, + fontSize = + 28.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(10.dp) + ) + + Text( + text = + if (game.type == "classic") { + "Классическая партия" + } else { + "Другой состав" + }, + color = + PokerPalette.TextPrimary, + fontSize = + 17.sp, + fontWeight = + FontWeight.SemiBold + ) + + Spacer( + modifier = + Modifier.height(6.dp) + ) + + Text( + text = + "Начало: ${formatMoscowDateTime(game.startedAt)}", + color = + PokerPalette.TextSecondary, + fontSize = + 14.sp + ) + + if (game.finishedAt != null) { + + Spacer( + modifier = + Modifier.height(4.dp) + ) + + Text( + text = + "Завершена: ${formatMoscowDateTime(game.finishedAt)}", + color = + PokerPalette.TextSecondary, + fontSize = + 14.sp + ) + } + } + + Spacer( + modifier = + Modifier.height(18.dp) + ) + + Text( + text = + "Итоговый счёт", + color = + PokerPalette.Gold, + fontSize = + 20.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height(12.dp) + ) + + game.players.forEach { player -> + + FinishedScoreCard( + player = player, + score = + game.scores[player.id] + ?: 0, + isLoser = + player.id == + game.loserId + ) + + Spacer( + modifier = + Modifier.height(12.dp) + ) + } + + Text( + text = + "Результат сохранён в истории партий", + color = + PokerPalette.TextSecondary, + fontSize = + 14.sp + ) +} + +@Composable +private fun LiveScoreCard( + player: GamePlayer, + score: Int, + targetScore: Int, + isLastPoint: Boolean +) { + + PokerPanel { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + + Text( + text = + if (player.isGuest) { + "${player.name} · гость" + } else { + player.name + }, + color = + if (isLastPoint) { + PokerPalette.Gold + } else { + PokerPalette.TextPrimary + }, + fontSize = + 22.sp, + fontWeight = + FontWeight.Bold + ) + + AnimatedScoreValue( + score = + score, + targetScore = + targetScore, + color = + if (isLastPoint) { + PokerPalette.Gold + } else { + PokerPalette.TextPrimary + } + ) + } + + if (isLastPoint) { + + Spacer( + modifier = + Modifier.height(8.dp) + ) + + PokerBadge( + text = + "ПОСЛЕДНЕЕ ОЧКО" + ) + } + } +} + +@Composable +private fun FinishedScoreCard( + player: GamePlayer, + score: Int, + isLoser: Boolean +) { + + PokerPanel { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.SpaceBetween, + verticalAlignment = + Alignment.CenterVertically + ) { + + Text( + text = + if (player.isGuest) { + "${player.name} · гость" + } else { + player.name + }, + color = + if (isLoser) { + PokerPalette.Gold + } else { + PokerPalette.TextPrimary + }, + fontSize = + 22.sp, + fontWeight = + FontWeight.Bold + ) + + Text( + text = + score.toString(), + color = + if (isLoser) { + PokerPalette.Gold + } else { + PokerPalette.TextPrimary + }, + fontSize = + 32.sp, + fontWeight = + FontWeight.Bold + ) + } + + if (isLoser) { + + Spacer( + modifier = + Modifier.height(8.dp) + ) + + PokerBadge( + text = + "ПРОИГРАЛ(А)" + ) + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/MainActivity.kt b/app/src/main/java/ru/durakscore/app/MainActivity.kt new file mode 100644 index 0000000..52a8d07 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/MainActivity.kt @@ -0,0 +1,3710 @@ +package ru.durakscore.app + +import android.os.Bundle +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.view.HapticFeedbackConstants +import android.widget.Toast +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.compose.BackHandler +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import ru.durakscore.app.ui.theme.DurakScoreTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material3.CircularProgressIndicator +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + + +class MainActivity : ComponentActivity() { + + companion object { + private const val UPDATE_LOG_TAG = "DurakUpdate" + private const val VPS_LATEST_URL = + "https://5.188.21.226/updates/latest.json" + } + + private val vpsUpdateStarted = + AtomicBoolean(false) + + private var availableVpsRelease by + mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Тёмный фон окна убирает белую вспышку до первого Compose-кадра. + window.setBackgroundDrawable( + ColorDrawable(0xFF000202.toInt()) + ) + + DurakAuthSession.initialize(applicationContext) + + startVpsUpdateCheck() + + enableEdgeToEdge() + + setContent { + + DurakScoreTheme { + + DurakometrMaterialTheme { + + Box( + modifier = + Modifier.fillMaxSize() + ) { + + var splashFinished by rememberSaveable { + mutableStateOf(false) + } + + LaunchedEffect(Unit) { + if (!splashFinished) { + delay(2_000L) + splashFinished = true + } + } + + if (!splashFinished) { + AppStartupSplash() + } else { + + var currentUser by remember { + mutableStateOf( + DurakAuthSession.current() + ) + } + + if ( + currentUser == + null + ) { + + AuthScreen( + onAuthSuccess = { + session -> + + DurakAuthSession.save( + session + ) + + currentUser = + session + } + ) + + } else { + + UserAccessGate( + user = + currentUser!!, + onLogout = { + + currentUser + ?.let { + DurakServerApi.logout( + it + ) + } + + DurakAuthSession.clear() + + currentUser = + null + } + ) + } + } + + availableVpsRelease + ?.let { release -> + + AlertDialog( + onDismissRequest = { + if (!release.mandatory) { + availableVpsRelease = + null + } + }, + title = { + Text( + "Доступно обновление" + ) + }, + text = { + Column { + Text( + "Версия ${release.versionName}" + ) + + if ( + release.notes + .isNotBlank() + ) { + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + Text( + release.notes + ) + } + } + }, + confirmButton = { + Button( + onClick = { + openUpdateInBrowser( + release.apkUrl + ) + } + ) { + Text( + "Скачать обновление" + ) + } + }, + dismissButton = { + if ( + !release.mandatory + ) { + TextButton( + onClick = { + availableVpsRelease = + null + } + ) { + Text( + "Позже" + ) + } + } + } + ) + } + } + } + } + } + } + + private fun startVpsUpdateCheck() { + + if ( + !vpsUpdateStarted + .compareAndSet( + false, + true + ) + ) { + return + } + + thread( + name = + "DurakVpsUpdateCheck", + isDaemon = + true + ) { + + try { + + val release = + loadVpsRelease() + + val currentCode = + currentVersionCode() + + Log.d( + UPDATE_LOG_TAG, + "VPS version=${release.versionName} (${release.versionCode}), current=$currentCode" + ) + + if ( + release.versionCode <= + currentCode + ) { + return@thread + } + + runOnUiThread { + availableVpsRelease = + release + } + + } catch ( + error: Throwable + ) { + + Log.e( + UPDATE_LOG_TAG, + "VPS update check failed", + error + ) + + runOnUiThread { + + Toast.makeText( + this@MainActivity, + "Не удалось проверить обновление на сервере", + Toast.LENGTH_LONG + ).show() + } + } + } + } + + + private fun loadVpsRelease(): VpsRelease { + + val connection = + URL(VPS_LATEST_URL) + .openConnection() as HttpURLConnection + + try { + connection.requestMethod = + "GET" + + connection.connectTimeout = + 10_000 + + connection.readTimeout = + 10_000 + + connection.useCaches = + false + + connection.setRequestProperty( + "Cache-Control", + "no-cache" + ) + + connection.setRequestProperty( + "Pragma", + "no-cache" + ) + + connection.setRequestProperty( + "Accept", + "application/json" + ) + + val responseCode = + connection.responseCode + + if ( + responseCode !in + 200..299 + ) { + error( + "latest.json HTTP $responseCode" + ) + } + + val jsonText = + connection + .inputStream + .bufferedReader() + .use { + it.readText() + } + + val json = + JSONObject(jsonText) + + val versionCode = + json.optLong( + "versionCode", + -1L + ) + + val versionName = + json.optString( + "versionName", + "" + ) + .trim() + + val apkUrl = + json.optString( + "apkUrl", + "" + ) + .trim() + + if (versionCode < 1L) { + error("Некорректный versionCode в latest.json") + } + + if (versionName.isBlank()) { + error("Нет versionName в latest.json") + } + + if (apkUrl.isBlank()) { + error("Нет apkUrl в latest.json") + } + + return VpsRelease( + versionCode = + versionCode, + versionName = + versionName, + apkUrl = + apkUrl, + mandatory = + json.optBoolean( + "mandatory", + false + ), + notes = + json.optString( + "notes", + "" + ) + ) + + } finally { + connection.disconnect() + } + } + + private fun currentVersionCode(): Long { + + val packageInfo = + packageManager.getPackageInfo( + packageName, + 0 + ) + + return if ( + Build.VERSION.SDK_INT >= + Build.VERSION_CODES.P + ) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + } + + private fun openUpdateInBrowser( + apkUrl: String + ) { + + try { + + val uri = + Uri.parse( + apkUrl + ) + + if ( + !uri.scheme.equals( + "https", + ignoreCase = true + ) + ) { + error( + "Update URL must use HTTPS" + ) + } + + startActivity( + Intent( + Intent.ACTION_VIEW, + uri + ) + ) + + availableVpsRelease = + null + + } catch ( + error: Throwable + ) { + + Log.e( + UPDATE_LOG_TAG, + "Failed to open update URL", + error + ) + + Toast.makeText( + this, + "Не удалось открыть ссылку на обновление", + Toast.LENGTH_LONG + ).show() + } + } + + private data class VpsRelease( + val versionCode: Long, + val versionName: String, + val apkUrl: String, + val mandatory: Boolean, + val notes: String + ) + +} + +@Composable +private fun AppStartupSplash() { + + /* + * Отдельная полноэкранная картинка, а не launcher icon. + * Файл должен лежать здесь: + * app/src/main/res/drawable/splash_durakometr.png + */ + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Color.Black + ) + ) { + + Image( + painter = + painterResource( + id = + R.drawable.splash_durakometr + ), + contentDescription = + "Дуракометр", + // Не режем арт на телефонах с немного разным соотношением сторон. + // На типичном 9:20 он занимает весь экран без полос. + contentScale = + ContentScale.Fit, + modifier = + Modifier.fillMaxSize() + ) + } +} + +enum class AppScreen { + MENU, + NEW_GAME, + GAME, + USERS, + HISTORY, + ACHIEVEMENTS, + LIVE_GAME, + FINAL_GAME, + MAIN_SCORE, + AUDIT_LOG, + DATA_AUDIT +} + +@Composable +fun DurakScoreApp( + access: UserAccess, + onLogout: () -> Unit +) { + var isGameActionPending by remember { + mutableStateOf(false) + } + var pendingFinishLoserIndex by remember { + mutableStateOf(null) + } + + var finishRequestSent by remember { + mutableStateOf(false) + } + var isFinishingGame by remember { + mutableStateOf(false) + } + var isRestoringGame by remember { + mutableStateOf(true) + } + val gameActionQueue = remember { + mutableStateListOf() + } + + var isProcessingGameQueue by remember { + mutableStateOf(false) + } + + var gameActionError by remember { + mutableStateOf(null) + } + var currentGameId by remember { + mutableStateOf(null) + } + + var finalGameId by remember { + mutableStateOf(null) + } + + var isCreatingGame by remember { + mutableStateOf(false) + } + + var gameCreationError by remember { + mutableStateOf(null) + } + + var gameStartedAt by remember { + mutableStateOf(null) + } + + var isResumedGame by remember { + mutableStateOf(false) + } + + var currentGamePlayers by remember { + mutableStateOf>(emptyList()) + } + + var currentGameType by remember { + mutableStateOf(null) + } + + var currentScreen by remember { + mutableStateOf(AppScreen.MENU) + } + + var targetScore by remember { + mutableIntStateOf(15) + } + + val scores = remember { + mutableStateListOf() + } + + val roundHistory = remember { + mutableStateListOf() + } + + var gameActive by remember { + mutableStateOf(false) + } + + var loserIndex by remember { + mutableStateOf(null) + } + + var showCancelGameDialog by remember { + mutableStateOf(false) + } + + var cancelGameText by remember { + mutableStateOf("") + } + + var isCancelingGame by remember { + mutableStateOf(false) + } + + // Крупный текст для Дениса и Дмитрия. + // Настройка сохраняется локально на конкретном телефоне. + val context = LocalContext.current + val uiPrefs = remember { + context.getSharedPreferences( + "durakscore_ui", + Context.MODE_PRIVATE + ) + } + + val canUseLargeText = + access.playerId == "denis" || + access.playerId == "dmitry" + + var largeTextEnabled by remember(access.playerId) { + mutableStateOf( + if (canUseLargeText) { + uiPrefs.getBoolean( + "large_text_${access.playerId}", + false + ) + } else { + false + } + ) + } + + val baseDensity = LocalDensity.current + fun applyServerGameState( + serverScores: Map, + serverHistory: List + ) { + + scores.clear() + + currentGamePlayers.forEach { player -> + + scores.add( + serverScores[player.id] ?: 0 + ) + + } + + val restoredLoserIndex = + currentGamePlayers.indexOfFirst { player -> + + (serverScores[player.id] ?: 0) >= + targetScore + } + + loserIndex = + if (restoredLoserIndex >= 0) { + restoredLoserIndex + } else { + null + } + + + + roundHistory.clear() + + serverHistory.forEach { playerId -> + + val index = + currentGamePlayers.indexOfFirst { + it.id == playerId + } + + if (index >= 0) { + roundHistory.add(index) + } + } + } + + + fun syncGameActionQueue() { + + if ( + isProcessingGameQueue || + gameActionQueue.isEmpty() + ) { + return + } + + val gameId = + currentGameId ?: return + + + // Берём всё, что успели натапать. + val actionsToSend = + gameActionQueue.toList() + + isProcessingGameQueue = true + + + GameRepository.applyActions( + + gameId = gameId, + + actions = actionsToSend, + + onSuccess = { + + // Убираем из очереди именно + // те действия, которые сервер подтвердил. + repeat(actionsToSend.size) { + + if (gameActionQueue.isNotEmpty()) { + gameActionQueue.removeAt(0) + } + } + + isProcessingGameQueue = false + + + // Пока сервер сохранял первую пачку, + // пользователь мог натапать ещё. + if (gameActionQueue.isNotEmpty()) { + syncGameActionQueue() + } + }, + + onError = { message -> + + // Что-то пошло не так. + // Выкидываем неподтверждённые + // optimistic-действия и читаем + // настоящий счёт с сервера. + + gameActionQueue.clear() + isProcessingGameQueue = false + + + GameRepository.loadState( + + gameId = gameId, + + onSuccess = { + serverScores, + serverHistory -> + + applyServerGameState( + serverScores, + serverHistory + ) + + loserIndex = null + + gameActionError = + "Не удалось сохранить изменения. Счёт восстановлен из базы." + }, + + onError = { + + gameActionError = + message + } + ) + } + ) + } + fun applyRemoteUnfinishedGame( + game: LoadedGame? + ) { + + /* + * Эта функция используется и при первом запуске, + * и для фоновой синхронизации меню. + * + * ВАЖНО: + * не трогаем локальное состояние, пока ведущий + * находится за игровым столом / отправляет очередь действий. + * Фоновая синхронизация нужна другому устройству прежде всего + * для мгновенного появления/исчезновения кнопки "Продолжить". + */ + if ( + currentScreen == + AppScreen.GAME || + isProcessingGameQueue || + gameActionQueue.isNotEmpty() || + isFinishingGame || + pendingFinishLoserIndex != + null + ) { + return + } + + if ( + game != + null + ) { + + currentGameId = + game.id + + currentGameType = + game.type + + currentGamePlayers = + game.players + + targetScore = + game.targetScore + + gameStartedAt = + game.startedAt + + isResumedGame = + true + + scores.clear() + + game.players.forEach { + player -> + + scores.add( + game.scores[ + player.id + ] ?: 0 + ) + } + + val restoredLoserIndex = + game.players + .indexOfFirst { + player -> + + ( + game.scores[ + player.id + ] ?: 0 + ) >= + game.targetScore + } + + loserIndex = + if ( + restoredLoserIndex >= + 0 + ) { + restoredLoserIndex + } else { + null + } + + roundHistory.clear() + + game.history.forEach { + playerId -> + + val index = + game.players + .indexOfFirst { + it.id == + playerId + } + + if ( + index >= + 0 + ) { + roundHistory.add( + index + ) + } + } + + gameActive = + true + + } else { + + /* + * На другом телефоне партия могла только что завершиться + * или быть отменена. Меню должно увидеть это без перезапуска. + */ + gameActive = + false + + currentGameId = + null + + gameStartedAt = + null + + isResumedGame = + false + + currentGameType = + null + + currentGamePlayers = + emptyList() + + scores.clear() + roundHistory.clear() + + loserIndex = + null + } + } + + /* + * Первоначальное восстановление текущей партии. + */ + LaunchedEffect( + access.playerId + ) { + + GameRepository + .loadLatestUnfinishedGame( + onSuccess = { + game -> + + applyRemoteUnfinishedGame( + game + ) + + isRestoringGame = + false + }, + onError = { + message -> + + isRestoringGame = + false + + gameActionError = + message + } + ) + } + + /* + * V1.5.1 — синхронизация состояния приложения между устройствами. + * + * Раньше наличие незавершённой партии проверялось только один раз + * при входе в приложение. Поэтому если Денис создавал/завершал партию + * на телефоне, второй клиент в главном меню узнавал об этом только + * после перезапуска. + * + * Теперь, пока открыт главный экран, раз в ~1 секунду спрашиваем VPS. + * Запрос идёт через GameRepository -> DurakServerApi, а не напрямую + * в Firestore. При уходе из меню LaunchedEffect автоматически отменяется. + */ + LaunchedEffect( + access.playerId, + currentScreen + ) { + + if ( + currentScreen != + AppScreen.MENU + ) { + return@LaunchedEffect + } + + while ( + true + ) { + + delay( + 1_000L + ) + + if ( + currentScreen != + AppScreen.MENU || + isRestoringGame || + isCreatingGame || + isGameActionPending || + isProcessingGameQueue || + isFinishingGame || + gameActionQueue.isNotEmpty() || + pendingFinishLoserIndex != + null + ) { + continue + } + + GameRepository + .loadLatestUnfinishedGame( + onSuccess = { + game -> + + applyRemoteUnfinishedGame( + game + ) + }, + onError = { + message -> + + /* + * Фоновая синхронизация не должна пугать + * пользователя кратким сетевым сбоем. + * Следующая попытка будет через секунду. + */ + Log.w( + "DurakScore", + "Background game-state sync failed: $message" + ) + } + ) + } + } + if (isRestoringGame) { + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = + Alignment.CenterHorizontally, + verticalArrangement = + Arrangement.Center + ) { + + CircularProgressIndicator() + + Spacer( + modifier = + Modifier.height(16.dp) + ) + + Text( + text = + "Проверяю текущую партию..." + ) + } + + return + } + LaunchedEffect( + isProcessingGameQueue, + gameActionQueue.size, + pendingFinishLoserIndex, + finishRequestSent + ) { + + val finishIndex = + pendingFinishLoserIndex + + if ( + finishIndex != null && + !isProcessingGameQueue && + gameActionQueue.isEmpty() && + !finishRequestSent + ) { + + val gameId = + currentGameId + + if (gameId == null) { + + isFinishingGame = false + pendingFinishLoserIndex = null + + gameActionError = + "Не удалось определить текущую партию" + + return@LaunchedEffect + } + + val loserPlayer = + currentGamePlayers[ + finishIndex + ] + + finishRequestSent = true + + + GameRepository.finishGame( + + gameId = gameId, + + loserId = + loserPlayer.id, + + finishedByUid = + DurakAuthSession.current() + ?.uid + .orEmpty(), + + finishedByName = + access.name, + + onSuccess = { + + isFinishingGame = false + finishRequestSent = false + + pendingFinishLoserIndex = + null + + gameActive = false + + finalGameId = + gameId + + loserIndex = + null + + currentScreen = + AppScreen.FINAL_GAME + + currentGameId = null + gameStartedAt = null + isResumedGame = false + currentGameType = null + currentGamePlayers = + emptyList() + + scores.clear() + roundHistory.clear() + gameActionQueue.clear() + }, + + onError = { message -> + + isFinishingGame = false + finishRequestSent = false + + pendingFinishLoserIndex = + null + + // Возвращаем пользователя + // обратно в игру. + gameActive = true + + finalGameId = + null + + loserIndex = + finishIndex + + currentScreen = + AppScreen.GAME + + gameActionError = + "Не удалось завершить партию: $message" + } + ) + } + } + // Системная кнопка Android «Назад» на внутренних экранах + // возвращает в меню, а не закрывает приложение. + BackHandler( + enabled = + currentScreen != AppScreen.MENU && + loserIndex == null && + !showCancelGameDialog + ) { + currentScreen = AppScreen.MENU + } + + CompositionLocalProvider( + LocalDensity provides Density( + density = baseDensity.density, + // Системный крупный шрифт и наш режим «Крупный текст» не должны + // перемножаться. Иначе, например, 1.30 × 1.35 превращается в 1.75 + // и ломает даже нормальные адаптивные карточки. Наш переключатель + // теперь гарантирует минимум 1.35, но уважает ещё более крупный + // системный fontScale пользователя. + fontScale = + if (largeTextEnabled) { + maxOf(baseDensity.fontScale, 1.35f) + } else { + baseDensity.fontScale + } + ) + ) { + + AnimatedContent( + modifier = + Modifier + .fillMaxSize() + .background( + PokerPalette.BackgroundTop + ), + targetState = + currentScreen, + transitionSpec = { + fadeIn( + animationSpec = + tween(160) + ) togetherWith + fadeOut( + animationSpec = + tween(120) + ) + }, + label = + "screen_transition" + ) { screen -> + + when (screen) { + + AppScreen.DATA_AUDIT -> { + + DataAuditScreen( + access = access, + onBack = { + currentScreen = AppScreen.MENU + } + ) + } + + AppScreen.AUDIT_LOG -> { + + AuditLogScreen( + access = access, + onBack = { + currentScreen = AppScreen.MENU + } + ) + } + + AppScreen.MAIN_SCORE -> { + + MainScoreScreen( + access = access, + onBack = { + currentScreen = AppScreen.MENU + } + ) + } + + AppScreen.LIVE_GAME -> { + + LiveGameScreen( + access = access, + onResultReached = { gameId -> + + finalGameId = + gameId + + currentScreen = + AppScreen.FINAL_GAME + }, + onBack = { + currentScreen = + AppScreen.MENU + } + ) + } + + AppScreen.FINAL_GAME -> { + + val resultGameId = + finalGameId + + if ( + resultGameId == null + ) { + LaunchedEffect(Unit) { + currentScreen = + AppScreen.MENU + } + } else { + + val localLoserIndex = + loserIndex + + val preview = + if ( + currentGameId == + resultGameId && + localLoserIndex != null && + localLoserIndex in + currentGamePlayers.indices + ) { + + FinalGamePreview( + id = + resultGameId, + type = + if ( + currentGameType == + GameType.CLASSIC + ) { + "classic" + } else { + "custom" + }, + targetScore = + targetScore, + players = + currentGamePlayers, + scores = + currentGamePlayers + .mapIndexed { + index, + player -> + player.id to + ( + scores + .getOrNull( + index + ) + ?: 0 + ) + } + .toMap(), + history = + roundHistory + .mapNotNull { + index -> + currentGamePlayers + .getOrNull( + index + ) + ?.id + }, + startedAt = + gameStartedAt, + loserId = + currentGamePlayers[ + localLoserIndex + ].id + ) + } else { + null + } + + val controlsAvailable = + ( + access.role == + "admin" || + access.role == + "scorekeeper" + ) && + currentGameId == + resultGameId && + localLoserIndex != null + + FinalGameScreen( + gameId = + resultGameId, + preview = + preview, + showResultControls = + controlsAvailable, + canConfirmResult = + controlsAvailable && + !isProcessingGameQueue && + gameActionQueue.isEmpty() && + !isGameActionPending && + !isFinishingGame, + isFinishingResult = + isFinishingGame, + onConfirmResult = { + + val currentLoserIndex = + loserIndex + + if ( + currentLoserIndex != + null && + !isFinishingGame + ) { + + pendingFinishLoserIndex = + currentLoserIndex + + finishRequestSent = + false + + isFinishingGame = + true + } + }, + onUndoLastPoint = { + + if ( + !isFinishingGame && + roundHistory + .isNotEmpty() + ) { + + val lastPlayerIndex = + roundHistory + .removeAt( + roundHistory + .lastIndex + ) + + if ( + scores[ + lastPlayerIndex + ] > 0 + ) { + scores[ + lastPlayerIndex + ]-- + } + + loserIndex = + null + + finalGameId = + null + + gameActionQueue.add( + QueuedGameAction( + type = + GameActionType + .UNDO_LAST + ) + ) + + syncGameActionQueue() + + currentScreen = + AppScreen.GAME + } + }, + onResultRolledBack = { + + finalGameId = + null + + currentScreen = + AppScreen.LIVE_GAME + }, + onBackToMenu = { + + finalGameId = + null + + currentScreen = + AppScreen.MENU + } + ) + } + } + + AppScreen.HISTORY -> { + + HistoryScreen( + access = + access, + onBack = { + currentScreen = + AppScreen.MENU + } + ) + } + + AppScreen.ACHIEVEMENTS -> { + + AchievementsScreen( + access = + access, + onBack = { + currentScreen = + AppScreen.MENU + } + ) + } + + AppScreen.USERS -> { + + AdminUsersScreen( + access = access, + + onBack = { + currentScreen = AppScreen.MENU + } + ) + } + + AppScreen.MENU -> { + + MainMenuScreen( + + onMainScore = { + currentScreen = AppScreen.MAIN_SCORE + }, + + onAuditLog = { + currentScreen = AppScreen.AUDIT_LOG + }, + + onDataAudit = { + currentScreen = AppScreen.DATA_AUDIT + }, + + onLiveGame = { + currentScreen = + AppScreen.LIVE_GAME + }, + onHistory = { + currentScreen = + AppScreen.HISTORY + }, + + onAchievements = { + currentScreen = + AppScreen.ACHIEVEMENTS + }, + + isFinishingGame = isFinishingGame, + largeTextEnabled = largeTextEnabled, + onToggleLargeText = { + val newValue = !largeTextEnabled + largeTextEnabled = newValue + uiPrefs.edit() + .putBoolean( + "large_text_${access.playerId}", + newValue + ) + .apply() + }, + access = access, + gameActive = gameActive, + + onUsers = { + currentScreen = AppScreen.USERS + }, + + onNewGame = { + currentScreen = AppScreen.NEW_GAME + }, + + onContinueGame = { + + val gameId = currentGameId + + if ( + gameId != null && + !isGameActionPending + ) { + + isGameActionPending = true + gameActionError = null + + fun openCurrentGame() { + isGameActionPending = false + isResumedGame = true + gameActive = true + + val reachedLimitIndex = + scores.indexOfFirst { + it >= targetScore + } + + loserIndex = + if (reachedLimitIndex >= 0) { + reachedLimitIndex + } else { + null + } + + if ( + reachedLimitIndex >= 0 + ) { + finalGameId = + gameId + + currentScreen = + AppScreen.FINAL_GAME + } else { + currentScreen = + AppScreen.GAME + } + } + + // Если партия всё ещё active — просто открываем экран. + // Только paused-партию реально переводим обратно в active. + GameRepository.loadGameStatus( + gameId = gameId, + onSuccess = { status -> + when (status) { + "active" -> { + openCurrentGame() + } + + "paused" -> { + GameRepository.resumeGame( + gameId = gameId, + onSuccess = { + openCurrentGame() + }, + onError = { message -> + isGameActionPending = false + gameActionError = message + } + ) + } + + else -> { + isGameActionPending = false + gameActive = false + gameActionError = + "Текущая партия уже недоступна" + } + } + }, + onError = { message -> + isGameActionPending = false + gameActionError = message + } + ) + } + }, + + onLogout = onLogout + ) + } + + AppScreen.NEW_GAME -> { + + NewGameSetupScreen( + + onBack = { + if (!isCreatingGame) { + currentScreen = AppScreen.MENU + } + }, + + onStartGame = { setup -> + + if (!isCreatingGame) { + + isCreatingGame = true + gameCreationError = null + + GameRepository.createGame( + + setup = setup, + + onSuccess = { game -> + + currentGameId = + game.id + + gameStartedAt = + game.startedAt + + isResumedGame = + false + + currentGamePlayers = + game.players + + currentGameType = + game.type + + targetScore = + game.targetScore + + scores.clear() + + game.players.forEach { + player -> + + scores.add( + game.scores[ + player.id + ] ?: 0 + ) + } + + roundHistory.clear() + + game.history.forEach { + playerId -> + + val index = + game.players + .indexOfFirst { + it.id == + playerId + } + + if ( + index >= + 0 + ) { + roundHistory.add( + index + ) + } + } + + loserIndex = + null + + gameActive = + true + + isCreatingGame = + false + + currentScreen = + AppScreen.GAME + }, + + onError = { + message -> + + isCreatingGame = + false + + gameCreationError = + message + } + ) + } + } + ) + } + + AppScreen.GAME -> { + + GameScreen( + gameId = currentGameId, + startedAt = gameStartedAt, + isResumedGame = isResumedGame, + playerIds = currentGamePlayers.map { + it.id + }, + playerNames = currentGamePlayers.map { + it.name + }, + scores = scores, + targetScore = targetScore, + history = roundHistory, + reactionSenderName = + access.name, + + onAddPoint = { playerIndex -> + + val alreadyFinishedIndex = + scores.indexOfFirst { + it >= targetScore + } + + if (alreadyFinishedIndex >= 0) { + + // Уже есть проигравший. + // Никаких дополнительных очков. + loserIndex = + alreadyFinishedIndex + + finalGameId = + currentGameId + + currentScreen = + AppScreen.FINAL_GAME + + } else { + + val player = + currentGamePlayers[playerIndex] + + scores[playerIndex]++ + + roundHistory.add( + playerIndex + ) + + gameActionQueue.add( + QueuedGameAction( + type = + GameActionType.ADD_POINT, + playerId = + player.id + ) + ) + + syncGameActionQueue() + + if ( + scores[playerIndex] >= + targetScore + ) { + loserIndex = + playerIndex + + finalGameId = + currentGameId + + currentScreen = + AppScreen.FINAL_GAME + } + } + }, + + onUndo = { + + if (roundHistory.isNotEmpty()) { + + // Сразу исправляем экран. + val lastPlayerIndex = + roundHistory.removeAt( + roundHistory.lastIndex + ) + + + if ( + scores[lastPlayerIndex] > 0 + ) { + + scores[lastPlayerIndex]-- + } + + + loserIndex = null + + + // Сервер догонит нас в фоне. + gameActionQueue.add( + QueuedGameAction( + type = + GameActionType.UNDO_LAST + ) + ) + + + syncGameActionQueue() + } + }, + + onPause = { + + if ( + isProcessingGameQueue || + gameActionQueue.isNotEmpty() + ) { + + gameActionError = + "Сохраняю последние очки. Попробуй ещё раз через секунду." + + } else { + + val gameId = + currentGameId + + if ( + gameId != null && + !isGameActionPending + ) { + + isGameActionPending = true + gameActionError = null + + GameRepository.pauseGame( + + gameId = gameId, + + onSuccess = { + + isGameActionPending = false + gameActive = true + + currentScreen = + AppScreen.MENU + }, + + onError = { message -> + + isGameActionPending = false + gameActionError = message + } + ) + } + } + }, + + onChangeTargetScore = { newTarget -> + + if ( + newTarget !in 2..99 || + isProcessingGameQueue || + gameActionQueue.isNotEmpty() || + isGameActionPending + ) { + if (newTarget !in 2..99) { + gameActionError = + "Лимит должен быть от 2 до 99" + } else { + gameActionError = + "Сохраняю последние изменения. Попробуй сменить лимит через секунду." + } + } else { + val gameId = currentGameId + + if (gameId != null) { + isGameActionPending = true + gameActionError = null + + GameRepository.updateTargetScore( + gameId = gameId, + newTargetScore = newTarget, + onSuccess = { + targetScore = newTarget + isGameActionPending = false + + val reachedLimitIndex = + scores.indexOfFirst { + it >= newTarget + } + + loserIndex = + if (reachedLimitIndex >= 0) { + reachedLimitIndex + } else { + null + } + + if ( + reachedLimitIndex >= 0 + ) { + finalGameId = + gameId + + currentScreen = + AppScreen.FINAL_GAME + } + }, + onError = { message -> + isGameActionPending = false + gameActionError = message + } + ) + } + } + }, + + onBackToMenu = { + currentScreen = AppScreen.MENU + }, + + onCancelGame = { + if ( + isProcessingGameQueue || + gameActionQueue.isNotEmpty() || + isGameActionPending + ) { + gameActionError = + "Сохраняю последние изменения. Попробуй отменить партию через секунду." + } else { + cancelGameText = "" + showCancelGameDialog = true + } + } + ) + } + } + } + + if (gameActionError != null) { + + AlertDialog( + onDismissRequest = { + gameActionError = null + }, + title = { + Text("Ошибка") + }, + text = { + Text(gameActionError!!) + }, + confirmButton = { + Button( + onClick = { + gameActionError = null + } + ) { + Text("ОК") + } + } + ) + } + + if (showCancelGameDialog) { + + AlertDialog( + onDismissRequest = { + if (!isCancelingGame) { + showCancelGameDialog = false + cancelGameText = "" + } + }, + title = { + Text("Отменить партию?") + }, + text = { + Column { + Text( + text = "Партия будет удалена и не попадёт в историю. Информация об отмене останется в журнале." + ) + + Spacer( + modifier = Modifier.height(18.dp) + ) + + Text( + text = "Для подтверждения введите «подтвердить»", + fontWeight = FontWeight.Bold + ) + + Spacer( + modifier = Modifier.height(8.dp) + ) + + OutlinedTextField( + value = cancelGameText, + onValueChange = { cancelGameText = it }, + label = { Text("Подтверждение") }, + singleLine = true, + enabled = !isCancelingGame + ) + } + }, + confirmButton = { + val confirmed = + cancelGameText.trim().lowercase() == "подтвердить" + + Button( + enabled = confirmed && !isCancelingGame, + onClick = { + val gameId = currentGameId + + if (gameId != null) { + isCancelingGame = true + gameActionError = null + + GameRepository.cancelGame( + gameId = gameId, + cancelledByUid = DurakAuthSession.current() + ?.uid + .orEmpty(), + cancelledByName = access.name, + onSuccess = { + isCancelingGame = false + showCancelGameDialog = false + cancelGameText = "" + + currentGameId = null + gameStartedAt = null + isResumedGame = false + currentGameType = null + currentGamePlayers = emptyList() + + scores.clear() + roundHistory.clear() + gameActionQueue.clear() + + loserIndex = null + gameActive = false + currentScreen = AppScreen.MENU + }, + onError = { message -> + isCancelingGame = false + gameActionError = + "Не удалось отменить партию: $message" + } + ) + } + } + ) { + Text( + if (isCancelingGame) { + "Удаляю..." + } else { + "Отменить партию" + } + ) + } + }, + dismissButton = { + TextButton( + enabled = !isCancelingGame, + onClick = { + showCancelGameDialog = false + cancelGameText = "" + } + ) { + Text("Назад") + } + } + ) + } + + + } +} + +@Composable +fun MainMenuScreen( + access: UserAccess, + gameActive: Boolean, + isFinishingGame: Boolean, + largeTextEnabled: Boolean, + onToggleLargeText: () -> Unit, + onNewGame: () -> Unit, + onContinueGame: () -> Unit, + onHistory: () -> Unit, + onAchievements: () -> Unit, + onLiveGame: () -> Unit, + onLogout: () -> Unit, + onUsers: () -> Unit, + onMainScore: () -> Unit, + onAuditLog: () -> Unit, + onDataAudit: () -> Unit, +) { + + val canManageGames = + access.role == + "admin" || + access.role == + "scorekeeper" + + val canSeeJournal = + access.role == + "admin" || + access.role == + "scorekeeper" + + val menuFontScale = + LocalDensity.current.fontScale + + val heroMinHeight = + when { + menuFontScale >= 1.55f -> 220.dp + menuFontScale >= 1.35f -> 205.dp + menuFontScale >= 1.18f -> 188.dp + else -> 170.dp + } + + val heroTextEndPadding = + when { + menuFontScale >= 1.45f -> 34.dp + menuFontScale >= 1.20f -> 54.dp + else -> 88.dp + } + + val heroTitleSize = + when { + menuFontScale >= 1.45f -> 20.sp + menuFontScale >= 1.20f -> 22.sp + else -> 24.sp + } + + val heroTitleLineHeight = + when { + menuFontScale >= 1.45f -> 22.sp + menuFontScale >= 1.20f -> 24.sp + else -> 26.sp + } + + val canWatch = + access.role == + "viewer" || + ( + canManageGames && + gameActive + ) + + PokerScreen { + + /* + * Адаптивная шапка. Она больше не имеет жёсткой высоты: + * при системном увеличении шрифта карточка растёт, а тексту + * оставляется больше места поверх затемнённой части арта. + */ + Card( + modifier = + Modifier + .fillMaxWidth() + .heightIn( + min = heroMinHeight + ), + colors = + CardDefaults.cardColors( + containerColor = + Color( + 0xF20A0D0B + ) + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = + RoundedCornerShape( + 22.dp + ) + ) { + + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn( + min = heroMinHeight + ) + ) { + + Image( + painter = + painterResource( + id = + R.drawable + .menu_header_jester + ), + contentDescription = + null, + contentScale = + ContentScale.Fit, + modifier = + Modifier + .align( + Alignment.CenterEnd + ) + .size( + width = + 188.dp, + height = + 170.dp + ) + ) + + Box( + modifier = + Modifier + .align( + Alignment.CenterEnd + ) + .size( + width = + 210.dp, + height = + heroMinHeight + ) + .background( + brush = + androidx.compose.ui.graphics.Brush + .horizontalGradient( + colors = + listOf( + Color( + 0xF20A0D0B + ), + Color( + 0x990A0D0B + ), + Color.Transparent + ) + ) + ) + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = + 18.dp, + top = + 17.dp, + end = + heroTextEndPadding, + bottom = + 14.dp + ) + ) { + + Text( + text = + "ДУРАКОМЕТР", + color = + PokerPalette.TextPrimary, + fontSize = + heroTitleSize, + fontFamily = + PokerDisplayFont, + fontWeight = + FontWeight.Black, + maxLines = + 2, + softWrap = + true, + lineHeight = + heroTitleLineHeight + ) + + Spacer( + modifier = + Modifier.height( + 3.dp + ) + ) + + Text( + text = + "Стол всё помнит.", + color = + PokerPalette.CrimsonBright, + fontSize = + 14.sp, + fontWeight = + FontWeight.Bold, + maxLines = + 2 + ) + + Spacer( + modifier = + Modifier.height( + 13.dp + ) + ) + + Text( + text = + access.name, + color = + PokerPalette.Gold, + fontSize = + 18.sp, + fontWeight = + FontWeight.Bold, + maxLines = + 2 + ) + + Text( + text = + if ( + gameActive + ) { + "Партия уже идёт. Кто-то явно встрял." + } else { + "Можно начинать охоту на дурака." + }, + color = + PokerPalette.TextSecondary, + fontSize = + 11.sp, + lineHeight = + 14.sp, + maxLines = + 3 + ) + } + } + } + + if ( + isFinishingGame + ) { + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + PokerPanel { + + Text( + text = + "☠ Фиксируем приговор...", + color = + PokerPalette.CrimsonBright, + fontSize = + 15.sp, + fontWeight = + FontWeight.Bold + ) + } + } + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + /* + * Главное действие — не просто очередная кнопка. + */ + PokerMenuArtTile( + iconRes = + R.drawable.menu_new_game, + title = + "НОВАЯ ПАРТИЯ", + subtitle = + if ( + gameActive + ) { + "Сначала закончи текущую катку." + } else { + "Проверим, кто сегодня заслужит корону дурака." + }, + onClick = + onNewGame, + enabled = + canManageGames && + !gameActive && + !isFinishingGame, + redAccent = + true + ) + + if ( + canManageGames && + gameActive + ) { + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_continue, + title = + "ПРОДОЛЖИТЬ ПАРТИЮ", + subtitle = + "Вернуться за стол и продолжить раздачу.", + onClick = + onContinueGame, + enabled = + !isFinishingGame, + redAccent = + true + ) + } + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + + Text( + text = + "СТОЛ И ПОЗОР", + color = + PokerPalette.Gold, + fontSize = + 13.sp, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + + BoxWithConstraints( + modifier = + Modifier.fillMaxWidth() + ) { + + /* + * При очень узкой логической ширине экрана или большом + * системном fontScale сетка 2×2 сама превращается в одну + * колонку. На обычных настройках остаётся привычная 2×2. + */ + val singleColumn = + maxWidth < 340.dp || + menuFontScale >= 1.55f + + if ( + singleColumn + ) { + + Column( + modifier = + Modifier.fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy( + 10.dp + ) + ) { + + PokerMenuArtTile( + iconRes = + R.drawable.menu_score, + title = + "Общий счёт", + subtitle = + "Кто чаще всех облажался.", + onClick = + onMainScore + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_achievements, + title = + "Достижения", + subtitle = + "Слава и сомнительные заслуги.", + onClick = + onAchievements + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_history, + title = + "История", + subtitle = + "Архив старых преступлений.", + onClick = + onHistory + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_watch, + title = + if ( + access.role == + "viewer" + ) { + "Текущая" + } else { + "Наблюдать" + }, + subtitle = + if ( + canWatch + ) { + "Смотреть стол вживую." + } else { + "Сейчас смотреть нечего." + }, + onClick = + onLiveGame, + enabled = + canWatch && + !isFinishingGame + ) + } + + } else { + + Column( + modifier = + Modifier.fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy( + 10.dp + ) + ) { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy( + 10.dp + ) + ) { + + PokerMenuArtTile( + iconRes = + R.drawable.menu_score, + title = + "Общий счёт", + subtitle = + "Кто чаще всех облажался.", + onClick = + onMainScore, + compact = + true, + modifier = + Modifier.weight( + 1f + ) + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_achievements, + title = + "Достижения", + subtitle = + "Слава и сомнительные заслуги.", + onClick = + onAchievements, + compact = + true, + modifier = + Modifier.weight( + 1f + ) + ) + } + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy( + 10.dp + ) + ) { + + PokerMenuArtTile( + iconRes = + R.drawable.menu_history, + title = + "История", + subtitle = + "Архив старых преступлений.", + onClick = + onHistory, + compact = + true, + modifier = + Modifier.weight( + 1f + ) + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_watch, + title = + if ( + access.role == + "viewer" + ) { + "Текущая" + } else { + "Наблюдать" + }, + subtitle = + if ( + canWatch + ) { + "Смотреть стол вживую." + } else { + "Сейчас смотреть нечего." + }, + onClick = + onLiveGame, + enabled = + canWatch && + !isFinishingGame, + compact = + true, + modifier = + Modifier.weight( + 1f + ) + ) + } + } + } + } + + if ( + canSeeJournal || + access.role == + "admin" + ) { + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + Text( + text = + "СЛУЖЕБНОЕ", + color = + PokerPalette.CrimsonBright, + fontSize = + 12.sp, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + } + + if ( + canSeeJournal + ) { + + PokerMenuArtTile( + iconRes = + R.drawable.menu_audit, + title = + "Журнал действий", + subtitle = + "Кто, когда и что нахимичил.", + onClick = + onAuditLog + ) + } + + if ( + access.role == + "admin" + ) { + + Spacer( + modifier = + Modifier.height( + 9.dp + ) + ) + + PokerMenuArtTile( + iconRes = + R.drawable.menu_users, + title = + "Пользователи", + subtitle = + "Допуски к этому балагану.", + onClick = + onUsers + ) + + Spacer( + modifier = + Modifier.height( + 9.dp + ) + ) + + PokerSecondaryButton( + text = + "🔎 Проверка данных", + onClick = + onDataAudit + ) + } + + if ( + access.playerId == + "denis" || + access.playerId == + "dmitry" + ) { + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + PokerSecondaryButton( + text = + if ( + largeTextEnabled + ) { + "Aa Крупный текст: ВКЛ" + } else { + "Aa Крупный текст" + }, + onClick = + onToggleLargeText + ) + } + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + PokerDangerButton( + text = + "🚪 Выйти из аккаунта", + onClick = + onLogout + ) + + Spacer( + modifier = + Modifier.height( + 19.dp + ) + ) + + Text( + text = + "♠ ♥ ♦ ♣", + color = + PokerPalette.Crimson, + fontSize = + 17.sp, + fontWeight = + FontWeight.Bold, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + + val versionContext = + LocalContext.current + + val versionName = + remember( + versionContext + ) { + runCatching { + versionContext + .packageManager + .getPackageInfo( + versionContext.packageName, + 0 + ) + .versionName + } + .getOrNull() + ?: "?" + } + + Text( + text = + "Дуракометр • v$versionName", + color = + Color( + 0xFF606763 + ), + fontSize = + 10.sp, + modifier = + Modifier.align( + Alignment.CenterHorizontally + ) + ) + } +} + +@Composable +fun GameScreen( + gameId: String?, + playerIds: List, + playerNames: List, + scores: List, + targetScore: Int, + history: List, + reactionSenderName: String, + onAddPoint: (Int) -> Unit, + onUndo: () -> Unit, + onPause: () -> Unit, + onChangeTargetScore: (Int) -> Unit, + onBackToMenu: () -> Unit, + onCancelGame: () -> Unit, + startedAt: Timestamp?, + isResumedGame: Boolean +) { + var showTargetDialog by remember { + mutableStateOf(false) + } + + var targetText by remember(targetScore) { + mutableStateOf(targetScore.toString()) + } + + val view = LocalView.current + + val lastPlayerIndex = history.lastOrNull() + val lastPlayerName = + lastPlayerIndex?.let { index -> + playerNames.getOrNull(index) + } + + val timeText = when { + startedAt == null -> null + isResumedGame -> + "Доигрываем от ${formatMoscowDateTime(startedAt)}" + else -> + "Начало: ${formatMoscowDateTime(startedAt)}" + } + + val subtitle = + if (timeText != null) { + "До $targetScore • $timeText" + } else { + "Играем до $targetScore" + } + + Box( + modifier = + Modifier.fillMaxSize() + ) { + + PokerScreen { + GothicGameTableHero( + targetScore = targetScore, + subtitle = subtitle + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "КТО СЕГОДНЯ ДУРАК", + color = PokerPalette.CrimsonBright, + fontSize = 11.sp, + fontWeight = FontWeight.Black + ) + + if (isResumedGame && lastPlayerName != null) { + Spacer(modifier = Modifier.height(12.dp)) + + PokerPanel { + Text( + text = "Последнее очко перед продолжением — $lastPlayerName", + color = PokerPalette.Gold, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + QueuedStreakPraiseBanner( + gameKey = + gameId ?: "local_game", + players = + playerIds.mapIndexedNotNull { + playerIndex, + playerId -> + + val name = + playerNames.getOrNull( + playerIndex + ) + ?: return@mapIndexedNotNull null + + playerId to name + }, + history = + history.mapNotNull { historyIndex -> + playerIds.getOrNull( + historyIndex + ) + }, + includeLatestOnStart = + false, + topSpacingDp = + 12 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + playerNames.forEachIndexed { index, playerName -> + PokerPlayerCard( + name = playerName, + score = scores.getOrElse(index) { 0 }, + targetScore = targetScore, + onAddPoint = { + view.performHapticFeedback( + HapticFeedbackConstants.CLOCK_TICK + ) + + onAddPoint(index) + }, + isLastPoint = index == lastPlayerIndex + ) + + Spacer(modifier = Modifier.height(10.dp)) + } + + Spacer(modifier = Modifier.height(6.dp)) + + if (gameId != null) { + StyledReactionBar( + gameId = gameId, + senderName = + reactionSenderName + ) + + Spacer( + modifier = + Modifier.height(12.dp) + ) + } + + PokerPanel { + PokerSecondaryButton( + text = "↶ Отменить последнее", + onClick = onUndo, + enabled = history.isNotEmpty() + ) + + Spacer(modifier = Modifier.height(10.dp)) + + PokerSecondaryButton( + text = "Изменить лимит • сейчас до $targetScore", + onClick = { + targetText = targetScore.toString() + showTargetDialog = true + } + ) + + Spacer(modifier = Modifier.height(10.dp)) + + PokerPrimaryButton( + text = "Поставить на паузу", + onClick = onPause + ) + + Spacer(modifier = Modifier.height(10.dp)) + + PokerBottomBackButton( + text = "← В меню", + onClick = onBackToMenu + ) + + Spacer(modifier = Modifier.height(14.dp)) + + PokerDangerButton( + text = "Отменить партию", + onClick = onCancelGame + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + Text( + text = "♣ Счёт сохраняется автоматически ♦", + color = PokerPalette.TextSecondary, + fontSize = 13.sp, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + + if (gameId != null) { + ReactionRainHost( + gameId = + gameId, + modifier = + Modifier.fillMaxSize(), + skipExistingOnStart = + true + ) + } + } + + if (showTargetDialog) { + val newTarget = targetText.toIntOrNull() + val targetValid = newTarget != null && newTarget in 2..99 + + AlertDialog( + onDismissRequest = { + showTargetDialog = false + }, + title = { + Text("До скольки играем?") + }, + text = { + Column { + Text( + text = "Можно изменить лимит прямо во время партии.", + fontSize = 15.sp + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + OutlinedButton( + onClick = { targetText = "10" }, + modifier = Modifier.weight(1f) + ) { + Text("10") + } + + OutlinedButton( + onClick = { targetText = "15" }, + modifier = Modifier.weight(1f) + ) { + Text("15") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = targetText, + onValueChange = { value -> + targetText = value.filter { it.isDigit() } + }, + label = { Text("Лимит") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + isError = targetText.isNotEmpty() && !targetValid + ) + + if (targetText.isNotEmpty() && !targetValid) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Введите число от 2 до 99", + color = MaterialTheme.colorScheme.error, + fontSize = 13.sp + ) + } + } + }, + confirmButton = { + Button( + enabled = targetValid, + onClick = { + val value = newTarget + if (value != null) { + showTargetDialog = false + if (value != targetScore) { + onChangeTargetScore(value) + } + } + } + ) { + Text("Сохранить") + } + }, + dismissButton = { + TextButton( + onClick = { + showTargetDialog = false + } + ) { + Text("Отмена") + } + } + ) + } +} + + +@Composable +private fun GothicGameTableHero( + targetScore: Int, + subtitle: String +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(132.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xF2080908) + ), + border = BorderStroke( + 1.dp, + PokerPalette.Crimson + ), + shape = RoundedCornerShape(18.dp) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Image( + painter = painterResource( + id = R.drawable.gothic_game_table_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.CenterEnd) + .size( + width = 150.dp, + height = 132.dp + ) + ) + + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size( + width = 170.dp, + height = 132.dp + ) + .background( + brush = androidx.compose.ui.graphics.Brush + .horizontalGradient( + listOf( + Color(0xF2080908), + Color(0x99080908), + Color.Transparent + ) + ) + ) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = 15.dp, + top = 14.dp, + end = 116.dp, + bottom = 10.dp + ) + ) { + Text( + text = "ПАРТИЯ", + color = PokerPalette.TextPrimary, + fontFamily = PokerDisplayFont, + fontSize = 27.sp, + fontWeight = FontWeight.Black, + maxLines = 1 + ) + + Text( + text = "ДО $targetScore", + color = PokerPalette.CrimsonBright, + fontSize = 17.sp, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.height(5.dp)) + + Text( + text = subtitle, + color = PokerPalette.Gold, + fontSize = 9.5.sp, + lineHeight = 12.sp, + maxLines = 2 + ) + } + } + } +} + +data class UserAccess( + val name: String, + val role: String, + val playerId: String +) + +@Composable +fun UserAccessGate( + user: DurakSession, + onLogout: () -> Unit +) { + + var isLoading by remember { + mutableStateOf(true) + } + + var access by remember { + mutableStateOf(null) + } + + var errorText by remember { + mutableStateOf(null) + } + + var isPending by remember { + mutableStateOf(false) + } + + LaunchedEffect(user.uid) { + + /* + * ЭТАП МИГРАЦИИ НА VPS: + * + * Сначала проверяем пользователя через наш HTTPS API. + * Авторизация, роли и игровые данные теперь идут через наш HTTPS API + * и PostgreSQL. В приложении больше нет сервер-зависимостей. + */ + + fun loadAccessStatusFromVps() { + + DurakServerApi + .loadAccessStatus( + user = + user, + onSuccess = { + result -> + + when ( + result.status + ) { + + "pending" -> { + isPending = + true + + errorText = + null + } + + "active" -> { + /* + * Редкий race: статус уже active, + * а первый /me попал в момент обновления. + * Просто повторяем /me один раз через + * текущий LaunchedEffect не нужно — + * показываем понятную просьбу перезапустить. + */ + errorText = + "Доступ уже выдан. Перезапустите приложение." + } + + else -> { + errorText = + "Для этого аккаунта доступ не назначен" + } + } + + isLoading = + false + }, + onError = { + message -> + + errorText = + message.ifBlank { + "Не удалось проверить доступ через сервер" + } + + isLoading = + false + } + ) + } + + DurakServerApi.loadMe( + user = user, + onSuccess = { serverMe -> + + access = + UserAccess( + name = + serverMe.name.ifBlank { + "Пользователь" + }, + role = + serverMe.role.ifBlank { + "viewer" + }, + playerId = + serverMe.playerId + ) + + Log.i( + "DurakServerApi", + "User access loaded from VPS: role=${serverMe.role}, playerId=${serverMe.playerId}" + ) + + isLoading = false + }, + onForbidden = { + + loadAccessStatusFromVps() + }, + onError = { message -> + + errorText = + message.ifBlank { + "Не удалось проверить доступ через сервер" + } + + isLoading = + false + } + ) + } + + when { + + isLoading -> { + + Box( + modifier = + Modifier + .fillMaxSize() + .background( + PokerPalette.BackgroundTop + ), + contentAlignment = + Alignment.Center + ) { + + CircularProgressIndicator( + color = + PokerPalette.CrimsonBright + ) + } + } + isPending -> { + + PendingAccessScreen( + onLogout = onLogout + ) + } + errorText != null -> { + + Box( + modifier = + Modifier + .fillMaxSize() + .background( + PokerPalette.BackgroundTop + ) + .padding( + 24.dp + ), + contentAlignment = + Alignment.Center + ) { + + Card( + colors = + CardDefaults.cardColors( + containerColor = + PokerPalette.Panel + ), + border = + BorderStroke( + 1.dp, + PokerPalette.Crimson + ), + shape = + RoundedCornerShape( + 20.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 20.dp + ), + horizontalAlignment = + Alignment.CenterHorizontally + ) { + + Text( + text = + "☠ ДОСТУПА НЕТ", + color = + PokerPalette.CrimsonBright, + fontSize = + 20.sp, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height( + 10.dp + ) + ) + + Text( + text = + errorText!!, + color = + PokerPalette.TextSecondary, + fontSize = + 15.sp + ) + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + PokerSecondaryButton( + text = + "Выйти", + onClick = + onLogout + ) + } + } + } + } + access != null -> { + + DurakScoreApp( + access = access!!, + onLogout = onLogout + ) + } + } +} + +@Composable +fun PendingAccessScreen( + onLogout: () -> Unit +) { + + Box( + modifier = + Modifier + .fillMaxSize() + .background( + PokerPalette.BackgroundTop + ) + .padding( + 22.dp + ), + contentAlignment = + Alignment.Center + ) { + + Card( + colors = + CardDefaults.cardColors( + containerColor = + PokerPalette.Panel + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = + RoundedCornerShape( + 22.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 22.dp + ), + horizontalAlignment = + Alignment.CenterHorizontally + ) { + + Text( + text = + "ДУРАКОМЕТР", + color = + PokerPalette.TextPrimary, + fontFamily = + PokerDisplayFont, + fontSize = + 30.sp, + fontWeight = + FontWeight.Black + ) + + Spacer( + modifier = + Modifier.height( + 4.dp + ) + ) + + Text( + text = + "Стол всё помнит.", + color = + PokerPalette.CrimsonBright, + fontSize = + 14.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height( + 24.dp + ) + ) + + Text( + text = + "Заявка отправлена", + color = + PokerPalette.Gold, + fontSize = + 22.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + + Text( + text = + "Ожидаем подтверждение администратора.", + color = + PokerPalette.TextSecondary, + fontSize = + 15.sp + ) + + Spacer( + modifier = + Modifier.height( + 22.dp + ) + ) + + PokerDangerButton( + text = + "Выйти", + onClick = + onLogout + ) + } + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/MainScoreRepository.kt b/app/src/main/java/ru/durakscore/app/MainScoreRepository.kt new file mode 100644 index 0000000..2bba9fb --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/MainScoreRepository.kt @@ -0,0 +1,72 @@ +package ru.durakscore.app + +import java.util.UUID + +object MainScoreRepository { + + fun correctScore( + playerId: String, + playerName: String, + newValue: Int, + changedByUid: String, + changedByName: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + if ( + newValue < + 0 + ) { + + onError( + "Счёт не может быть отрицательным" + ) + + return + } + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + + onError( + "Пользователь не авторизован" + ) + + return + } + + /* + * Актёра сервер определяет сам по session token. + * playerName / changedByUid / changedByName оставлены + * в сигнатуре, чтобы не ломать существующий UI-код. + */ + val requestId = + UUID + .randomUUID() + .toString() + + DurakServerApi + .correctMainScore( + user = + user, + playerId = + playerId, + newValue = + newValue, + requestId = + requestId, + onSuccess = { + + onSuccess() + }, + onError = + onError + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/MainScoreScreen.kt b/app/src/main/java/ru/durakscore/app/MainScoreScreen.kt new file mode 100644 index 0000000..9c64c9b --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/MainScoreScreen.kt @@ -0,0 +1,2116 @@ +package ru.durakscore.app + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.util.Date +import java.util.Locale + +data class MainScorePlayer( + val id: String, + val name: String, + val sortOrder: Int, + val losses: Int? +) + +private data class MainScoreGame( + val id: String, + val loserId: String?, + val finishedAt: Timestamp? +) + +private data class MainScoreRecord( + val iconRes: Int, + val title: String, + val playerName: String, + val value: String +) + +private val MainScoreCard = + PokerPalette.Panel + +private val MainScoreCardStrong = + PokerPalette.PanelStrong + +private val MainScoreBorder = + PokerPalette.PanelBorder + +private val MainScoreGreen = + PokerPalette.Good + +private val MainScoreRed = + PokerPalette.Bad + +private val MainScoreGold = + PokerPalette.Gold + +private val MainScoreMuted = + PokerPalette.TextSecondary + +private fun playerSuit( + playerId: String +): String = + when (playerId) { + "denis" -> "♠" + "dmitry" -> "♣" + "rybka" -> "♥" + "masha" -> "♦" + else -> "🃏" + } + +private fun parseMainScoreGame( + game: ServerFinishedGame +): MainScoreGame? { + + if ( + game.isTest != + TEST_MODE + ) { + return null + } + + if ( + game.gameType != + "classic" + ) { + return null + } + + return MainScoreGame( + id = + game.id, + loserId = + game.loserId + .takeIf { + it.isNotBlank() + }, + finishedAt = + game.finishedAtEpochMillis + .takeIf { + it > 0L + } + ?.let { + Timestamp( + Date( + it + ) + ) + } + ) +} + +private fun calculateLongestLossStreak( + games: List, + playerId: String +): Int { + + var current = + 0 + + var best = + 0 + + games + .sortedBy { + it.finishedAt + ?.seconds + ?: 0L + } + .forEach { + game -> + + if ( + game.loserId == + playerId + ) { + current++ + + if ( + current > + best + ) { + best = + current + } + } else { + current = + 0 + } + } + + return best +} + +private fun formatRate( + losses: Int, + games: Int +): String { + + if ( + games <= + 0 + ) { + return "—" + } + + val rate = + losses.toDouble() / + games.toDouble() + + return String.format( + Locale.US, + "%.2f", + rate + ) +} + +private fun lossesWord( + count: Int +): String { + + val mod100 = + count % 100 + + if ( + mod100 in + 11..14 + ) { + return "поражений" + } + + return when ( + count % 10 + ) { + 1 -> + "поражение" + + 2, 3, 4 -> + "поражения" + + else -> + "поражений" + } +} + +@Composable +fun MainScoreScreen( + access: UserAccess, + onBack: () -> Unit +) { + + BackHandler( + onBack = + onBack + ) + + val canEdit = + access.role == + "admin" || + access.role == + "scorekeeper" + + var players by remember { + mutableStateOf>( + emptyList() + ) + } + + var playerData by remember { + mutableStateOf>>( + emptyMap() + ) + } + + var scoreData by remember { + mutableStateOf>( + emptyMap() + ) + } + + var games by remember { + mutableStateOf>( + emptyList() + ) + } + + var playersLoaded by remember { + mutableStateOf(false) + } + + var scoresLoaded by remember { + mutableStateOf(false) + } + + var gamesLoaded by remember { + mutableStateOf(false) + } + + var errorText by remember { + mutableStateOf( + null + ) + } + + var editingPlayer by remember { + mutableStateOf( + null + ) + } + + var editValue by remember { + mutableStateOf("") + } + + var editError by remember { + mutableStateOf( + null + ) + } + + var isSaving by remember { + mutableStateOf(false) + } + + fun rebuildList() { + + players = + playerData.map { + (id, data) -> + + MainScorePlayer( + id = + id, + name = + data.first, + sortOrder = + data.second, + losses = + scoreData[id] + ) + } + .sortedBy { + it.sortOrder + } + } + + LaunchedEffect(Unit) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + errorText = + "Пользователь не авторизован" + + playersLoaded = + true + + scoresLoaded = + true + + gamesLoaded = + true + + return@LaunchedEffect + } + + DurakServerApi + .loadMainScore( + user = + user, + onSuccess = { + serverPlayers -> + + val corePlayers = + serverPlayers + .filter { + it.isCore + } + + playerData = + corePlayers + .associate { + it.id to + Pair( + it.name, + it.sortOrder + ) + } + + scoreData = + corePlayers + .associate { + it.id to + it.losses + } + + playersLoaded = + true + + scoresLoaded = + true + + rebuildList() + }, + onError = { + message -> + + errorText = + message.ifBlank { + "Не удалось загрузить общий счёт" + } + + playersLoaded = + true + + scoresLoaded = + true + } + ) + + DurakServerApi + .loadFinishedGames( + user = + user, + onSuccess = { + serverGames -> + + games = + serverGames + .mapNotNull { + parseMainScoreGame( + it + ) + } + + gamesLoaded = + true + }, + onError = { + /* + * Основной счёт всё равно показываем. + * Исторические карточки просто не считаем, + * если отдельный запрос истории не удался. + */ + gamesLoaded = + true + } + ) + } + + val isLoading = + !playersLoaded || + !scoresLoaded || + !gamesLoaded + + val scoreInitialized = + players.isNotEmpty() && + players.all { + it.losses != + null + } + + val rankedPlayers = + remember( + players + ) { + players + .filter { + it.losses != + null + } + .sortedWith( + compareBy { + it.losses + ?: Int.MAX_VALUE + } + .thenBy { + it.sortOrder + } + ) + } + + val minLosses = + rankedPlayers + .minOfOrNull { + it.losses + ?: Int.MAX_VALUE + } + + val leaders = + if ( + minLosses != + null + ) { + rankedPlayers + .filter { + it.losses == + minLosses + } + } else { + emptyList() + } + + val leader = + leaders + .firstOrNull() + + val appLossesByPlayer = + remember( + games + ) { + games + .mapNotNull { + it.loserId + } + .groupingBy { + it + } + .eachCount() + } + + val leaderAppLosses = + leader + ?.let { + appLossesByPlayer[ + it.id + ] ?: 0 + } + ?: 0 + + val leaderRate = + formatRate( + losses = + leaderAppLosses, + games = + games.size + ) + + val maxLosses = + rankedPlayers + .maxOfOrNull { + it.losses + ?: 0 + } + + val worstPlayers = + if ( + maxLosses != + null + ) { + rankedPlayers + .filter { + it.losses == + maxLosses + } + } else { + emptyList() + } + + val lossStreaksByPlayer = + rankedPlayers + .map { + player -> + + player to + calculateLongestLossStreak( + games = + games, + playerId = + player.id + ) + } + + val maxLossStreak = + lossStreaksByPlayer + .maxOfOrNull { + it.second + } + ?: 0 + + val longestStreakPlayers = + if ( + maxLossStreak > 0 + ) { + lossStreaksByPlayer + .filter { + it.second == + maxLossStreak + } + } else { + emptyList() + } + + val appBestRateLosses = + if ( + games.isNotEmpty() + ) { + rankedPlayers + .minOfOrNull { + player -> + appLossesByPlayer[ + player.id + ] ?: 0 + } + ?: 0 + } else { + 0 + } + + val appBestRatePlayers = + if ( + games.isNotEmpty() + ) { + rankedPlayers + .filter { + player -> + ( + appLossesByPlayer[ + player.id + ] ?: 0 + ) == + appBestRateLosses + } + } else { + emptyList() + } + + val records = + buildList { + + if ( + leaders.isNotEmpty() && + minLosses != + null + ) { + add( + MainScoreRecord( + iconRes = + R.drawable.main_score_trophy, + title = + "Меньше всего поражений", + playerName = + leaders.joinToString( + separator = " и " + ) { + it.name + }, + value = + minLosses.toString() + ) + ) + } + + if ( + worstPlayers.isNotEmpty() && + maxLosses != + null + ) { + add( + MainScoreRecord( + iconRes = + R.drawable.main_score_dual_skull, + title = + "Больше всего поражений", + playerName = + worstPlayers.joinToString( + separator = " и " + ) { + it.name + }, + value = + maxLosses.toString() + ) + ) + } + + if ( + longestStreakPlayers.isNotEmpty() && + maxLossStreak > + 0 + ) { + add( + MainScoreRecord( + iconRes = + R.drawable.main_score_flame_jester, + title = + "Самая длинная серия поражений", + playerName = + longestStreakPlayers + .joinToString( + separator = " и " + ) { + it.first.name + }, + value = + if ( + longestStreakPlayers.size > + 1 + ) { + "по $maxLossStreak подряд" + } else { + "$maxLossStreak подряд" + } + ) + ) + } + + if ( + appBestRatePlayers.isNotEmpty() && + games.isNotEmpty() + ) { + add( + MainScoreRecord( + iconRes = + R.drawable.main_score_shield_jester, + title = + "Реже всех проигрывает", + playerName = + appBestRatePlayers + .joinToString( + separator = " и " + ) { + it.name + }, + value = + formatRate( + losses = + appBestRateLosses, + games = + games.size + ) + ) + ) + } + } + + PokerScreen { + + /* + * Верх концепта 1: + * стрелка — заголовок — инфо. + */ + Row( + modifier = + Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + + Text( + text = + "←", + color = + MainScoreGold, + fontSize = + 34.sp, + fontWeight = + FontWeight.Light, + modifier = + Modifier + .clickable { + onBack() + } + .padding( + end = 12.dp + ) + ) + + Text( + text = + "Общий счёт", + color = + PokerPalette.TextPrimary, + fontSize = + 30.sp, + fontFamily = + PokerDisplayFont, + fontWeight = + FontWeight.Bold, + lineHeight = + 34.sp, + maxLines = + 2, + textAlign = + TextAlign.Center, + modifier = + Modifier.weight( + 1f + ) + ) + + Text( + text = + "ⓘ", + color = + MainScoreGold, + fontSize = + 27.sp + ) + } + + Spacer( + modifier = + Modifier.height( + 18.dp + ) + ) + + if ( + TEST_MODE + ) { + + Surface( + shape = + RoundedCornerShape( + 18.dp + ), + color = + MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreGold + ), + modifier = + Modifier.fillMaxWidth() + ) { + + Text( + text = + "ТЕСТОВЫЙ РЕЖИМ • тестовые партии не изменяют настоящий общий счёт", + modifier = + Modifier.padding( + 14.dp + ), + color = + MainScoreGold, + fontSize = + 13.sp, + fontWeight = + FontWeight.SemiBold + ) + } + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + } + + when { + + isLoading -> { + + Row( + modifier = + Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.Center + ) { + + CircularProgressIndicator( + color = + MainScoreGold + ) + } + } + + errorText != + null -> { + + Surface( + shape = + RoundedCornerShape( + 20.dp + ), + color = + MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreBorder + ), + modifier = + Modifier.fillMaxWidth() + ) { + + Text( + text = + errorText!!, + modifier = + Modifier.padding( + 18.dp + ), + color = + PokerPalette.Danger, + fontSize = + 16.sp + ) + } + } + + else -> { + + if ( + !scoreInitialized + ) { + + Surface( + shape = + RoundedCornerShape( + 20.dp + ), + color = + MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreBorder + ), + modifier = + Modifier.fillMaxWidth() + ) { + + Text( + text = + "Стартовый счёт ещё не внесён", + modifier = + Modifier.padding( + 18.dp + ), + color = + PokerPalette.TextPrimary, + fontSize = + 17.sp, + fontWeight = + FontWeight.Bold + ) + } + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + } + + if ( + leader != + null && + minLosses != + null + ) { + + LeaderCard( + leader = + leader, + leaderCount = + leaders.size, + losses = + minLosses + ) + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth() + ) { + val stackStats = + maxWidth < 340.dp || + LocalDensity.current.fontScale >= 1.28f + + if (stackStats) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy(10.dp) + ) { + MiniStatCard( + modifier = Modifier.fillMaxWidth(), + iconRes = + R.drawable.main_score_hat, + title = + "ПАРТИЙ В ПРИЛОЖЕНИИ", + value = + games.size.toString() + ) + + MiniStatCard( + modifier = Modifier.fillMaxWidth(), + iconRes = + R.drawable.main_score_skull_hat, + title = + "ПОРАЖЕНИЙ ЛИДЕРА НА ПАРТИЮ", + value = + leaderRate + ) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(10.dp) + ) { + MiniStatCard( + modifier = Modifier.weight(1f), + iconRes = + R.drawable.main_score_hat, + title = + "ПАРТИЙ В ПРИЛОЖЕНИИ", + value = + games.size.toString() + ) + + MiniStatCard( + modifier = Modifier.weight(1f), + iconRes = + R.drawable.main_score_skull_hat, + title = + "ПОРАЖЕНИЙ ЛИДЕРА\nНА ПАРТИЮ", + value = + leaderRate + ) + } + } + } + + Spacer( + modifier = + Modifier.height( + 14.dp + ) + ) + + RankingCard( + players = + rankedPlayers, + canEdit = + canEdit, + onEdit = { + player -> + + editingPlayer = + player + + editValue = + player.losses + ?.toString() + ?: "" + + editError = + null + } + ) + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + HistoryCard( + records = + records + ) + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + Surface( + shape = + RoundedCornerShape( + 18.dp + ), + color = + MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreBorder + ), + modifier = + Modifier.fillMaxWidth() + ) { + + Row( + modifier = + Modifier.padding( + horizontal = + 14.dp, + vertical = + 12.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + Image( + painter = + painterResource( + id = + R.drawable.main_score_footer_jester + ), + contentDescription = + null, + modifier = + Modifier.size( + 50.dp + ) + ) + + Spacer( + modifier = + Modifier.size( + 10.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ), + horizontalAlignment = + Alignment.CenterHorizontally + ) { + + Text( + text = + "Поражения учитываются только в классических партиях.", + color = + MainScoreMuted, + fontSize = + 13.sp, + textAlign = + TextAlign.Center + ) + + Spacer( + modifier = + Modifier.height( + 4.dp + ) + ) + + Text( + text = + "Меньше поражений — лучше.", + color = + MainScoreGold, + fontSize = + 13.sp, + fontWeight = + FontWeight.Bold + ) + } + } + } + + Spacer( + modifier = + Modifier.height( + 16.dp + ) + ) + + PokerBottomBackButton( + onClick = + onBack + ) + } + } + } + } + + /* + * Сохраняем старую ручную корректировку, + * но теперь она открывается тапом по игроку + * в рейтинге — интерфейс чище. + */ + if ( + editingPlayer != + null + ) { + + val player = + editingPlayer!! + + AlertDialog( + onDismissRequest = { + if ( + !isSaving + ) { + editingPlayer = + null + } + }, + title = { + Text( + "Изменить счёт: ${player.name}" + ) + }, + text = { + + Column { + + Text( + "Сейчас: ${player.losses}" + ) + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + OutlinedTextField( + value = + editValue, + onValueChange = { + value -> + + editValue = + value.filter { + it.isDigit() + } + + editError = + null + }, + label = { + Text( + "Новый счёт" + ) + }, + keyboardOptions = + KeyboardOptions( + keyboardType = + KeyboardType.Number + ), + singleLine = + true + ) + + if ( + TEST_MODE + ) { + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + Text( + "Тестовый режим: изменение реального счёта заблокировано." + ) + } + + if ( + editError != + null + ) { + + Spacer( + modifier = + Modifier.height( + 12.dp + ) + ) + + Text( + editError!! + ) + } + } + }, + confirmButton = { + + Button( + enabled = + !TEST_MODE && + !isSaving, + colors = + ButtonDefaults + .buttonColors( + containerColor = + MainScoreGold, + contentColor = + Color( + 0xFF1C180E + ) + ), + onClick = { + + val newValue = + editValue + .toIntOrNull() + + if ( + newValue == + null + ) { + editError = + "Введите число" + } else { + + isSaving = + true + + MainScoreRepository + .correctScore( + playerId = + player.id, + playerName = + player.name, + newValue = + newValue, + changedByUid = + DurakAuthSession.current() + ?.uid + .orEmpty(), + changedByName = + access.name, + onSuccess = { + isSaving = + false + + editingPlayer = + null + }, + onError = { + message -> + + isSaving = + false + + editError = + message + } + ) + } + } + ) { + + Text( + if ( + isSaving + ) { + "Сохраняю..." + } else { + "Сохранить" + } + ) + } + }, + dismissButton = { + + TextButton( + enabled = + !isSaving, + onClick = { + editingPlayer = + null + } + ) { + Text( + "Отмена" + ) + } + } + ) + } +} + +@Composable +private fun LeaderCard( + leader: MainScorePlayer, + leaderCount: Int, + losses: Int +) { + val fontScale = LocalDensity.current.fontScale + val heroMinHeight = + when { + fontScale >= 1.30f -> 218.dp + fontScale >= 1.15f -> 194.dp + else -> 178.dp + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = PokerPalette.PanelStrong, + border = + BorderStroke( + width = 1.dp, + color = MainScoreBorder + ), + shape = RoundedCornerShape(24.dp) + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight) + ) { + Image( + painter = + painterResource( + id = R.drawable.main_score_leader_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .align(Alignment.CenterEnd) + .padding(end = 2.dp) + .size( + width = + if (fontScale >= 1.25f) { + 96.dp + } else { + 112.dp + }, + height = + if (fontScale >= 1.25f) { + 142.dp + } else { + 154.dp + } + ) + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 18.dp, + end = + if (fontScale >= 1.25f) { + 72.dp + } else { + 104.dp + }, + bottom = 18.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = + Modifier.size( + if (fontScale >= 1.25f) { + 66.dp + } else { + 78.dp + } + ), + shape = CircleShape, + color = Color(0xFF07110D), + border = + BorderStroke( + 3.dp, + MainScoreGold + ) + ) { + Box( + contentAlignment = Alignment.Center + ) { + Text( + text = playerSuit(leader.id), + color = + if ( + leader.id == "rybka" || + leader.id == "masha" + ) { + Color(0xFFC5453D) + } else { + Color(0xFF656966) + }, + fontSize = + if (fontScale >= 1.25f) { + 34.sp + } else { + 41.sp + }, + fontWeight = FontWeight.Black + ) + } + } + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = + if (leaderCount > 1) { + "👑 ОДИН ИЗ ЛИДЕРОВ" + } else { + "👑 ЛИДЕР" + }, + color = MainScoreGold, + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = "меньше поражений — лучше", + color = MainScoreMuted, + fontSize = 10.sp, + lineHeight = 13.sp, + maxLines = 3, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(7.dp)) + + Text( + text = leader.name, + color = PokerPalette.TextPrimary, + fontSize = + if (fontScale >= 1.25f) { + 21.sp + } else { + 25.sp + }, + lineHeight = 27.sp, + fontWeight = FontWeight.Black, + maxLines = 2, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Row( + verticalAlignment = Alignment.Bottom + ) { + Text( + text = losses.toString(), + color = MainScoreGreen, + fontSize = + if (fontScale >= 1.25f) { + 36.sp + } else { + 43.sp + }, + fontWeight = FontWeight.Black + ) + + Spacer(modifier = Modifier.size(6.dp)) + + Text( + text = lossesWord(losses), + color = MainScoreMuted, + fontSize = 12.sp, + lineHeight = 15.sp, + maxLines = 2, + softWrap = true, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 7.dp) + ) + } + } + } + } + } +} + +@Composable +private fun MiniStatCard( + modifier: Modifier, + iconRes: Int, + title: String, + value: String +) { + val fontScale = LocalDensity.current.fontScale + + Surface( + modifier = + modifier.heightIn( + min = + if (fontScale >= 1.25f) { + 142.dp + } else { + 118.dp + } + ), + color = MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreBorder + ), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = 12.dp, + vertical = 13.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(42.dp), + color = Color(0xFF040D09), + shape = RoundedCornerShape(12.dp) + ) { + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.padding(3.dp) + ) + } + + Spacer(modifier = Modifier.size(9.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + color = MainScoreMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + lineHeight = 13.sp, + maxLines = 5, + softWrap = true, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(5.dp)) + + Text( + text = value, + color = PokerPalette.TextPrimary, + fontSize = + if (fontScale >= 1.25f) { + 24.sp + } else { + 27.sp + }, + fontWeight = FontWeight.Black, + maxLines = 2, + softWrap = true + ) + } + } + } +} + +@Composable +private fun RankingCard( + players: List, + canEdit: Boolean, + onEdit: (MainScorePlayer) -> Unit +) { + + Surface( + modifier = + Modifier.fillMaxWidth(), + color = + MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreBorder + ), + shape = + RoundedCornerShape( + 22.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + horizontal = + 16.dp, + vertical = + 10.dp + ) + ) { + + players.forEachIndexed { + index, + player -> + + val value = + player.losses + ?: 0 + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + enabled = + canEdit && + player.losses != + null + ) { + onEdit( + player + ) + } + .padding( + vertical = + 13.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + Text( + text = + when ( + index + ) { + 0 -> "🥇" + 1 -> "🥈" + 2 -> "🥉" + else -> "4" + }, + color = + MainScoreMuted, + fontSize = + if ( + index < + 3 + ) { + 25.sp + } else { + 21.sp + }, + modifier = + Modifier + .size( + 42.dp + ) + .padding( + top = + 4.dp + ) + ) + + Surface( + modifier = + Modifier.size( + 43.dp + ), + shape = + CircleShape, + color = + Color( + 0xFF132A20 + ), + border = + BorderStroke( + 1.dp, + if ( + index == + 0 + ) { + MainScoreGold + } else { + MainScoreBorder + } + ) + ) { + + Box( + contentAlignment = + Alignment.Center + ) { + + Text( + text = + playerSuit( + player.id + ), + color = + if ( + player.id == + "rybka" || + player.id == + "masha" + ) { + Color( + 0xFFC85850 + ) + } else { + PokerPalette + .TextPrimary + }, + fontSize = + 23.sp, + fontWeight = + FontWeight.Bold + ) + } + } + + Spacer( + modifier = + Modifier.size( + 12.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + player.name, + color = + PokerPalette.TextPrimary, + fontSize = + 18.sp, + fontWeight = + FontWeight.Bold + ) + + if ( + canEdit + ) { + + Text( + text = + "нажми для корректировки", + color = + MainScoreMuted, + fontSize = + 10.sp + ) + } + } + + Text( + text = + value.toString(), + color = + if ( + index == + 0 + ) { + MainScoreGreen + } else { + MainScoreRed + }, + fontSize = + 31.sp, + fontWeight = + FontWeight.Black + ) + } + + if ( + index != + players.lastIndex + ) { + + Surface( + modifier = + Modifier + .fillMaxWidth() + .height( + 1.dp + ), + color = + Color( + 0xFF2B3B32 + ) + ) {} + } + } + } + } +} + +@Composable +private fun HistoryCard( + records: List +) { + + Surface( + modifier = + Modifier.fillMaxWidth(), + color = + MainScoreCard, + border = + BorderStroke( + 1.dp, + MainScoreBorder + ), + shape = + RoundedCornerShape( + 22.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 17.dp + ) + ) { + + Text( + text = + "ВСЯ ИСТОРИЯ", + color = + MainScoreGold, + fontSize = + 18.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + Modifier.height( + 9.dp + ) + ) + + if ( + records.isEmpty() + ) { + + Text( + text = + "Пока недостаточно данных для рекордов.", + color = + MainScoreMuted, + fontSize = + 14.sp + ) + + } else { + + records.forEachIndexed { + index, + record -> + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + vertical = + 10.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + Image( + painter = + painterResource( + id = + record.iconRes + ), + contentDescription = + null, + modifier = + Modifier.size( + 46.dp + ) + ) + + Spacer( + modifier = + Modifier.size( + 11.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + record.title, + color = + PokerPalette.TextPrimary, + fontSize = + 15.sp, + fontWeight = + FontWeight.Medium + ) + + Spacer( + modifier = + Modifier.height( + 3.dp + ) + ) + + Text( + text = + record.playerName, + color = + MainScoreGold, + fontSize = + 14.sp, + fontWeight = + FontWeight.Bold + ) + } + + Text( + text = + record.value, + color = + if ( + record.title.contains( + "Больше всего" + ) || + record.title.contains( + "серия" + ) + ) { + MainScoreRed + } else { + MainScoreGreen + }, + fontSize = + 18.sp, + fontWeight = + FontWeight.Black + ) + } + + if ( + index != + records.lastIndex + ) { + + Surface( + modifier = + Modifier + .fillMaxWidth() + .height( + 1.dp + ), + color = + Color( + 0xFF2B3B32 + ) + ) {} + } + } + } + + if ( + records.any { + it.title.contains( + "серия", + ignoreCase = + true + ) + } + ) { + + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + + Text( + text = + "Подробные серии считаются по классическим партиям, сыгранным уже через Дуракометр.", + color = + MainScoreMuted, + fontSize = + 11.sp + ) + } + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/NewGameSetupScreen.kt b/app/src/main/java/ru/durakscore/app/NewGameSetupScreen.kt new file mode 100644 index 0000000..103dd72 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/NewGameSetupScreen.kt @@ -0,0 +1,743 @@ +package ru.durakscore.app + +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.layout.ContentScale +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Card +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.background +import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +enum class GameType { + CLASSIC, + CUSTOM +} + +data class GamePlayer( + val id: String, + val name: String, + val gender: String, + val isCore: Boolean, + val isGuest: Boolean = false, + val sortOrder: Int = 999 +) + +data class GameSetup( + val type: GameType, + val players: List, + val targetScore: Int +) + +@Composable +fun NewGameSetupScreen( + onBack: () -> Unit, + onStartGame: (GameSetup) -> Unit +) { + var permanentPlayers by remember { + mutableStateOf>(emptyList()) + } + + var isLoading by remember { mutableStateOf(true) } + var loadError by remember { mutableStateOf(null) } + + var selectedType by remember { + mutableStateOf(null) + } + + var selectedPlayerIds by remember { + mutableStateOf>(emptySet()) + } + + var guestPlayers by remember { + mutableStateOf>(emptyList()) + } + + var guestName by remember { mutableStateOf("") } + var guestGender by remember { mutableStateOf("male") } + + var targetScoreText by remember { mutableStateOf("15") } + + LaunchedEffect(Unit) { + + val user = + DurakAuthSession.current() + + if ( + user == + null + ) { + loadError = + "Пользователь не авторизован" + + isLoading = + false + + return@LaunchedEffect + } + + DurakServerApi + .loadMainScore( + user = + user, + onSuccess = { + serverPlayers -> + + permanentPlayers = + serverPlayers + .map { + player -> + + GamePlayer( + id = + player.id, + name = + player.name, + gender = + player.gender.ifBlank { + "male" + }, + isCore = + player.isCore, + isGuest = + false, + sortOrder = + player.sortOrder + ) + } + .sortedBy { + it.sortOrder + } + + isLoading = + false + + loadError = + null + }, + onError = { + message -> + + loadError = + message.ifBlank { + "Не удалось загрузить игроков" + } + + isLoading = + false + } + ) + } + + val corePlayers = permanentPlayers.filter { it.isCore } + val selectedPermanentPlayers = + permanentPlayers.filter { it.id in selectedPlayerIds } + + val currentPlayers = when (selectedType) { + GameType.CLASSIC -> corePlayers + GameType.CUSTOM -> selectedPermanentPlayers + guestPlayers + null -> emptyList() + } + + val targetScore = targetScoreText.toIntOrNull() + val targetValid = targetScore != null && targetScore in 2..99 + + val canStart = when (selectedType) { + GameType.CLASSIC -> corePlayers.size >= 4 && targetValid + GameType.CUSTOM -> currentPlayers.size >= 2 && targetValid + null -> false + } + + PokerScreen { + PokerBackTextButton(onClick = onBack) + + Spacer(modifier = Modifier.height(4.dp)) + + GothicNewGameHero() + + Spacer(modifier = Modifier.height(16.dp)) + + when { + isLoading -> { + PokerPanel { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + CircularProgressIndicator( + color = PokerPalette.Gold + ) + Text( + text = "Загружаю игроков...", + color = PokerPalette.TextPrimary, + fontSize = 17.sp + ) + } + } + } + + loadError != null -> { + PokerPanel { + Text( + text = loadError!!, + color = PokerPalette.Danger, + fontSize = 17.sp, + fontWeight = FontWeight.Bold + ) + } + } + + else -> { + PokerPanel { + Text( + text = "01 • СОСТАВ ИГРЫ", + color = PokerPalette.CrimsonBright, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(14.dp)) + + if (selectedType == GameType.CLASSIC) { + PokerPrimaryButton( + text = "♠ Классический набор игроков", + onClick = { selectedType = GameType.CLASSIC } + ) + } else { + PokerSecondaryButton( + text = "♠ Классический набор игроков", + onClick = { selectedType = GameType.CLASSIC } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Дмитрий, Денис, Рыбка и Маша", + color = PokerPalette.TextSecondary, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(14.dp)) + + if (selectedType == GameType.CUSTOM) { + PokerPrimaryButton( + text = "♣ Другой состав", + onClick = { selectedType = GameType.CUSTOM } + ) + } else { + PokerSecondaryButton( + text = "♣ Другой состав", + onClick = { selectedType = GameType.CUSTOM } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Выбрать игроков вручную или добавить гостя", + color = PokerPalette.TextSecondary, + fontSize = 14.sp + ) + } + + if (selectedType != null) { + Spacer(modifier = Modifier.height(16.dp)) + + PokerPanel { + Text( + text = "02 • ДО СКОЛЬКИ ИГРАЕМ", + color = PokerPalette.CrimsonBright, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(14.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + if (targetScoreText == "10") { + PokerPrimaryButton( + text = "До 10", + onClick = { targetScoreText = "10" } + ) + } else { + PokerSecondaryButton( + text = "До 10", + onClick = { targetScoreText = "10" } + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + if (targetScoreText == "15") { + PokerPrimaryButton( + text = "До 15", + onClick = { targetScoreText = "15" } + ) + } else { + PokerSecondaryButton( + text = "До 15", + onClick = { targetScoreText = "15" } + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = targetScoreText, + onValueChange = { value -> + targetScoreText = value.filter { it.isDigit() }.take(2) + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Свой лимит") }, + supportingText = { + Text("Можно указать от 2 до 99") + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = PokerPalette.TextPrimary, + unfocusedTextColor = PokerPalette.TextPrimary, + focusedBorderColor = PokerPalette.Gold, + unfocusedBorderColor = PokerPalette.GoldDark, + focusedLabelColor = PokerPalette.Gold, + unfocusedLabelColor = PokerPalette.TextSecondary, + focusedSupportingTextColor = PokerPalette.TextSecondary, + unfocusedSupportingTextColor = PokerPalette.TextSecondary, + cursorColor = PokerPalette.Gold + ) + ) + } + } + if (selectedType == GameType.CUSTOM) { + Spacer(modifier = Modifier.height(16.dp)) + + PokerPanel { + Text( + text = "03 • ВЫБЕРИ ИГРОКОВ", + color = PokerPalette.CrimsonBright, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(10.dp)) + + permanentPlayers.forEach { player -> + PlayerPreviewRow( + player = player, + checked = player.id in selectedPlayerIds, + enabled = true, + onToggle = { + selectedPlayerIds = + if (player.id in selectedPlayerIds) { + selectedPlayerIds - player.id + } else { + selectedPlayerIds + player.id + } + } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + PokerPanel { + Text( + text = "☠ ДОБАВИТЬ ГОСТЯ", + color = PokerPalette.CrimsonBright, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = guestName, + onValueChange = { guestName = it.take(30) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Имя гостя") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = PokerPalette.TextPrimary, + unfocusedTextColor = PokerPalette.TextPrimary, + focusedBorderColor = PokerPalette.Gold, + unfocusedBorderColor = PokerPalette.GoldDark, + focusedLabelColor = PokerPalette.Gold, + unfocusedLabelColor = PokerPalette.TextSecondary, + cursorColor = PokerPalette.Gold + ) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + if (guestGender == "male") { + PokerPrimaryButton( + text = "Мужской", + onClick = { guestGender = "male" } + ) + } else { + PokerSecondaryButton( + text = "Мужской", + onClick = { guestGender = "male" } + ) + } + } + + Column(modifier = Modifier.weight(1f)) { + if (guestGender == "female") { + PokerPrimaryButton( + text = "Женский", + onClick = { guestGender = "female" } + ) + } else { + PokerSecondaryButton( + text = "Женский", + onClick = { guestGender = "female" } + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + PokerSecondaryButton( + text = "Добавить гостя", + enabled = guestName.trim().isNotEmpty(), + onClick = { + val cleanName = guestName.trim() + if (cleanName.isNotEmpty()) { + guestPlayers = guestPlayers + GamePlayer( + id = "guest_${System.currentTimeMillis()}_${guestPlayers.size}", + name = cleanName, + gender = guestGender, + isCore = false, + isGuest = true + ) + guestName = "" + } + } + ) + + if (guestPlayers.isNotEmpty()) { + Spacer(modifier = Modifier.height(14.dp)) + + guestPlayers.forEach { guest -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = guest.name, + color = PokerPalette.TextPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "Гость", + color = PokerPalette.TextSecondary, + fontSize = 13.sp + ) + } + + TextButton( + onClick = { + guestPlayers = guestPlayers.filterNot { + it.id == guest.id + } + } + ) { + Text( + text = "Убрать", + color = PokerPalette.Danger + ) + } + } + } + } + } + } + + if (selectedType != null) { + Spacer(modifier = Modifier.height(18.dp)) + + PokerPanel { + Text( + text = "За столом: ${currentPlayers.size}", + color = PokerPalette.TextSecondary, + fontSize = 15.sp + ) + + Spacer(modifier = Modifier.height(10.dp)) + + PokerPrimaryButton( + text = "РАЗДАТЬ КАРТЫ • ПОЕХАЛИ", + enabled = canStart, + onClick = { + val score = targetScore ?: return@PokerPrimaryButton + onStartGame( + GameSetup( + type = selectedType!!, + players = currentPlayers, + targetScore = score + ) + ) + } + ) + + if (!canStart) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = when { + !targetValid -> "Укажи корректный лимит счёта" + selectedType == GameType.CLASSIC -> "Не удалось загрузить всю основную четвёрку" + else -> "Выбери минимум двух игроков" + }, + color = PokerPalette.TextSecondary, + fontSize = 13.sp + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(22.dp)) + } +} + +@Composable +private fun GothicNewGameHero() { + val fontScale = LocalDensity.current.fontScale + val heroMinHeight = + when { + fontScale >= 1.30f -> 194.dp + fontScale >= 1.15f -> 174.dp + else -> 154.dp + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight), + colors = + CardDefaults.cardColors( + containerColor = Color(0xF2080908) + ), + border = + BorderStroke( + 1.dp, + PokerPalette.GoldDark + ), + shape = RoundedCornerShape(18.dp) + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = heroMinHeight) + ) { + Image( + painter = + painterResource( + id = R.drawable.gothic_new_game_jester + ), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = + Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(170.dp) + ) + + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .width(195.dp) + .background( + brush = + androidx.compose.ui.graphics.Brush + .horizontalGradient( + listOf( + Color(0xF2080908), + Color(0xB0080908), + Color.Transparent + ) + ) + ) + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + top = 16.dp, + end = + if (fontScale >= 1.25f) { + 70.dp + } else { + 125.dp + }, + bottom = 12.dp + ) + ) { + Text( + text = "НОВАЯ ПАРТИЯ", + color = PokerPalette.TextPrimary, + fontFamily = PokerDisplayFont, + fontSize = + if (fontScale >= 1.25f) { + 21.sp + } else { + 24.sp + }, + lineHeight = 27.sp, + fontWeight = FontWeight.Black, + maxLines = 2, + softWrap = true + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "ВЫБЕРИ, КТО СЕГОДНЯ ПОСТРАДАЕТ", + color = PokerPalette.CrimsonBright, + fontSize = 10.sp, + lineHeight = 14.sp, + fontWeight = FontWeight.Black, + maxLines = 3, + softWrap = true + ) + + Spacer(modifier = Modifier.height(7.dp)) + + Text( + text = "Состав • лимит • карты на стол", + color = PokerPalette.Gold, + fontSize = 10.5.sp, + lineHeight = 14.sp, + maxLines = 3, + softWrap = true + ) + } + } + } +} + +@Composable +private fun PlayerPreviewRow( + player: GamePlayer, + checked: Boolean, + enabled: Boolean, + onToggle: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onToggle() } + .padding(vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + enabled = enabled, + onCheckedChange = { onToggle() }, + colors = CheckboxDefaults.colors( + checkedColor = PokerPalette.Crimson, + checkmarkColor = PokerPalette.TextPrimary, + uncheckedColor = PokerPalette.GoldDark, + disabledCheckedColor = PokerPalette.GoldDark, + disabledUncheckedColor = PokerPalette.Disabled + ) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Text( + text = player.name, + color = PokerPalette.TextPrimary, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + softWrap = true + ) + + Text( + text = if (player.isGuest) "Гость" else if (player.isCore) "Основной игрок" else "Игрок", + color = PokerPalette.TextSecondary, + fontSize = 13.sp, + lineHeight = 17.sp, + maxLines = 2, + softWrap = true + ) + } + + Text( + text = if (player.gender == "female") "♥" else "♠", + color = if (player.gender == "female") PokerPalette.Danger else PokerPalette.Gold, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/PokerUi.kt b/app/src/main/java/ru/durakscore/app/PokerUi.kt new file mode 100644 index 0000000..b9e0d88 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/PokerUi.kt @@ -0,0 +1,1487 @@ +package ru.durakscore.app + +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp + +/* + * ДУРАКОМЕТР — DARK JESTER UI + * + * Этот файл задает ОБЩИЙ визуальный язык приложения: + * почти черный фон, темный карточный зеленый, + * тонкие золотые рамки, красные опасные значения. + * + * Любой экран, который использует PokerScreen / PokerPanel / + * PokerHeader / Poker*Button / PokerPlayerCard, + * автоматически получает новый стиль. + */ +object PokerPalette { + + // Почти чёрный базовый фон. + val BackgroundTop = + Color(0xFF000000) + + val BackgroundBottom = + Color(0xFF030504) + + // Карточный зелёный теперь очень тёмный и только как оттенок. + val Table = + Color(0xFF091510) + + val TableDark = + Color(0xFF040806) + + // Панели ближе к главному меню: чёрные, но не плоские. + val Panel = + Color(0xF2070B09) + + val PanelStrong = + Color(0xFF0A0F0C) + + val PanelBorder = + Color(0xFF5A4828) + + val PanelBorderSoft = + Color(0xFF25302A) + + val Gold = + Color(0xFFD6AB51) + + val GoldBright = + Color(0xFFEBC864) + + val GoldDark = + Color(0xFF78612F) + + // Красный — главный эмоциональный акцент приложения. + val Crimson = + Color(0xFFA91F1B) + + val CrimsonBright = + Color(0xFFE33B32) + + val CrimsonDark = + Color(0xFF3D0B0A) + + val CrimsonGlow = + Color(0x44A91F1B) + + val TextPrimary = + Color(0xFFF2ECE2) + + val TextSecondary = + Color(0xFFA6A19A) + + val Good = + Color(0xFF70BD53) + + val Bad = + Color(0xFFE44036) + + // +1 теперь тоже вписан в палитру, а не выбивается голубым. + val BlueChip = + Color(0xFF721816) + + val BlueChipText = + Color(0xFFF7EDE4) + + val Danger = + CrimsonBright + + val DangerDark = + CrimsonDark + + val Disabled = + Color(0xFF414743) + + val LastPointPanel = + Color(0xF222100D) +} + +val PokerDisplayFont = + FontFamily.Serif + +private val DurakometrDarkColors = + darkColorScheme( + primary = + PokerPalette.CrimsonBright, + onPrimary = + PokerPalette.TextPrimary, + primaryContainer = + PokerPalette.CrimsonDark, + onPrimaryContainer = + PokerPalette.TextPrimary, + secondary = + PokerPalette.Gold, + onSecondary = + Color(0xFF171006), + secondaryContainer = + Color(0xFF211A0D), + onSecondaryContainer = + PokerPalette.GoldBright, + background = + PokerPalette.BackgroundTop, + onBackground = + PokerPalette.TextPrimary, + surface = + PokerPalette.PanelStrong, + onSurface = + PokerPalette.TextPrimary, + surfaceVariant = + PokerPalette.TableDark, + onSurfaceVariant = + PokerPalette.TextSecondary, + outline = + PokerPalette.GoldDark, + error = + PokerPalette.Bad, + onError = + PokerPalette.TextPrimary + ) + +@Composable +fun DurakometrMaterialTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = + DurakometrDarkColors, + content = + content + ) +} + + +@Composable +fun PokerScreen( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Box( + modifier = + modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + PokerPalette.BackgroundTop, + PokerPalette.BackgroundBottom + ) + ) + ) + ) { + // Красный дым сверху справа. + Box( + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.radialGradient( + colors = + listOf( + PokerPalette.CrimsonGlow, + Color.Transparent + ), + radius = + 760f + ) + ) + ) + + // Еле заметный карточный зелёный снизу слева. + Box( + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.radialGradient( + colors = + listOf( + Color(0x100D4A30), + Color.Transparent + ), + radius = + 980f + ) + ) + ) + + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + /* + * На телефонах с "Масштабом экрана" полезная ширина может + * быть заметно меньше физического разрешения. Поэтому боковые + * поля уменьшаются сами, а нижняя системная навигация больше + * не перекрывает последний блок экрана. + */ + val sidePadding = + when { + maxWidth < 340.dp -> 10.dp + maxWidth < 380.dp -> 12.dp + maxWidth < 420.dp -> 15.dp + else -> 18.dp + } + + Column( + modifier = + Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .verticalScroll( + rememberScrollState() + ) + .padding( + start = sidePadding, + end = sidePadding, + top = 10.dp, + bottom = 24.dp + ), + content = + content + ) + } + } +} + +@Composable +fun PokerHeader( + title: String, + subtitle: String? = null, + badge: String? = null +) { + val fontScale = LocalDensity.current.fontScale + val titleSize = + when { + fontScale >= 1.30f -> 28.sp + fontScale >= 1.15f -> 30.sp + else -> 33.sp + } + + Text( + text = title, + color = PokerPalette.TextPrimary, + fontSize = titleSize, + lineHeight = (titleSize.value + 4).sp, + fontFamily = PokerDisplayFont, + fontWeight = FontWeight.Bold, + maxLines = 3, + softWrap = true + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Box( + modifier = + Modifier + .width(54.dp) + .height(2.dp) + .background(PokerPalette.Crimson) + ) + + if (subtitle != null) { + Spacer(modifier = Modifier.height(7.dp)) + + Text( + text = subtitle, + color = PokerPalette.TextSecondary, + fontSize = 15.sp, + lineHeight = 20.sp, + maxLines = 5, + softWrap = true + ) + } + + if (badge != null) { + Spacer(modifier = Modifier.height(11.dp)) + PokerBadge(text = badge) + } +} + +@Composable +fun PokerBadge( + text: String +) { + + Box( + modifier = + Modifier + .clip( + RoundedCornerShape( + 100.dp + ) + ) + .background( + PokerPalette.CrimsonDark + ) + .border( + width = + 1.dp, + color = + PokerPalette.Crimson, + shape = + RoundedCornerShape( + 100.dp + ) + ) + .padding( + horizontal = + 13.dp, + vertical = + 7.dp + ) + ) { + + Text( + text = + text, + color = + PokerPalette.GoldBright, + fontWeight = + FontWeight.Bold, + fontSize = + 13.sp + ) + } +} + +@Composable +fun PokerPanel( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + + Card( + modifier = + modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + PokerPalette.Panel + ), + border = + BorderStroke( + width = + 1.dp, + color = + PokerPalette.PanelBorder + ), + shape = + RoundedCornerShape( + 18.dp + ) + ) { + + Column( + modifier = + Modifier.padding( + 16.dp + ), + content = + content + ) + } +} + +@Composable +fun PokerPrimaryButton( + text: String, + onClick: () -> Unit, + enabled: Boolean = true +) { + + Button( + onClick = + onClick, + enabled = + enabled, + modifier = + Modifier.fillMaxWidth(), + shape = + RoundedCornerShape( + 16.dp + ), + colors = + ButtonDefaults.buttonColors( + containerColor = + PokerPalette.Crimson, + contentColor = + PokerPalette.TextPrimary, + disabledContainerColor = + PokerPalette.Disabled, + disabledContentColor = + Color(0xFFCDD3CF) + ) + ) { + + Text( + text = text, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + softWrap = true + ) + } +} + +@Composable +fun PokerSecondaryButton( + text: String, + onClick: () -> Unit, + enabled: Boolean = true +) { + + OutlinedButton( + onClick = + onClick, + enabled = + enabled, + modifier = + Modifier.fillMaxWidth(), + shape = + RoundedCornerShape( + 16.dp + ), + border = + BorderStroke( + width = + 1.dp, + color = + if ( + enabled + ) { + PokerPalette.GoldDark + } else { + PokerPalette.Disabled + } + ), + colors = + ButtonDefaults + .outlinedButtonColors( + contentColor = + PokerPalette.TextPrimary, + disabledContentColor = + Color(0xFFCDD3CF) + ) + ) { + + Text( + text = text, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 3, + softWrap = true + ) + } +} + +@Composable +fun PokerDangerButton( + text: String, + onClick: () -> Unit, + enabled: Boolean = true +) { + + OutlinedButton( + onClick = + onClick, + enabled = + enabled, + modifier = + Modifier.fillMaxWidth(), + shape = + RoundedCornerShape( + 16.dp + ), + border = + BorderStroke( + width = + 1.dp, + color = + if ( + enabled + ) { + PokerPalette.Danger + } else { + PokerPalette.Disabled + } + ), + colors = + ButtonDefaults + .outlinedButtonColors( + contentColor = + PokerPalette.Danger, + disabledContentColor = + PokerPalette.Disabled + ) + ) { + + Text( + text = text, + fontSize = 18.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 3, + softWrap = true + ) + } +} + +/** + * Анимация изменения счёта. + * + * Нужна не только игровому столу, но и LiveGameScreen, + * поэтому это общий компонент PokerUi. + */ +@Composable +fun AnimatedScoreValue( + score: Int, + targetScore: Int, + color: Color = + PokerPalette.TextPrimary +) { + var previousScore by remember { + mutableIntStateOf(score) + } + + val scale = remember { Animatable(1f) } + + LaunchedEffect(score) { + val increased = score > previousScore + previousScore = score + + if (!increased) { + return@LaunchedEffect + } + + scale.snapTo(1f) + scale.animateTo( + targetValue = 1.16f, + animationSpec = tween(durationMillis = 110) + ) + scale.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 185) + ) + } + + val fontScale = LocalDensity.current.fontScale + val scoreFont = + when { + fontScale >= 1.30f -> 34.sp + fontScale >= 1.15f -> 38.sp + else -> 42.sp + } + val targetFont = + when { + fontScale >= 1.30f -> 14.sp + fontScale >= 1.15f -> 15.sp + else -> 17.sp + } + + Row( + modifier = + Modifier.graphicsLayer { + scaleX = scale.value + scaleY = scale.value + }, + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = score.toString(), + color = color, + fontSize = scoreFont, + fontWeight = FontWeight.Black + ) + + Text( + text = "/ $targetScore", + color = PokerPalette.TextSecondary, + fontSize = targetFont, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 7.dp) + ) + } +} + +@Composable +fun PokerPlayerCard( + name: String, + score: Int, + targetScore: Int, + onAddPoint: (() -> Unit)? = null, + isLastPoint: Boolean = false, + lastPointText: String = "последнее очко" +) { + val fontScale = LocalDensity.current.fontScale + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (isLastPoint) { + PokerPalette.LastPointPanel + } else { + PokerPalette.Panel + } + ), + border = + BorderStroke( + width = if (isLastPoint) 2.dp else 1.dp, + color = + if (isLastPoint) { + PokerPalette.CrimsonBright + } else { + PokerPalette.PanelBorderSoft + } + ), + shape = RoundedCornerShape(20.dp) + ) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = 16.dp, + vertical = 14.dp + ) + ) { + val stacked = + maxWidth < 315.dp || + fontScale >= 1.30f + + if (stacked) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(9.dp) + ) { + Text( + text = name, + color = + if (isLastPoint) { + PokerPalette.GoldBright + } else { + PokerPalette.TextPrimary + }, + fontSize = 21.sp, + lineHeight = 25.sp, + fontWeight = FontWeight.Bold, + maxLines = 3, + softWrap = true + ) + + if (isLastPoint) { + Text( + text = "• $lastPointText", + color = PokerPalette.Gold, + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 3 + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + AnimatedScoreValue( + score = score, + targetScore = targetScore, + color = PokerPalette.TextPrimary + ) + + if (onAddPoint != null) { + Button( + onClick = onAddPoint, + modifier = + Modifier.size( + width = 54.dp, + height = 44.dp + ), + contentPadding = PaddingValues(0.dp), + shape = RoundedCornerShape(13.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = PokerPalette.BlueChip, + contentColor = PokerPalette.BlueChipText + ) + ) { + Text( + text = "+1", + fontSize = 16.sp, + fontWeight = FontWeight.ExtraBold + ) + } + } + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = name, + color = + if (isLastPoint) { + PokerPalette.GoldBright + } else { + PokerPalette.TextPrimary + }, + fontSize = 24.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + softWrap = true + ) + + if (isLastPoint) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "• $lastPointText", + color = PokerPalette.Gold, + fontSize = 13.sp, + lineHeight = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2 + ) + } + } + + AnimatedScoreValue( + score = score, + targetScore = targetScore, + color = PokerPalette.TextPrimary + ) + + if (onAddPoint != null) { + Button( + onClick = onAddPoint, + modifier = + Modifier.size( + width = 54.dp, + height = 44.dp + ), + contentPadding = PaddingValues(0.dp), + shape = RoundedCornerShape(13.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = PokerPalette.BlueChip, + contentColor = PokerPalette.BlueChipText + ) + ) { + Text( + text = "+1", + fontSize = 16.sp, + fontWeight = FontWeight.ExtraBold + ) + } + } + } + } + } + } +} + +@Composable +fun PokerMenuTile( + icon: String, + title: String, + subtitle: String? = null, + onClick: () -> Unit, + enabled: Boolean = true, + redAccent: Boolean = false, + modifier: Modifier = Modifier +) { + + val borderColor = + when { + !enabled -> + PokerPalette.Disabled + + redAccent -> + PokerPalette.Crimson + + else -> + PokerPalette.PanelBorder + } + + Card( + modifier = + modifier + .fillMaxWidth() + .clickable( + enabled = + enabled, + onClick = + onClick + ), + colors = + CardDefaults.cardColors( + containerColor = + if ( + redAccent + ) { + Color(0xF2160C0B) + } else { + PokerPalette.Panel + } + ), + border = + BorderStroke( + width = + if ( + redAccent + ) { + 1.5.dp + } else { + 1.dp + }, + color = + borderColor + ), + shape = + RoundedCornerShape( + 18.dp + ) + ) { + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = + 15.dp, + vertical = + 14.dp + ), + verticalAlignment = + Alignment.CenterVertically, + horizontalArrangement = + Arrangement.spacedBy( + 12.dp + ) + ) { + + Box( + modifier = + Modifier + .size( + 43.dp + ) + .clip( + RoundedCornerShape( + 13.dp + ) + ) + .background( + if ( + redAccent + ) { + PokerPalette.CrimsonDark + } else { + PokerPalette.TableDark + } + ) + .border( + width = + 1.dp, + color = + if ( + redAccent + ) { + PokerPalette.Crimson + } else { + PokerPalette.GoldDark + }, + shape = + RoundedCornerShape( + 13.dp + ) + ), + contentAlignment = + Alignment.Center + ) { + + Text( + text = + icon, + fontSize = + 22.sp + ) + } + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + title, + color = + if ( + enabled + ) { + PokerPalette.TextPrimary + } else { + PokerPalette.Disabled + }, + fontSize = + 17.sp, + lineHeight = + 21.sp, + fontWeight = + FontWeight.Bold, + maxLines = + 3, + softWrap = + true + ) + + if ( + subtitle != + null + ) { + + Spacer( + modifier = + Modifier.height( + 2.dp + ) + ) + + Text( + text = + subtitle, + color = + if ( + redAccent + ) { + Color(0xFFD9A09B) + } else { + PokerPalette.TextSecondary + }, + fontSize = + 11.sp, + lineHeight = + 14.sp, + maxLines = + 4, + softWrap = + true + ) + } + } + + Text( + text = + "›", + color = + if ( + redAccent + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.Gold + }, + fontSize = + 29.sp, + fontWeight = + FontWeight.Light + ) + } + } +} + + + +@Composable +private fun PokerMenuArtIcon( + iconRes: Int, + size: androidx.compose.ui.unit.Dp, + enabled: Boolean +) { + + val frameShape = + RoundedCornerShape( + 11.dp + ) + + Box( + modifier = + Modifier + .size( + size + ) + .border( + width = + 1.dp, + color = + if ( + enabled + ) { + PokerPalette.GoldDark + } else { + PokerPalette.Disabled + }, + shape = + frameShape + ) + .padding( + 3.dp + ), + contentAlignment = + Alignment.Center + ) { + + Image( + painter = + painterResource( + id = + iconRes + ), + contentDescription = + null, + contentScale = + ContentScale.Fit, + modifier = + Modifier.fillMaxSize() + ) + } +} + +@Composable +fun PokerMenuArtTile( + iconRes: Int, + title: String, + subtitle: String, + onClick: () -> Unit, + enabled: Boolean = true, + redAccent: Boolean = false, + compact: Boolean = false, + modifier: Modifier = Modifier +) { + + val borderColor = + when { + !enabled -> + PokerPalette.Disabled + + redAccent -> + PokerPalette.Crimson + + else -> + PokerPalette.PanelBorder + } + + val fontScale = + LocalDensity.current.fontScale + + /* + * Раньше здесь была жёсткая height(134.dp / 88.dp). + * На Samsung/Poco с увеличенным системным шрифтом текст становился + * выше карточки и обрезался. Теперь это только минимальная высота: + * если тексту нужно больше места, карточка спокойно растёт вниз. + */ + val minimumHeight = + if (compact) { + when { + fontScale >= 1.55f -> 184.dp + fontScale >= 1.35f -> 166.dp + fontScale >= 1.18f -> 150.dp + else -> 134.dp + } + } else { + when { + fontScale >= 1.55f -> 132.dp + fontScale >= 1.35f -> 116.dp + fontScale >= 1.18f -> 102.dp + else -> 88.dp + } + } + + val compactIconSize = + when { + fontScale >= 1.55f -> 46.dp + fontScale >= 1.35f -> 50.dp + fontScale >= 1.18f -> 54.dp + else -> 58.dp + } + + Card( + modifier = + modifier + .fillMaxWidth() + .heightIn( + min = minimumHeight + ) + .clickable( + enabled = + enabled, + onClick = + onClick + ), + colors = + CardDefaults.cardColors( + containerColor = + if ( + redAccent + ) { + Color(0xF2140908) + } else { + Color(0xF2050D0A) + } + ), + border = + BorderStroke( + width = + if ( + redAccent + ) { + 1.5.dp + } else { + 1.dp + }, + color = + borderColor + ), + shape = + RoundedCornerShape( + 18.dp + ) + ) { + + if ( + compact + ) { + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = + 12.dp, + vertical = + 10.dp + ) + ) { + + Row( + modifier = + Modifier.fillMaxWidth(), + verticalAlignment = + Alignment.CenterVertically + ) { + + PokerMenuArtIcon( + iconRes = + iconRes, + size = + compactIconSize, + enabled = + enabled + ) + + Spacer( + modifier = + Modifier.weight( + 1f + ) + ) + + Text( + text = + "›", + color = + if ( + enabled + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.Disabled + }, + fontSize = + 27.sp, + fontWeight = + FontWeight.Light + ) + } + + Spacer( + modifier = + Modifier.height( + 4.dp + ) + ) + + Text( + text = + title, + color = + if ( + enabled + ) { + PokerPalette.TextPrimary + } else { + PokerPalette.Disabled + }, + fontSize = + 14.sp, + lineHeight = + 17.sp, + fontWeight = + FontWeight.Black, + maxLines = + 2, + softWrap = + true + ) + + Spacer( + modifier = + Modifier.height( + 2.dp + ) + ) + + Text( + text = + subtitle, + color = + PokerPalette.TextSecondary, + fontSize = + 10.sp, + lineHeight = + 13.sp, + maxLines = + 3, + softWrap = + true + ) + } + + } else { + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding( + horizontal = + 13.dp, + vertical = + 10.dp + ), + verticalAlignment = + Alignment.CenterVertically + ) { + + PokerMenuArtIcon( + iconRes = + iconRes, + size = + if (fontScale >= 1.45f) { + 52.dp + } else { + 60.dp + }, + enabled = + enabled + ) + + Spacer( + modifier = + Modifier.size( + 10.dp + ) + ) + + Column( + modifier = + Modifier.weight( + 1f + ) + ) { + + Text( + text = + title, + color = + if ( + enabled + ) { + PokerPalette.TextPrimary + } else { + PokerPalette.Disabled + }, + fontSize = + 16.sp, + lineHeight = + 20.sp, + fontWeight = + FontWeight.Black, + maxLines = + 2, + softWrap = + true + ) + + Spacer( + modifier = + Modifier.height( + 2.dp + ) + ) + + Text( + text = + subtitle, + color = + if ( + redAccent + ) { + Color(0xFFD6A09A) + } else { + PokerPalette.TextSecondary + }, + fontSize = + 10.sp, + lineHeight = + 13.sp, + maxLines = + 3, + softWrap = + true + ) + } + + Text( + text = + "›", + color = + if ( + redAccent + ) { + PokerPalette.CrimsonBright + } else { + PokerPalette.Gold + }, + fontSize = + 28.sp, + fontWeight = + FontWeight.Light + ) + } + } + } +} + +@Composable +fun PokerBottomBackButton( + text: String = "← В меню", + onClick: () -> Unit +) { + + PokerSecondaryButton( + text = + text, + onClick = + onClick + ) +} + +@Composable +fun PokerBackTextButton( + text: String = "← Назад", + onClick: () -> Unit +) { + + TextButton( + onClick = + onClick + ) { + + Text( + text = + text, + color = + PokerPalette.GoldBright, + fontSize = + 16.sp, + fontWeight = + FontWeight.SemiBold + ) + } +} diff --git a/app/src/main/java/ru/durakscore/app/ServerApi.kt b/app/src/main/java/ru/durakscore/app/ServerApi.kt new file mode 100644 index 0000000..ee89519 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/ServerApi.kt @@ -0,0 +1,3140 @@ +package ru.durakscore.app + +import android.os.Handler +import android.os.Looper +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.util.UUID +import kotlin.concurrent.thread + +data class ServerMe( + val uid: String, + val email: String?, + val name: String, + val playerId: String, + val role: String, + val active: Boolean +) + +data class ServerMainScorePlayer( + val id: String, + val name: String, + val gender: String, + val isCore: Boolean, + val sortOrder: Int, + val losses: Int +) + +data class ServerMainScoreCorrection( + val playerId: String, + val oldValue: Int, + val newValue: Int, + val alreadyApplied: Boolean +) + +data class ServerFinishGameResult( + val gameId: String, + val loserId: String, + val alreadyFinished: Boolean, + val affectsMainScore: Boolean, + val mainScoreApplied: Boolean, + val newLosses: Int? +) + +data class ServerGamePlayer( + val id: String, + val name: String, + val gender: String, + val isCore: Boolean, + val isGuest: Boolean +) + +data class ServerLoadedGame( + val id: String, + val gameType: String, + val status: String, + val targetScore: Int, + val isTest: Boolean, + val affectsMainScore: Boolean, + val players: List, + val scores: Map, + val history: List, + val startedAtEpochMillis: Long +) + +data class ServerGameState( + val scores: Map, + val history: List +) + +data class ServerFinishedGame( + val id: String, + val gameType: String, + val isTest: Boolean, + val targetScore: Int, + val players: List, + val finalScores: Map, + val history: List, + val loserId: String, + val loserName: String, + val loserGender: String, + val startedAtEpochMillis: Long, + val finishedAtEpochMillis: Long +) + +data class ServerAuditLog( + val id: String, + val type: String, + val eventAtEpochMillis: Long?, + val actorName: String, + val title: String, + val details: List, + val isTest: Boolean +) + +data class ServerLiveGame( + val id: String, + val gameType: String, + val status: String, + val targetScore: Int, + val isTest: Boolean, + val players: List, + val scores: Map, + val finalScores: Map, + val history: List, + val startedAtEpochMillis: Long, + val lastActionType: String?, + val lastActionAtEpochMillis: Long?, + val finishedAtEpochMillis: Long?, + val loserId: String?, + val loserName: String?, + val loserGender: String? +) + +data class ServerLiveSnapshot( + val activeGame: ServerLiveGame?, + val lastFinishedGame: ServerLiveGame? +) + +data class ServerReaction( + val id: String, + val emoji: String, + val senderUid: String, + val senderName: String?, + val createdAtEpochMillis: Long? +) + +data class ServerAccessStatus( + val status: String +) + +data class ServerPendingAccessRequest( + val uid: String, + val name: String, + val email: String, + val createdAtEpochMillis: Long? +) + +data class ServerActiveMember( + val uid: String, + val name: String, + val playerId: String, + val role: String, + val active: Boolean +) + +data class ServerAdminUsers( + val requests: List, + val members: List +) + +data class ServerDataAuditPlayer( + val id: String, + val name: String +) + +data class ServerDataAuditGame( + val id: String, + val gameType: String, + val isTest: Boolean, + val affectsMainScore: Boolean, + val mainScoreApplied: Boolean, + val targetScore: Int, + val playerIds: List, + val finalScores: Map, + val roundHistory: List, + val loserId: String?, + val finishedAtEpochMillis: Long?, + val hasFinalScores: Boolean +) + +data class ServerDataAuditEvent( + val id: String, + val type: String, + val playerId: String?, + val oldValue: Int?, + val newValue: Int?, + val gameId: String?, + val values: Map, + val eventAtEpochMillis: Long? +) + +data class ServerDataAuditSource( + val players: List, + val scores: Map, + val games: List, + val auditEvents: List +) + +class ServerPollingHandle( + private val cancelAction: () -> Unit +) { + @Volatile + private var stopped = + false + + fun remove() { + if (!stopped) { + stopped = true + cancelAction() + } + } +} + +object DurakServerApi { + + private const val TAG = + "DurakServerApi" + + private const val BASE_URL = + "https://5.188.21.226" + + private const val CONNECT_TIMEOUT_MS = + 7_000 + + private const val READ_TIMEOUT_MS = + 10_000 + + private val mainHandler = + Handler( + Looper.getMainLooper() + ) + + fun login( + email: String, + password: String, + onSuccess: (DurakSession) -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "email", + email.trim() + ) + .put( + "password", + password + ) + .toString() + + plainJsonRequest( + method = + "POST", + path = + "/api/v1/auth/login", + body = + body, + onSuccess = { + json -> + + onSuccess( + parseAuthSession( + json + ) + ) + }, + onError = + onError + ) + } + + + fun register( + name: String, + email: String, + password: String, + onSuccess: (DurakSession) -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "name", + name.trim() + ) + .put( + "email", + email.trim() + ) + .put( + "password", + password + ) + .toString() + + plainJsonRequest( + method = + "POST", + path = + "/api/v1/auth/register", + body = + body, + onSuccess = { + json -> + + onSuccess( + parseAuthSession( + json + ) + ) + }, + onError = + onError + ) + } + + + fun logout( + user: DurakSession + ) { + + authenticatedJsonRequest( + user = + user, + method = + "POST", + path = + "/api/v1/auth/logout", + body = + null, + allowNetworkRetry = + false, + onSuccess = { + }, + onError = { + } + ) + } + + + private fun parseAuthSession( + json: JSONObject + ): DurakSession { + + return DurakSession( + uid = + json.optString( + "uid", + "" + ), + name = + json.optString( + "name", + "Пользователь" + ), + email = + json.optString( + "email", + "" + ), + token = + json.optString( + "token", + "" + ), + expiresAtEpochMillis = + json.optLong( + "expiresAtEpochMillis", + 0L + ) + ) + } + + + fun loadMe( + user: DurakSession, + onSuccess: (ServerMe) -> Unit, + onForbidden: () -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = "/api/v1/me", + body = null, + onForbidden = onForbidden, + onSuccess = { json -> + + onSuccess( + ServerMe( + uid = + json.optString( + "uid", + user.uid + ), + email = + json.optString( + "email", + "" + ) + .takeIf { + it.isNotBlank() && + it != + "null" + }, + name = + json.optString( + "name", + "Пользователь" + ), + playerId = + json.optString( + "playerId", + "" + ), + role = + json.optString( + "role", + "viewer" + ), + active = + json.optBoolean( + "active", + true + ) + ) + ) + }, + onError = onError + ) + } + + fun loadMainScore( + user: DurakSession, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = "/api/v1/main-score", + body = null, + onSuccess = { json -> + + val array = + json.getJSONArray( + "players" + ) + + val players = + buildList { + + for ( + index in + 0 until array.length() + ) { + + val item = + array.getJSONObject( + index + ) + + add( + ServerMainScorePlayer( + id = + item.optString( + "id", + "" + ), + name = + item.optString( + "name", + "" + ), + gender = + item.optString( + "gender", + "" + ), + isCore = + item.optBoolean( + "isCore", + false + ), + sortOrder = + item.optInt( + "sortOrder", + 999 + ), + losses = + item.optInt( + "losses", + 0 + ) + ) + ) + } + } + + Log.i( + TAG, + "Main score loaded from VPS: players=${players.size}" + ) + + onSuccess( + players + ) + }, + onError = onError + ) + } + + fun correctMainScore( + user: DurakSession, + playerId: String, + newValue: Int, + requestId: String, + onSuccess: (ServerMainScoreCorrection) -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "playerId", + playerId + ) + .put( + "newValue", + newValue + ) + .put( + "requestId", + requestId + ) + .toString() + + authenticatedJsonRequest( + user = user, + method = "POST", + path = + "/api/v1/main-score/correct", + body = body, + onSuccess = { json -> + + val result = + ServerMainScoreCorrection( + playerId = + json.optString( + "playerId", + playerId + ), + oldValue = + json.optInt( + "oldValue", + newValue + ), + newValue = + json.optInt( + "newValue", + newValue + ), + alreadyApplied = + json.optBoolean( + "alreadyApplied", + false + ) + ) + + Log.i( + TAG, + "Main score correction synced: player=${result.playerId}, ${result.oldValue}->${result.newValue}" + ) + + onSuccess( + result + ) + }, + onError = onError + ) + } + + fun finishGame( + user: DurakSession, + gameId: String, + loserId: String, + onSuccess: (ServerFinishGameResult) -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "loserId", + loserId + ) + .toString() + + authenticatedJsonRequest( + user = user, + method = "POST", + path = + "/api/v1/games/$gameId/finish", + body = body, + onSuccess = { json -> + + val newLosses = + if ( + json.isNull( + "newLosses" + ) + ) { + null + } else { + json.optInt( + "newLosses" + ) + } + + val result = + ServerFinishGameResult( + gameId = + json.optString( + "gameId", + gameId + ), + loserId = + json.optString( + "loserId", + loserId + ), + alreadyFinished = + json.optBoolean( + "alreadyFinished", + false + ), + affectsMainScore = + json.optBoolean( + "affectsMainScore", + false + ), + mainScoreApplied = + json.optBoolean( + "mainScoreApplied", + false + ), + newLosses = + newLosses + ) + + Log.i( + TAG, + "Game finish synced: game=${result.gameId}, loser=${result.loserId}, newLosses=${result.newLosses}" + ) + + onSuccess( + result + ) + }, + onError = onError + ) + } + + fun createGame( + user: DurakSession, + setup: GameSetup, + isTest: Boolean, + onSuccess: (ServerLoadedGame) -> Unit, + onError: (String) -> Unit + ) { + + val requestId = + UUID.randomUUID() + .toString() + + val gameType = + if ( + setup.type == + GameType.CLASSIC + ) { + "classic" + } else { + "custom" + } + + val players = + JSONArray() + + setup.players.forEach { + player -> + + players.put( + JSONObject() + .put( + "id", + player.id + ) + .put( + "name", + player.name + ) + .put( + "gender", + player.gender + ) + .put( + "isCore", + player.isCore + ) + .put( + "isGuest", + player.isGuest + ) + ) + } + + val body = + JSONObject() + .put( + "requestId", + requestId + ) + .put( + "gameType", + gameType + ) + .put( + "targetScore", + setup.targetScore + ) + .put( + "players", + players + ) + .put( + "isTest", + isTest + ) + .toString() + + authenticatedJsonRequest( + user = user, + method = "POST", + path = + "/api/v1/games/create", + body = body, + onSuccess = { json -> + + val gameJson = + json.getJSONObject( + "game" + ) + + onSuccess( + parseLoadedGame( + gameJson + ) + ) + }, + onError = onError + ) + } + + fun loadLatestUnfinishedGame( + user: DurakSession, + isTest: Boolean, + onSuccess: (ServerLoadedGame?) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = + "/api/v1/games/unfinished?isTest=$isTest", + body = null, + onSuccess = { json -> + + if ( + json.isNull( + "game" + ) + ) { + onSuccess( + null + ) + } else { + onSuccess( + parseLoadedGame( + json.getJSONObject( + "game" + ) + ) + ) + } + }, + onError = onError + ) + } + + fun loadGameState( + user: DurakSession, + gameId: String, + onSuccess: (ServerGameState) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = + "/api/v1/games/$gameId/state", + body = null, + onSuccess = { json -> + + val game = + json.getJSONObject( + "game" + ) + + onSuccess( + ServerGameState( + scores = + parseScores( + game.optJSONObject( + "scores" + ) + ), + history = + parseHistory( + game.optJSONArray( + "history" + ) + ) + ) + ) + }, + onError = onError + ) + } + + fun loadGameStatus( + user: DurakSession, + gameId: String, + onSuccess: (String) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = + "/api/v1/games/$gameId/status", + body = null, + onSuccess = { json -> + + onSuccess( + json.optString( + "status", + "unknown" + ) + ) + }, + onError = onError + ) + } + + fun applyGameActions( + user: DurakSession, + gameId: String, + actions: List, + onSuccess: (ServerGameState) -> Unit, + onError: (String) -> Unit + ) { + + val requestId = + UUID.randomUUID() + .toString() + + val actionsJson = + JSONArray() + + actions.forEach { + action -> + + val item = + JSONObject() + + when ( + action.type + ) { + + GameActionType.ADD_POINT -> { + item.put( + "type", + "add_point" + ) + + item.put( + "playerId", + action.playerId + ) + } + + GameActionType.UNDO_LAST -> { + item.put( + "type", + "undo_last" + ) + } + } + + actionsJson.put( + item + ) + } + + val body = + JSONObject() + .put( + "requestId", + requestId + ) + .put( + "actions", + actionsJson + ) + .toString() + + authenticatedJsonRequest( + user = user, + method = "POST", + path = + "/api/v1/games/$gameId/actions", + body = body, + onSuccess = { json -> + + onSuccess( + ServerGameState( + scores = + parseScores( + json.optJSONObject( + "scores" + ) + ), + history = + parseHistory( + json.optJSONArray( + "history" + ) + ) + ) + ) + }, + onError = onError + ) + } + + fun pauseGame( + user: DurakSession, + gameId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + simpleGamePost( + user = + user, + path = + "/api/v1/games/$gameId/pause", + body = + JSONObject() + .toString(), + onSuccess = + onSuccess, + onError = + onError + ) + } + + fun resumeGame( + user: DurakSession, + gameId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + simpleGamePost( + user = + user, + path = + "/api/v1/games/$gameId/resume", + body = + JSONObject() + .toString(), + onSuccess = + onSuccess, + onError = + onError + ) + } + + fun updateTargetScore( + user: DurakSession, + gameId: String, + newTargetScore: Int, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "newTargetScore", + newTargetScore + ) + .toString() + + simpleGamePost( + user = + user, + path = + "/api/v1/games/$gameId/target-score", + body = + body, + onSuccess = + onSuccess, + onError = + onError + ) + } + + fun cancelGame( + user: DurakSession, + gameId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + simpleGamePost( + user = + user, + path = + "/api/v1/games/$gameId/cancel", + body = + JSONObject() + .toString(), + onSuccess = + onSuccess, + onError = + onError + ) + } + + private fun simpleGamePost( + user: DurakSession, + path: String, + body: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "POST", + path = path, + body = body, + onSuccess = { + onSuccess() + }, + onError = onError + ) + } + + private fun parseLoadedGame( + json: JSONObject + ): ServerLoadedGame { + + val playersJson = + json.optJSONArray( + "players" + ) + + val players = + buildList { + + if ( + playersJson != + null + ) { + + for ( + index in + 0 until + playersJson.length() + ) { + + val item = + playersJson + .getJSONObject( + index + ) + + add( + ServerGamePlayer( + id = + item.optString( + "id", + "" + ), + name = + item.optString( + "name", + "" + ), + gender = + item.optString( + "gender", + "male" + ), + isCore = + item.optBoolean( + "isCore", + false + ), + isGuest = + item.optBoolean( + "isGuest", + false + ) + ) + ) + } + } + } + + return ServerLoadedGame( + id = + json.optString( + "id", + "" + ), + gameType = + json.optString( + "gameType", + "custom" + ), + status = + json.optString( + "status", + "unknown" + ), + targetScore = + json.optInt( + "targetScore", + 15 + ), + isTest = + json.optBoolean( + "isTest", + false + ), + affectsMainScore = + json.optBoolean( + "affectsMainScore", + false + ), + players = + players, + scores = + parseScores( + json.optJSONObject( + "scores" + ) + ), + history = + parseHistory( + json.optJSONArray( + "history" + ) + ), + startedAtEpochMillis = + json.optLong( + "startedAtEpochMillis", + 0L + ) + ) + } + + private fun parseScores( + json: JSONObject? + ): Map { + + if ( + json == + null + ) { + return emptyMap() + } + + val result = + mutableMapOf() + + val keys = + json.keys() + + while ( + keys.hasNext() + ) { + + val key = + keys.next() + + result[key] = + json.optInt( + key, + 0 + ) + } + + return result + } + + private fun parseHistory( + array: JSONArray? + ): List { + + if ( + array == + null + ) { + return emptyList() + } + + return buildList { + + for ( + index in + 0 until array.length() + ) { + + val value = + array.optString( + index, + "" + ) + + if ( + value.isNotBlank() + ) { + add( + value + ) + } + } + } + } + + fun loadFinishedGames( + user: DurakSession, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = + "/api/v1/games/finished?limit=500", + body = null, + onSuccess = { json -> + + val array = + json.getJSONArray( + "games" + ) + + val games = + buildList { + + for ( + index in + 0 until array.length() + ) { + + val item = + array.getJSONObject( + index + ) + + val playersJson = + item.optJSONArray( + "players" + ) + + val players = + buildList { + + if ( + playersJson != + null + ) { + + for ( + playerIndex in + 0 until playersJson.length() + ) { + + val player = + playersJson + .getJSONObject( + playerIndex + ) + + add( + ServerGamePlayer( + id = + player.optString( + "id", + "" + ), + name = + player.optString( + "name", + "" + ), + gender = + player.optString( + "gender", + "male" + ), + isCore = + player.optBoolean( + "isCore", + false + ), + isGuest = + player.optBoolean( + "isGuest", + false + ) + ) + ) + } + } + } + + add( + ServerFinishedGame( + id = + item.optString( + "id", + "" + ), + gameType = + item.optString( + "gameType", + "custom" + ), + isTest = + item.optBoolean( + "isTest", + false + ), + targetScore = + item.optInt( + "targetScore", + 15 + ), + players = + players, + finalScores = + parseScores( + item.optJSONObject( + "finalScores" + ) + ), + history = + parseHistory( + item.optJSONArray( + "history" + ) + ), + loserId = + item.optString( + "loserId", + "" + ), + loserName = + item.optString( + "loserName", + "Неизвестно" + ), + loserGender = + item.optString( + "loserGender", + "male" + ), + startedAtEpochMillis = + item.optLong( + "startedAtEpochMillis", + 0L + ), + finishedAtEpochMillis = + item.optLong( + "finishedAtEpochMillis", + 0L + ) + ) + ) + } + } + + Log.i( + TAG, + "Finished games loaded from VPS: games=${games.size}" + ) + + onSuccess( + games + ) + }, + onError = onError + ) + } + + fun loadAuditLogs( + user: DurakSession, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = user, + method = "GET", + path = + "/api/v1/audit-logs?limit=500", + body = null, + onSuccess = { json -> + + val array = + json.getJSONArray( + "logs" + ) + + val logs = + buildList { + + for ( + index in + 0 until array.length() + ) { + + val item = + array.getJSONObject( + index + ) + + val detailsJson = + item.optJSONArray( + "details" + ) + + val details = + buildList { + + if ( + detailsJson != + null + ) { + + for ( + detailIndex in + 0 until detailsJson.length() + ) { + + add( + detailsJson.optString( + detailIndex, + "" + ) + ) + } + } + } + .filter { + it.isNotBlank() + } + + val eventAt = + if ( + item.isNull( + "eventAtEpochMillis" + ) + ) { + null + } else { + item.optLong( + "eventAtEpochMillis" + ) + } + + add( + ServerAuditLog( + id = + item.optString( + "id", + "" + ), + type = + item.optString( + "type", + "unknown" + ), + eventAtEpochMillis = + eventAt, + actorName = + item.optString( + "actorName", + "Неизвестно" + ), + title = + item.optString( + "title", + "Системное действие" + ), + details = + details, + isTest = + item.optBoolean( + "isTest", + false + ) + ) + ) + } + } + + Log.i( + TAG, + "Audit log loaded from VPS: logs=${logs.size}" + ) + + onSuccess( + logs + ) + }, + onError = onError + ) + } + + private fun parseLiveGame( + item: JSONObject? + ): ServerLiveGame? { + + if ( + item == + null + ) { + return null + } + + val playersJson = + item.optJSONArray( + "players" + ) + + val players = + buildList { + + if ( + playersJson != + null + ) { + + for ( + index in + 0 until playersJson.length() + ) { + + val player = + playersJson + .getJSONObject( + index + ) + + add( + ServerGamePlayer( + id = + player.optString( + "id", + "" + ), + name = + player.optString( + "name", + "" + ), + gender = + player.optString( + "gender", + "male" + ), + isCore = + player.optBoolean( + "isCore", + false + ), + isGuest = + player.optBoolean( + "isGuest", + false + ) + ) + ) + } + } + } + + fun nullableLong( + key: String + ): Long? = + if ( + item.isNull( + key + ) + ) { + null + } else { + item.optLong( + key + ) + } + + fun nullableString( + key: String + ): String? = + item.optString( + key, + "" + ) + .takeIf { + it.isNotBlank() && + it != + "null" + } + + return ServerLiveGame( + id = + item.optString( + "id", + "" + ), + gameType = + item.optString( + "gameType", + "custom" + ), + status = + item.optString( + "status", + "active" + ), + targetScore = + item.optInt( + "targetScore", + 15 + ), + isTest = + item.optBoolean( + "isTest", + false + ), + players = + players, + scores = + parseScores( + item.optJSONObject( + "scores" + ) + ), + finalScores = + parseScores( + item.optJSONObject( + "finalScores" + ) + ), + history = + parseHistory( + item.optJSONArray( + "history" + ) + ), + startedAtEpochMillis = + item.optLong( + "startedAtEpochMillis", + 0L + ), + lastActionType = + nullableString( + "lastActionType" + ), + lastActionAtEpochMillis = + nullableLong( + "lastActionAtEpochMillis" + ), + finishedAtEpochMillis = + nullableLong( + "finishedAtEpochMillis" + ), + loserId = + nullableString( + "loserId" + ), + loserName = + nullableString( + "loserName" + ), + loserGender = + nullableString( + "loserGender" + ) + ) + } + + private fun parseReaction( + item: JSONObject + ): ServerReaction { + + val createdAt = + if ( + item.isNull( + "createdAtEpochMillis" + ) + ) { + null + } else { + item.optLong( + "createdAtEpochMillis" + ) + } + + return ServerReaction( + id = + item.optString( + "id", + "" + ), + emoji = + item.optString( + "emoji", + "" + ), + senderUid = + item.optString( + "senderUid", + "" + ), + senderName = + item.optString( + "senderName", + "" + ) + .takeIf { + it.isNotBlank() + }, + createdAtEpochMillis = + createdAt + ) + } + + fun loadLiveSnapshot( + user: DurakSession, + isTest: Boolean, + onSuccess: (ServerLiveSnapshot) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = + user, + method = + "GET", + path = + "/api/v1/live/snapshot?isTest=$isTest", + body = + null, + onSuccess = { + json -> + + val active = + if ( + json.isNull( + "activeGame" + ) + ) { + null + } else { + parseLiveGame( + json.optJSONObject( + "activeGame" + ) + ) + } + + val finished = + if ( + json.isNull( + "lastFinishedGame" + ) + ) { + null + } else { + parseLiveGame( + json.optJSONObject( + "lastFinishedGame" + ) + ) + } + + onSuccess( + ServerLiveSnapshot( + activeGame = + active, + lastFinishedGame = + finished + ) + ) + }, + onError = + onError + ) + } + + fun startLivePolling( + user: DurakSession, + isTest: Boolean, + intervalMs: Long = 450L, + onSnapshot: (ServerLiveSnapshot) -> Unit, + onError: (String) -> Unit + ): ServerPollingHandle { + + var stopped = + false + + lateinit var tick: + Runnable + + fun schedule( + delayMs: Long + ) { + if ( + !stopped + ) { + mainHandler.postDelayed( + tick, + delayMs + ) + } + } + + tick = + Runnable { + + if ( + stopped + ) { + return@Runnable + } + + loadLiveSnapshot( + user = + user, + isTest = + isTest, + onSuccess = { + snapshot -> + + if ( + stopped + ) { + return@loadLiveSnapshot + } + + onSnapshot( + snapshot + ) + + schedule( + intervalMs + ) + }, + onError = { + message -> + + if ( + stopped + ) { + return@loadLiveSnapshot + } + + onError( + message + ) + + schedule( + 1_200L + ) + } + ) + } + + mainHandler.post( + tick + ) + + return ServerPollingHandle { + stopped = true + mainHandler.removeCallbacks( + tick + ) + } + } + + fun sendReaction( + user: DurakSession, + gameId: String, + requestId: String, + emoji: String, + onSuccess: (ServerReaction) -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "requestId", + requestId + ) + .put( + "emoji", + emoji + ) + .toString() + + authenticatedJsonRequest( + user = + user, + method = + "POST", + path = + "/api/v1/games/$gameId/reactions", + body = + body, + onSuccess = { + json -> + + val reaction = + json.getJSONObject( + "reaction" + ) + + onSuccess( + parseReaction( + reaction + ) + ) + }, + onError = + onError + ) + } + + fun loadReactions( + user: DurakSession, + gameId: String, + onSuccess: (List) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = + user, + method = + "GET", + path = + "/api/v1/games/$gameId/reactions?limit=80", + body = + null, + onSuccess = { + json -> + + val array = + json.getJSONArray( + "reactions" + ) + + val reactions = + buildList { + + for ( + index in + 0 until array.length() + ) { + + add( + parseReaction( + array.getJSONObject( + index + ) + ) + ) + } + } + + onSuccess( + reactions + ) + }, + onError = + onError + ) + } + + fun startReactionPolling( + user: DurakSession, + gameId: String, + intervalMs: Long = 450L, + onSnapshot: (List) -> Unit, + onError: (String) -> Unit + ): ServerPollingHandle { + + var stopped = + false + + lateinit var tick: + Runnable + + fun schedule( + delayMs: Long + ) { + if ( + !stopped + ) { + mainHandler.postDelayed( + tick, + delayMs + ) + } + } + + tick = + Runnable { + + if ( + stopped + ) { + return@Runnable + } + + loadReactions( + user = + user, + gameId = + gameId, + onSuccess = { + reactions -> + + if ( + stopped + ) { + return@loadReactions + } + + onSnapshot( + reactions + ) + + schedule( + intervalMs + ) + }, + onError = { + message -> + + if ( + stopped + ) { + return@loadReactions + } + + onError( + message + ) + + schedule( + 1_200L + ) + } + ) + } + + mainHandler.post( + tick + ) + + return ServerPollingHandle { + stopped = true + mainHandler.removeCallbacks( + tick + ) + } + } + + fun loadAccessStatus( + user: DurakSession, + onSuccess: (ServerAccessStatus) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = + user, + method = + "GET", + path = + "/api/v1/access-status", + body = + null, + onSuccess = { + json -> + + onSuccess( + ServerAccessStatus( + status = + json.optString( + "status", + "none" + ) + ) + ) + }, + onError = + onError + ) + } + + fun loadAdminUsers( + user: DurakSession, + onSuccess: (ServerAdminUsers) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = + user, + method = + "GET", + path = + "/api/v1/admin/users", + body = + null, + onSuccess = { + json -> + + val requestsJson = + json.getJSONArray( + "requests" + ) + + val requests = + buildList { + + for ( + index in + 0 until requestsJson.length() + ) { + + val item = + requestsJson + .getJSONObject( + index + ) + + add( + ServerPendingAccessRequest( + uid = + item.optString( + "uid", + "" + ), + name = + item.optString( + "name", + "Без имени" + ), + email = + item.optString( + "email", + "Почта не указана" + ), + createdAtEpochMillis = + if ( + item.isNull( + "createdAtEpochMillis" + ) + ) { + null + } else { + item.optLong( + "createdAtEpochMillis" + ) + } + ) + ) + } + } + + val membersJson = + json.getJSONArray( + "members" + ) + + val members = + buildList { + + for ( + index in + 0 until membersJson.length() + ) { + + val item = + membersJson + .getJSONObject( + index + ) + + add( + ServerActiveMember( + uid = + item.optString( + "uid", + "" + ), + name = + item.optString( + "name", + "Пользователь" + ), + playerId = + item.optString( + "playerId", + "" + ), + role = + item.optString( + "role", + "viewer" + ), + active = + item.optBoolean( + "active", + true + ) + ) + ) + } + } + + onSuccess( + ServerAdminUsers( + requests = + requests, + members = + members + ) + ) + }, + onError = + onError + ) + } + + fun approveAdminUser( + user: DurakSession, + requestUid: String, + playerId: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + + val body = + JSONObject() + .put( + "requestUid", + requestUid + ) + .put( + "playerId", + playerId + ) + .toString() + + authenticatedJsonRequest( + user = + user, + method = + "POST", + path = + "/api/v1/admin/users/approve", + body = + body, + onSuccess = { + onSuccess() + }, + onError = + onError + ) + } + + fun loadDataAuditSource( + user: DurakSession, + onSuccess: (ServerDataAuditSource) -> Unit, + onError: (String) -> Unit + ) { + + authenticatedJsonRequest( + user = + user, + method = + "GET", + path = + "/api/v1/data-audit/source", + body = + null, + onSuccess = { + json -> + + val playersJson = + json.getJSONArray( + "players" + ) + + val players = + buildList { + + for ( + index in + 0 until playersJson.length() + ) { + + val item = + playersJson + .getJSONObject( + index + ) + + add( + ServerDataAuditPlayer( + id = + item.optString( + "id", + "" + ), + name = + item.optString( + "name", + "" + ) + ) + ) + } + } + + val scores = + parseScores( + json.optJSONObject( + "scores" + ) + ) + + val gamesJson = + json.getJSONArray( + "games" + ) + + val games = + buildList { + + for ( + index in + 0 until gamesJson.length() + ) { + + val item = + gamesJson + .getJSONObject( + index + ) + + val idsJson = + item.optJSONArray( + "playerIds" + ) + + val playerIds = + buildList { + + if ( + idsJson != + null + ) { + + for ( + idIndex in + 0 until idsJson.length() + ) { + add( + idsJson.optString( + idIndex, + "" + ) + ) + } + } + } + .filter { + it.isNotBlank() + } + + add( + ServerDataAuditGame( + id = + item.optString( + "id", + "" + ), + gameType = + item.optString( + "gameType", + "unknown" + ), + isTest = + item.optBoolean( + "isTest", + false + ), + affectsMainScore = + item.optBoolean( + "affectsMainScore", + false + ), + mainScoreApplied = + item.optBoolean( + "mainScoreApplied", + false + ), + targetScore = + item.optInt( + "targetScore", + 0 + ), + playerIds = + playerIds, + finalScores = + parseScores( + item.optJSONObject( + "finalScores" + ) + ), + roundHistory = + parseHistory( + item.optJSONArray( + "roundHistory" + ) + ), + loserId = + item.optString( + "loserId", + "" + ) + .takeIf { + it.isNotBlank() + }, + finishedAtEpochMillis = + if ( + item.isNull( + "finishedAtEpochMillis" + ) + ) { + null + } else { + item.optLong( + "finishedAtEpochMillis" + ) + }, + hasFinalScores = + item.optBoolean( + "hasFinalScores", + false + ) + ) + ) + } + } + + val eventsJson = + json.getJSONArray( + "auditEvents" + ) + + val events = + buildList { + + for ( + index in + 0 until eventsJson.length() + ) { + + val item = + eventsJson + .getJSONObject( + index + ) + + fun nullableInt( + key: String + ): Int? = + if ( + item.isNull( + key + ) + ) { + null + } else { + item.optInt( + key + ) + } + + fun nullableLong( + key: String + ): Long? = + if ( + item.isNull( + key + ) + ) { + null + } else { + item.optLong( + key + ) + } + + fun nullableString( + key: String + ): String? = + item.optString( + key, + "" + ) + .takeIf { + it.isNotBlank() + } + + add( + ServerDataAuditEvent( + id = + item.optString( + "id", + "" + ), + type = + item.optString( + "type", + "unknown" + ), + playerId = + nullableString( + "playerId" + ), + oldValue = + nullableInt( + "oldValue" + ), + newValue = + nullableInt( + "newValue" + ), + gameId = + nullableString( + "gameId" + ), + values = + parseScores( + item.optJSONObject( + "values" + ) + ), + eventAtEpochMillis = + nullableLong( + "eventAtEpochMillis" + ) + ) + ) + } + } + + onSuccess( + ServerDataAuditSource( + players = + players, + scores = + scores, + games = + games, + auditEvents = + events + ) + ) + }, + onError = + onError + ) + } + + private fun authenticatedJsonRequest( + user: DurakSession, + method: String, + path: String, + body: String?, + allowNetworkRetry: Boolean = true, + onForbidden: (() -> Unit)? = null, + onSuccess: (JSONObject) -> Unit, + onError: (String) -> Unit + ) { + + val token = + user.token.trim() + + if ( + token.isBlank() + ) { + + onMain { + onError( + "Сессия отсутствует. Войдите снова." + ) + } + + return + } + + thread( + name = + "durak-api-request", + isDaemon = + true + ) { + + var connection: + HttpURLConnection? = + null + + try { + + connection = + ( + URL( + "$BASE_URL$path" + ) + .openConnection() + as HttpURLConnection + ) + .apply { + + requestMethod = + method + + connectTimeout = + CONNECT_TIMEOUT_MS + + readTimeout = + READ_TIMEOUT_MS + + setRequestProperty( + "Accept", + "application/json" + ) + + setRequestProperty( + "Authorization", + "Bearer $token" + ) + + useCaches = + false + + if ( + body != + null + ) { + + doOutput = + true + + setRequestProperty( + "Content-Type", + "application/json; charset=utf-8" + ) + } + } + + if ( + body != + null + ) { + + connection + .outputStream + .bufferedWriter( + Charsets.UTF_8 + ) + .use { + it.write( + body + ) + } + } + + val code = + connection + .responseCode + + val responseBody = + readResponseBody( + connection, + code + ) + + when { + + code in + 200..299 -> { + + val json = + if ( + responseBody + .isBlank() + ) { + JSONObject() + } else { + JSONObject( + responseBody + ) + } + + onMain { + onSuccess( + json + ) + } + } + + code == + HttpURLConnection + .HTTP_UNAUTHORIZED -> { + + onMain { + onError( + "Сессия истекла. Войдите снова." + ) + } + } + + code == + HttpURLConnection + .HTTP_FORBIDDEN -> { + + onMain { + + if ( + onForbidden != + null + ) { + onForbidden() + } else { + onError( + extractDetail( + responseBody, + "Нет доступа" + ) + ) + } + } + } + + else -> { + + val message = + extractDetail( + responseBody, + "VPS ответил HTTP $code" + ) + + Log.w( + TAG, + "$path HTTP $code: $responseBody" + ) + + onMain { + onError( + message + ) + } + } + } + + } catch ( + error: Exception + ) { + + Log.e( + TAG, + "$path request failed", + error + ) + + if ( + allowNetworkRetry + ) { + + try { + Thread.sleep( + 500L + ) + } catch ( + ignored: InterruptedException + ) { + Thread + .currentThread() + .interrupt() + } + + onMain { + + authenticatedJsonRequest( + user = + user, + method = + method, + path = + path, + body = + body, + allowNetworkRetry = + false, + onForbidden = + onForbidden, + onSuccess = + onSuccess, + onError = + onError + ) + } + + } else { + + onMain { + onError( + "VPS недоступен: ${error.message ?: "ошибка сети"}" + ) + } + } + + } finally { + + connection + ?.disconnect() + } + } + } + + + private fun plainJsonRequest( + method: String, + path: String, + body: String?, + allowNetworkRetry: Boolean = true, + onSuccess: (JSONObject) -> Unit, + onError: (String) -> Unit + ) { + + thread( + name = + "durak-auth-request", + isDaemon = + true + ) { + + var connection: + HttpURLConnection? = + null + + try { + + connection = + ( + URL( + "$BASE_URL$path" + ) + .openConnection() + as HttpURLConnection + ) + .apply { + + requestMethod = + method + + connectTimeout = + CONNECT_TIMEOUT_MS + + readTimeout = + READ_TIMEOUT_MS + + setRequestProperty( + "Accept", + "application/json" + ) + + useCaches = + false + + if ( + body != + null + ) { + doOutput = + true + + setRequestProperty( + "Content-Type", + "application/json; charset=utf-8" + ) + } + } + + if ( + body != + null + ) { + connection + .outputStream + .bufferedWriter( + Charsets.UTF_8 + ) + .use { + it.write( + body + ) + } + } + + val code = + connection + .responseCode + + val responseBody = + readResponseBody( + connection, + code + ) + + if ( + code in + 200..299 + ) { + + val json = + if ( + responseBody + .isBlank() + ) { + JSONObject() + } else { + JSONObject( + responseBody + ) + } + + onMain { + onSuccess( + json + ) + } + + } else { + + val message = + extractDetail( + responseBody, + "VPS ответил HTTP $code" + ) + + onMain { + onError( + message + ) + } + } + + } catch ( + error: Exception + ) { + + if ( + allowNetworkRetry + ) { + + try { + Thread.sleep( + 500L + ) + } catch ( + ignored: InterruptedException + ) { + Thread + .currentThread() + .interrupt() + } + + onMain { + plainJsonRequest( + method = + method, + path = + path, + body = + body, + allowNetworkRetry = + false, + onSuccess = + onSuccess, + onError = + onError + ) + } + + } else { + + onMain { + onError( + "VPS недоступен: ${error.message ?: "ошибка сети"}" + ) + } + } + + } finally { + + connection + ?.disconnect() + } + } + } + + + private fun extractDetail( + body: String, + fallback: String + ): String { + + if ( + body.isBlank() + ) { + return fallback + } + + return try { + + JSONObject( + body + ) + .optString( + "detail", + fallback + ) + .ifBlank { + fallback + } + + } catch ( + ignored: Exception + ) { + + fallback + } + } + + private fun readResponseBody( + connection: HttpURLConnection, + code: Int + ): String { + + val stream = + if ( + code in + 200..299 + ) { + connection.inputStream + } else { + connection.errorStream + } + + return stream + ?.bufferedReader( + Charsets.UTF_8 + ) + ?.use { + it.readText() + } + .orEmpty() + } + + private fun onMain( + action: () -> Unit + ) { + + if ( + Looper.myLooper() == + Looper.getMainLooper() + ) { + + action() + + } else { + + mainHandler.post( + action + ) + } + } +} diff --git a/app/src/main/java/ru/durakscore/app/StreakPraise.kt b/app/src/main/java/ru/durakscore/app/StreakPraise.kt new file mode 100644 index 0000000..d0976cd --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/StreakPraise.kt @@ -0,0 +1,959 @@ +package ru.durakscore.app + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.max +import kotlinx.coroutines.delay + +const val STREAK_PRAISE_DURATION_MS = 30_000L + +data class StreakPraiseResult( + val playerId: String, + val playerName: String, + val streak: Int, + val message: String +) + +private val streakPraiseTemplates = listOf( + "{name} держит {streak} без +1. Остальные, вы там карты держите или просто хуй в руке?", + "У {nameGen} уже {streak}. Остальные выглядят так, будто правила впервые увидели пять минут назад.", + "{name} снова без +1. Стол превращается в выставку коллективной рукожопости.", + "Очко опять прошло мимо {nameGen}. Остальные, блядь, хоть вид сделайте, что сопротивляетесь.", + "{name} держит {streak}. На фоне остальных это уже выглядит как игра взрослого с детсадом.", + "У {nameGen} серия {streak}. Остальные уверенно работают поставщиками чужой красивой статистики.", + "{name} опять сухой. Остальные за столом сегодня чисто мебель с картами.", + "Серия {nameGen}: {streak}. У остальных, похоже, мозг ушёл на перекур и не вернулся.", + "{name} снова мимо +1. Остальные позорятся настолько синхронно, что это уже командная работа.", + "У {nameGen} {streak} без поражения. Остальным пора не карты менять, а руки целиком.", + "{name} держится {streak}. Стол официально признан зоной массового обсёра.", + "Очередная раздача — и снова не {nameDat}. Остальные, вы точно сюда играть пришли?", + "{name} опять без +1. Пока остальные собирают очки, {name} собирает доказательства вашей беспомощности.", + "У {nameGen} серия {streak}. Остальные выглядят как три ошибки, случайно севшие за один стол.", + "{name} держит {streak}. Кто-нибудь объясните остальным, что проигрывать по очереди — не стратегия.", + "Очко снова не досталось {nameDat}. Стол продолжает методично унижать всех остальных.", + "{name} снова сухой. Остальные уже не соперники — обслуживающий персонал этой серии.", + "Серия {streak} у {nameGen}. Чужая самооценка за столом сейчас где-то под плинтусом.", + "{name} держится. Остальные будто соревнуются, кто быстрее обосрётся.", + "У {nameGen} уже {streak}. Остальные могут продолжать изображать игру, если им так спокойнее.", + "{name} снова без +1. Приложение уже не считает это везением — оно просто ржёт над остальными.", + "Серия {nameGen}: {streak}. Остальные, сука, соберитесь хотя бы ради приличия.", + "{name} опять проходит чистым. У остальных статистика выглядит как протокол аварии.", + "Очко снова ушло не {nameDat}. Кто бы сомневался — цирк работает без выходных.", + "{name} держит {streak}. Остальным пора признать: сегодня вы не игроки, вы контент.", + "У {nameGen} серия {streak}. Стол уже напоминает персональную ферму чужих поражений.", + "{name} снова сухой. Остальные так стабильно лажают, что это можно заносить в трудовую.", + "Серия {streak} у {nameGen}. Остальным выдайте каски — по ним статистика уже ходит сапогами.", + "{name} держится {streak}. Остальные выглядят так, будто каждый ход принимают решение монеткой.", + "У {nameGen} всё ещё ноль новых проблем. У остальных — полный склад собственного пиздеца.", + "{name} опять без +1. Остальные, вы там соревнуетесь за звание главного долбоёба вечера?", + "Серия {nameGen}: {streak}. Кто-то за столом играет, остальные просто портят воздух.", + "{name} держит {streak}. На фоне остальных это уже не серия, а публичная порка статистикой.", + "Очко опять выбрало кого угодно, только не {nameAcc}. Остальные исправно несут свою службу позора.", + "{name} снова сухой. Остальные так дружно тонут, что спасательный круг уже бесполезен.", + "У {nameGen} серия {streak}. Если это ваши лучшие попытки, можно уже выключать свет.", + "{name} держится. Остальные сегодня доказали, что дно действительно может быть командным.", + "Серия {streak} у {nameGen}. У остальных руки есть, но по игре этого вообще не видно.", + "{name} опять без +1. Стол выглядит как место, где один играет, а остальные отбывают наказание.", + "У {nameGen} уже {streak}. Остальные, нахуй вы вообще сели, если собирались только страдать?", + "{name} снова проходит сухим. Остальные продолжают жрать +1 как будто это бесплатная раздача.", + "Серия {nameGen}: {streak}. У остальных уровень сопротивления — мокрый картон.", + "{name} держит {streak}. Похоже, единственная ваша общая тактика — не мешать {nameDat} кайфовать.", + "Очко снова не {nameDat}. Остальные, может, хоть правила перечитаете между унижениями?", + "{name} опять без +1. Ваша коллективная беспомощность уже начинает выглядеть профессионально.", + "У {nameGen} серия {streak}. Остальным пора открывать кружок «как стабильно обсираться за столом».", + "{name} держится {streak}. Статистика остальных выглядит так, будто её заполнял враг.", + "Серия {nameGen}: {streak}. Остальные сегодня не играют в дурака — они его рекламируют.", + "{name} снова сухой. Остальные, блядь, хоть одного нормального хода за вечер родите.", + "У {nameGen} уже {streak}. Стол официально переименован в филиал чужого унижения.", + "{name} опять без +1. Остальные с такой игрой могут требовать зарплату за массовку.", + "Серия {streak} у {nameGen}. Остальным пора перестать делать вид, что это всё ещё конкурентная игра.", + "{name} держится. Чужие поражения уже складываются у {nameGen} в красивую коллекцию.", + "У {nameGen} серия {streak}. Остальные выглядят как люди, которых сюда заманили обманом.", + "{name} снова сухой. Ваша игра настолько херовая, что даже случайность стесняется вмешиваться.", + "Очко опять прошло мимо {nameGen}. Остальные продолжают принимать весь пиздец на себя.", + "{name} держит {streak}. Кто-нибудь вызовите остальным техподдержку — игроки явно не загрузились.", + "Серия {nameGen}: {streak}. Остальные могут смело менять карты на таблички «я долбоёб».", + "{name} снова без +1. Это уже не преимущество — это издевательство над вашими попытками играть.", + "У {nameGen} уже {streak}. Остальные так уверенно сосут по статистике, будто тренировались.", + "{name} держится {streak}. На столе четыре места, а ощущение, что соперников у {nameGen} нет вообще.", + "Серия {streak} у {nameGen}. Остальные, может, пора перестать нажимать случайные кнопки в голове?", + "{name} опять сухой. Чужая серия растёт быстрее, чем ваша способность сделать выводы.", + "У {nameGen} серия {streak}. Стол уже не кипит — он смирился с вашей беспомощностью.", + "{name} снова без +1. Остальные выглядят как неудачный эксперимент по командному позору.", + "Очко опять не досталось {nameDat}. Да сколько можно так стабильно быть хуёвыми?", + "{name} держит {streak}. Если бы за ошибки давали медали, остальные уже стояли бы на пьедестале.", + "Серия {nameGen}: {streak}. Остальные уверенно превращают вечер в персональное шоу {nameGen}.", + "{name} снова сухой. Все вокруг получают +1, будто подписали коллективный договор на унижение.", + "У {nameGen} уже {streak}. Остальные могут дальше тасовать карты — навыки от этого не появятся.", + "{name} держится. По ощущениям, остальные играют не против {nameGen}, а против собственного мозга.", + "Серия {streak} у {nameGen}. Остальным пора признать поражение не в партии, а в профессии.", + "{name} опять без +1. Такая массовая рукожопость уже заслуживает отдельной ачивки.", + "У {nameGen} серия {streak}. Остальные, сука, вы хоть между раздачами анализируете, что творите?", + "{name} держит {streak}. Стол продолжает выдавать остальным пощёчины, а они ещё и очередь занимают.", + "Очко снова прошло мимо {nameGen}. Чужой позор уже работает на автомате.", + "{name} снова сухой. Остальные сегодня играют роль статистов в фильме «Как просрать всё».", + "Серия {nameGen}: {streak}. У остальных осталось два варианта: собраться или окончательно обосраться.", + "{name} держится {streak}. Пока остальные ищут удачу, им бы сначала найти руки.", + "У {nameGen} уже {streak}. Это уже не серия, это коллективное заявление остальных о профнепригодности.", + "{name} опять без +1. Стол давно понял, кто тут играет, а кто просто занимает стулья.", + "Серия {streak} у {nameGen}. Остальные продолжают доказывать, что предел тупого хода ещё не найден.", + "{name} снова сухой. Если ваша цель была сделать {nameAcc} легендой — план выполняется идеально.", + "У {nameGen} серия {streak}. Остальные с такой игрой скоро будут извиняться перед колодой.", + "{name} держит {streak}. Чужие +1 уже выглядят как добровольные пожертвования в фонд {nameGen}.", + "Очко опять не {nameDat}. Остальные, вы там специально по очереди подставляетесь?", + "{name} снова без +1. Ваша общая стратегия выглядит как «авось кто-нибудь другой обосрётся сильнее».", + "Серия {nameGen}: {streak}. Остальные уже не тонут — они обустраиваются на дне.", + "{name} держится. Статистика остальных настолько убогая, что её хочется накрыть салфеткой.", + "У {nameGen} уже {streak}. Если бы позор был валютой, остальные сегодня стали бы миллиардерами.", + "{name} опять сухой. Остальные так долго лажают, что это уже можно считать традицией.", + "Серия {streak} у {nameGen}. Колода, похоже, тоже потеряла уважение к остальным.", + "{name} держит {streak}. На вашем месте я бы уже проверил, не перепутали ли вы игру с саморазрушением.", + "У {nameGen} серия {streak}. Остальные продолжают героически проигрывать борьбу со здравым смыслом.", + "{name} снова без +1. Стол смотрит на остальных и тихо охуевает от стабильности провала.", + "Очко опять прошло мимо {nameGen}. Остальные настолько удобные соперники, что это уже подозрительно.", + "{name} держится {streak}. Вы там не командой ли решили сделать одного человека неприкасаемым?", + "Серия {nameGen}: {streak}. Остальным пора перестать искать оправдания — они уже закончились.", + "{name} снова сухой. У остальных сегодня всё прекрасно, кроме рук, головы и результата.", + "У {nameGen} уже {streak}. Стол официально сообщает: коллективный обсер продолжается." +) + +fun isStreakPraiseMilestone( + streak: Int +): Boolean { + return streak in intArrayOf( + 5, 7, 9, 10, 12, 13, 15, 17, 20, + 25, 30, 35, 40, 45, 50 + ) || + (streak > 50 && streak % 5 == 0) +} + + +private data class RussianNameForms( + val nominative: String, + val genitive: String, + val dative: String, + val accusative: String, + val instrumental: String +) + +private fun russianNameForms( + playerId: String, + rawName: String +): RussianNameForms { + + /* + * Для основной четвёрки задаём формы вручную — + * тут склонения должны быть безошибочными. + */ + return when (playerId.lowercase()) { + "dmitry" -> + RussianNameForms( + nominative = "Дмитрий", + genitive = "Дмитрия", + dative = "Дмитрию", + accusative = "Дмитрия", + instrumental = "Дмитрием" + ) + + "denis" -> + RussianNameForms( + nominative = "Денис", + genitive = "Дениса", + dative = "Денису", + accusative = "Дениса", + instrumental = "Денисом" + ) + + "rybka" -> + RussianNameForms( + nominative = "Рыбка", + genitive = "Рыбки", + dative = "Рыбке", + accusative = "Рыбку", + instrumental = "Рыбкой" + ) + + "masha" -> + RussianNameForms( + nominative = "Маша", + genitive = "Маши", + dative = "Маше", + accusative = "Машу", + instrumental = "Машей" + ) + + else -> + guessRussianNameForms( + rawName + ) + } +} + +private fun guessRussianNameForms( + rawName: String +): RussianNameForms { + + val name = + rawName.trim() + + if (name.isBlank()) { + return RussianNameForms( + nominative = rawName, + genitive = rawName, + dative = rawName, + accusative = rawName, + instrumental = rawName + ) + } + + val lower = + name.lowercase() + + return when { + lower.endsWith("ий") -> { + val stem = + name.dropLast(2) + + RussianNameForms( + nominative = name, + genitive = stem + "ия", + dative = stem + "ию", + accusative = stem + "ия", + instrumental = stem + "ием" + ) + } + + lower.endsWith("й") -> { + val stem = + name.dropLast(1) + + RussianNameForms( + nominative = name, + genitive = stem + "я", + dative = stem + "ю", + accusative = stem + "я", + instrumental = stem + "ем" + ) + } + + lower.endsWith("а") -> { + val stem = + name.dropLast(1) + + val genitiveEnding = + if ( + stem.lastOrNull() + ?.lowercaseChar() in + listOf( + 'г', 'к', 'х', + 'ж', 'ч', 'ш', 'щ' + ) + ) { + "и" + } else { + "ы" + } + + RussianNameForms( + nominative = name, + genitive = stem + genitiveEnding, + dative = stem + "е", + accusative = stem + "у", + instrumental = stem + "ой" + ) + } + + lower.endsWith("я") -> { + val stem = + name.dropLast(1) + + RussianNameForms( + nominative = name, + genitive = stem + "и", + dative = stem + "е", + accusative = stem + "ю", + instrumental = stem + "ей" + ) + } + + lower.lastOrNull() in + ('а'..'я') && + lower.lastOrNull() !in + listOf( + 'ь', 'й' + ) -> { + + RussianNameForms( + nominative = name, + genitive = name + "а", + dative = name + "у", + accusative = name + "а", + instrumental = name + "ом" + ) + } + + else -> { + /* + * Нестандартные/иностранные имена не ломаем: + * оставляем как ввёл пользователь. + */ + RussianNameForms( + nominative = name, + genitive = name, + dative = name, + accusative = name, + instrumental = name + ) + } + } +} + +private fun stableHash( + value: String +): Long { + + var hash = + -3750763034362895579L + + value.forEach { char -> + hash = + hash xor char.code.toLong() + + hash *= + 1099511628211L + } + + return hash +} + +private fun positiveIndex( + value: Long, + size: Int +): Int { + + val mod = + value % size.toLong() + + return if (mod < 0L) { + (mod + size).toInt() + } else { + mod.toInt() + } +} + +private fun phraseIndexFor( + playerId: String, + streak: Int, + historySize: Int +): Int { + + /* + * Один общий пул из 100 жёстких фраз доступен уже с СЕРИИ 5. + * + * Выбор псевдослучайный, но детерминированный по событию: + * одинаковая история покажет одинаковую фразу на всех телефонах, + * а разные игроки/серии/раздачи будут реально крутить весь пул. + */ + val runStart = + historySize - streak + + val seed = + stableHash( + "$playerId|$runStart|$streak|$historySize|streak-praise-v5" + ) + + var index = + positiveIndex( + seed, + streakPraiseTemplates.size + ) + + /* + * Защита от редкого повтора соседней отметки той же серии. + */ + val previousMilestone = + when (streak) { + 7 -> 5 + 9 -> 7 + 10 -> 9 + 12 -> 10 + 13 -> 12 + 15 -> 13 + 17 -> 15 + 20 -> 17 + 25 -> 20 + 30 -> 25 + 35 -> 30 + 40 -> 35 + 45 -> 40 + 50 -> 45 + else -> + if ( + streak > 50 && + streak % 5 == 0 + ) { + streak - 5 + } else { + null + } + } + + if (previousMilestone != null) { + val previousHistorySize = + historySize - + (streak - previousMilestone) + + val previousRunStart = + previousHistorySize - + previousMilestone + + val previousSeed = + stableHash( + "$playerId|$previousRunStart|$previousMilestone|$previousHistorySize|streak-praise-v5" + ) + + val previousIndex = + positiveIndex( + previousSeed, + streakPraiseTemplates.size + ) + + if (index == previousIndex) { + index = + (index + 37) % + streakPraiseTemplates.size + } + } + + return index +} + +fun buildStreakPraises( + players: List>, + history: List +): List { + + if (players.isEmpty() || history.isEmpty()) { + return emptyList() + } + + val lastLoserId = + history.last() + + val candidates = + players.mapNotNull { (playerId, playerName) -> + + if (playerId == lastLoserId) { + return@mapNotNull null + } + + var streak = 0 + + for (loserId in history.asReversed()) { + if (loserId == playerId) { + break + } + + streak++ + } + + if (!isStreakPraiseMilestone(streak)) { + return@mapNotNull null + } + + Triple( + playerId, + playerName, + streak + ) + } + .sortedWith( + compareByDescending> { + it.third + }.thenBy { + it.first + } + ) + + return candidates.map { chosen -> + + val playerId = + chosen.first + + val playerName = + chosen.second + + val streak = + chosen.third + + val phraseIndex = + phraseIndexFor( + playerId = playerId, + streak = streak, + historySize = history.size + ) + + val nameForms = + russianNameForms( + playerId = playerId, + rawName = playerName + ) + + val message = + streakPraiseTemplates[ + phraseIndex + ] + .replace( + "{nameGen}", + nameForms.genitive + ) + .replace( + "{nameDat}", + nameForms.dative + ) + .replace( + "{nameAcc}", + nameForms.accusative + ) + .replace( + "{nameIns}", + nameForms.instrumental + ) + .replace( + "{name}", + nameForms.nominative + ) + .replace( + "{streak}", + streak.toString() + ) + + StreakPraiseResult( + playerId = playerId, + playerName = playerName, + streak = streak, + message = message + ) + } +} + +/* + * Оставляем старую функцию для совместимости. + */ +fun buildStreakPraise( + players: List>, + history: List +): StreakPraiseResult? = + buildStreakPraises( + players = players, + history = history + ).firstOrNull() + +@Composable +fun QueuedStreakPraiseBanner( + gameKey: String, + players: List>, + history: List, + includeLatestOnStart: Boolean = false, + topSpacingDp: Int = 12 +) { + + /* + * НОВАЯ ЛОГИКА: + * + * Никакой очереди по 30 секунд. + * + * Если на одном ходе milestone получили сразу несколько игроков, + * показываем их ОДНОВРЕМЕННО в одном компактном блоке. + * + * Если во время показа прилетел новый milestone — + * старый блок сразу заменяется новым. + * + * В результате игра никогда не ждёт баннеры. + */ + var activePraises by remember(gameKey) { + mutableStateOf>( + emptyList() + ) + } + + var activeEventHistorySize by remember(gameKey) { + mutableIntStateOf(-1) + } + + var lastProcessedHistorySize by remember( + gameKey, + includeLatestOnStart + ) { + mutableIntStateOf( + if ( + includeLatestOnStart && + history.isNotEmpty() + ) { + history.size - 1 + } else { + history.size + } + ) + } + + LaunchedEffect( + gameKey, + history.size + ) { + + if ( + history.size < + lastProcessedHistorySize + ) { + activePraises = + emptyList() + + activeEventHistorySize = + -1 + + lastProcessedHistorySize = + history.size + + return@LaunchedEffect + } + + if ( + history.size == + lastProcessedHistorySize + ) { + return@LaunchedEffect + } + + /* + * Если во время показа серии игрок получает +1, + * его серия закончилась прямо сейчас — убираем его баннер + * немедленно, а не ждём окончания 30-секундного таймера. + * + * Берём ВСЕ новые раздачи, потому что Firestore иногда может + * прислать сразу несколько изменений одним обновлением. + */ + val newLoserIds = + history + .subList( + lastProcessedHistorySize + .coerceAtLeast(0), + history.size + ) + .toSet() + + if ( + activePraises.any { praise -> + praise.playerId in newLoserIds + } + ) { + activePraises = + activePraises.filterNot { praise -> + praise.playerId in newLoserIds + } + + if (activePraises.isEmpty()) { + activeEventHistorySize = + -1 + } + } + + val fromSize = + (lastProcessedHistorySize + 1) + .coerceAtLeast(1) + + var latestPraises = + emptyList() + + var latestPrefixSize = + -1 + + /* + * Если Firestore прислал сразу несколько новых ходов, + * не заставляем пользователя смотреть старые события. + * Берём самое СВЕЖЕЕ событие стрика. + */ + for ( + prefixSize in + fromSize..history.size + ) { + + val praises = + buildStreakPraises( + players = + players, + history = + history.take( + prefixSize + ) + ) + + if ( + praises.isNotEmpty() + ) { + latestPraises = + praises + + latestPrefixSize = + prefixSize + } + } + + /* + * Если milestone случился внутри пачки обновлений, но после него + * этот же игрок успел получить +1, не показываем уже мёртвую серию. + */ + if ( + latestPraises.isNotEmpty() && + latestPrefixSize >= 0 && + latestPrefixSize < history.size + ) { + val losersAfterMilestone = + history + .drop(latestPrefixSize) + .toSet() + + latestPraises = + latestPraises.filterNot { praise -> + praise.playerId in losersAfterMilestone + } + } + + lastProcessedHistorySize = + history.size + + if ( + latestPraises.isNotEmpty() + ) { + activePraises = + latestPraises + + activeEventHistorySize = + latestPrefixSize + } + } + + LaunchedEffect( + gameKey, + activeEventHistorySize + ) { + + if ( + activeEventHistorySize < + 0 || + activePraises.isEmpty() + ) { + return@LaunchedEffect + } + + val eventId = + activeEventHistorySize + + delay( + STREAK_PRAISE_DURATION_MS + ) + + if ( + activeEventHistorySize == + eventId + ) { + activePraises = + emptyList() + + activeEventHistorySize = + -1 + } + } + + /* + * ВАЖНО: + * streak-плашка больше не участвует в layout игрового стола. + * Popup рисуется поверх неинтерактивной верхней части экрана, + * поэтому появление/исчезновение плашки не двигает карточки + * игроков и кнопки +1 — миссклика из-за сдвига больше нет. + */ + if ( + activePraises.isNotEmpty() + ) { + + Popup( + alignment = + Alignment.TopCenter, + properties = + PopupProperties( + focusable = false + ) + ) { + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding( + top = + (20 + topSpacingDp).dp, + start = + 14.dp, + end = + 14.dp + ) + ) { + + if ( + activePraises.size == + 1 + ) { + + StreakPraiseBanner( + praise = + activePraises.first() + ) + + } else { + + MultiStreakPraiseBanner( + praises = + activePraises, + lastLoserName = + history + .lastOrNull() + ?.let { loserId -> + players + .firstOrNull { + it.first == + loserId + } + ?.second + } + ) + } + } + } + } +} + +@Composable +private fun MultiStreakPraiseBanner( + praises: List, + lastLoserName: String? +) { + + PokerPanel { + + Text( + text = + "🔥 СЕРИИ СРАЗУ У ${praises.size}", + color = + PokerPalette.Gold, + fontSize = + 15.sp, + fontWeight = + FontWeight.Bold + ) + + Spacer( + modifier = + androidx.compose.ui.Modifier.height( + 7.dp + ) + ) + + praises.forEach { praise -> + + Text( + text = + "${praise.playerName} — ${praise.streak} без +1", + color = + PokerPalette.TextPrimary, + fontSize = + 16.sp, + fontWeight = + FontWeight.SemiBold + ) + + Spacer( + modifier = + androidx.compose.ui.Modifier.height( + 3.dp + ) + ) + } + + Spacer( + modifier = + androidx.compose.ui.Modifier.height( + 6.dp + ) + ) + + val joke = + when { + + lastLoserName != null && + praises.size >= 3 -> + "$lastLoserName, ты там один очки собираешь, что ли?" + + lastLoserName != null -> + "$lastLoserName, у людей серии растут. У тебя пока растёт только счёт." + + else -> + "За столом массовая неприкосновенность. Кто-то явно работает поставщиком +1." + } + + Text( + text = + joke, + color = + PokerPalette.Gold, + fontSize = + 15.sp, + fontWeight = + FontWeight.SemiBold + ) + } +} + +@Composable +fun StreakPraiseBanner( + praise: StreakPraiseResult +) { + PokerPanel { + Text( + text = "🔥 СЕРИЯ ${praise.streak}", + color = PokerPalette.Gold, + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + + Spacer( + modifier = + androidx.compose.ui.Modifier.height(7.dp) + ) + + Text( + text = praise.message, + color = PokerPalette.TextPrimary, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/durakscore/app/Timestamp.kt b/app/src/main/java/ru/durakscore/app/Timestamp.kt new file mode 100644 index 0000000..64ca894 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/Timestamp.kt @@ -0,0 +1,23 @@ +package ru.durakscore.app + +import java.util.Date + +/** + * Tiny app-local timestamp model. + * + * It intentionally mirrors only the two operations the UI actually needs + * from the old cloud Timestamp class: seconds and toDate(). + */ +data class Timestamp( + private val value: Date +) { + + val seconds: Long + get() = + value.time / 1000L + + fun toDate(): Date = + Date( + value.time + ) +} diff --git a/app/src/main/java/ru/durakscore/app/ui/theme/Color.kt b/app/src/main/java/ru/durakscore/app/ui/theme/Color.kt new file mode 100644 index 0000000..9ddb3ec --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package ru.durakscore.app.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/ru/durakscore/app/ui/theme/Theme.kt b/app/src/main/java/ru/durakscore/app/ui/theme/Theme.kt new file mode 100644 index 0000000..e86fca4 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package ru.durakscore.app.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun DurakScoreTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/durakscore/app/ui/theme/Type.kt b/app/src/main/java/ru/durakscore/app/ui/theme/Type.kt new file mode 100644 index 0000000..d411649 --- /dev/null +++ b/app/src/main/java/ru/durakscore/app/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package ru.durakscore.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/keepRules/rules.keep b/app/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/app/src/main/keepRules/rules.keep @@ -0,0 +1,12 @@ +# Add project specific R8 rules here. +# AGP will combine all keep rule files in src/main/keepRules to pass to R8 +# +# For more details, see +# https://d.android.com/r/tools/r8/keep-rules + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} \ No newline at end of file diff --git a/app/src/main/res/drawable/gothic_achievements_jester.png b/app/src/main/res/drawable/gothic_achievements_jester.png new file mode 100644 index 0000000..7e984e0 Binary files /dev/null and b/app/src/main/res/drawable/gothic_achievements_jester.png differ diff --git a/app/src/main/res/drawable/gothic_armor.png b/app/src/main/res/drawable/gothic_armor.png new file mode 100644 index 0000000..b3acfe5 Binary files /dev/null and b/app/src/main/res/drawable/gothic_armor.png differ diff --git a/app/src/main/res/drawable/gothic_audit_jester.png b/app/src/main/res/drawable/gothic_audit_jester.png new file mode 100644 index 0000000..1c1aaad Binary files /dev/null and b/app/src/main/res/drawable/gothic_audit_jester.png differ diff --git a/app/src/main/res/drawable/gothic_bandaged_heart.png b/app/src/main/res/drawable/gothic_bandaged_heart.png new file mode 100644 index 0000000..c3ba58e Binary files /dev/null and b/app/src/main/res/drawable/gothic_bandaged_heart.png differ diff --git a/app/src/main/res/drawable/gothic_burning_card.png b/app/src/main/res/drawable/gothic_burning_card.png new file mode 100644 index 0000000..5c31608 Binary files /dev/null and b/app/src/main/res/drawable/gothic_burning_card.png differ diff --git a/app/src/main/res/drawable/gothic_crown_skull.png b/app/src/main/res/drawable/gothic_crown_skull.png new file mode 100644 index 0000000..504119e Binary files /dev/null and b/app/src/main/res/drawable/gothic_crown_skull.png differ diff --git a/app/src/main/res/drawable/gothic_game_table_jester.png b/app/src/main/res/drawable/gothic_game_table_jester.png new file mode 100644 index 0000000..f40e3c6 Binary files /dev/null and b/app/src/main/res/drawable/gothic_game_table_jester.png differ diff --git a/app/src/main/res/drawable/gothic_goblet.png b/app/src/main/res/drawable/gothic_goblet.png new file mode 100644 index 0000000..9397ee9 Binary files /dev/null and b/app/src/main/res/drawable/gothic_goblet.png differ diff --git a/app/src/main/res/drawable/gothic_history_card.png b/app/src/main/res/drawable/gothic_history_card.png new file mode 100644 index 0000000..ce54582 Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_card.png differ diff --git a/app/src/main/res/drawable/gothic_history_cards.png b/app/src/main/res/drawable/gothic_history_cards.png new file mode 100644 index 0000000..89ad8a2 Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_cards.png differ diff --git a/app/src/main/res/drawable/gothic_history_classic.png b/app/src/main/res/drawable/gothic_history_classic.png new file mode 100644 index 0000000..a93bff4 Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_classic.png differ diff --git a/app/src/main/res/drawable/gothic_history_crown.png b/app/src/main/res/drawable/gothic_history_crown.png new file mode 100644 index 0000000..dcd05fc Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_crown.png differ diff --git a/app/src/main/res/drawable/gothic_history_jester.png b/app/src/main/res/drawable/gothic_history_jester.png new file mode 100644 index 0000000..1ebee8a Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_jester.png differ diff --git a/app/src/main/res/drawable/gothic_history_loser.png b/app/src/main/res/drawable/gothic_history_loser.png new file mode 100644 index 0000000..f3bc1a9 Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_loser.png differ diff --git a/app/src/main/res/drawable/gothic_history_skull.png b/app/src/main/res/drawable/gothic_history_skull.png new file mode 100644 index 0000000..4641650 Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_skull.png differ diff --git a/app/src/main/res/drawable/gothic_history_survivor.png b/app/src/main/res/drawable/gothic_history_survivor.png new file mode 100644 index 0000000..820529e Binary files /dev/null and b/app/src/main/res/drawable/gothic_history_survivor.png differ diff --git a/app/src/main/res/drawable/gothic_jester_face.png b/app/src/main/res/drawable/gothic_jester_face.png new file mode 100644 index 0000000..fcc0717 Binary files /dev/null and b/app/src/main/res/drawable/gothic_jester_face.png differ diff --git a/app/src/main/res/drawable/gothic_new_game_jester.png b/app/src/main/res/drawable/gothic_new_game_jester.png new file mode 100644 index 0000000..564dee4 Binary files /dev/null and b/app/src/main/res/drawable/gothic_new_game_jester.png differ diff --git a/app/src/main/res/drawable/gothic_serial_jester.png b/app/src/main/res/drawable/gothic_serial_jester.png new file mode 100644 index 0000000..211bac6 Binary files /dev/null and b/app/src/main/res/drawable/gothic_serial_jester.png differ diff --git a/app/src/main/res/drawable/gothic_skull_jester.png b/app/src/main/res/drawable/gothic_skull_jester.png new file mode 100644 index 0000000..0f5926a Binary files /dev/null and b/app/src/main/res/drawable/gothic_skull_jester.png differ diff --git a/app/src/main/res/drawable/gothic_spikes.png b/app/src/main/res/drawable/gothic_spikes.png new file mode 100644 index 0000000..040aaa0 Binary files /dev/null and b/app/src/main/res/drawable/gothic_spikes.png differ diff --git a/app/src/main/res/drawable/gothic_user_badge.png b/app/src/main/res/drawable/gothic_user_badge.png new file mode 100644 index 0000000..fcc0717 Binary files /dev/null and b/app/src/main/res/drawable/gothic_user_badge.png differ diff --git a/app/src/main/res/drawable/gothic_users_jester.png b/app/src/main/res/drawable/gothic_users_jester.png new file mode 100644 index 0000000..69a4760 Binary files /dev/null and b/app/src/main/res/drawable/gothic_users_jester.png differ diff --git a/app/src/main/res/drawable/gothic_vacuum.png b/app/src/main/res/drawable/gothic_vacuum.png new file mode 100644 index 0000000..5e50719 Binary files /dev/null and b/app/src/main/res/drawable/gothic_vacuum.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/main_score_dual_skull.png b/app/src/main/res/drawable/main_score_dual_skull.png new file mode 100644 index 0000000..31bd0ba Binary files /dev/null and b/app/src/main/res/drawable/main_score_dual_skull.png differ diff --git a/app/src/main/res/drawable/main_score_flame_jester.png b/app/src/main/res/drawable/main_score_flame_jester.png new file mode 100644 index 0000000..ed32ad2 Binary files /dev/null and b/app/src/main/res/drawable/main_score_flame_jester.png differ diff --git a/app/src/main/res/drawable/main_score_footer_jester.png b/app/src/main/res/drawable/main_score_footer_jester.png new file mode 100644 index 0000000..6ad5171 Binary files /dev/null and b/app/src/main/res/drawable/main_score_footer_jester.png differ diff --git a/app/src/main/res/drawable/main_score_hat.png b/app/src/main/res/drawable/main_score_hat.png new file mode 100644 index 0000000..580875a Binary files /dev/null and b/app/src/main/res/drawable/main_score_hat.png differ diff --git a/app/src/main/res/drawable/main_score_leader_jester.png b/app/src/main/res/drawable/main_score_leader_jester.png new file mode 100644 index 0000000..a2c64aa Binary files /dev/null and b/app/src/main/res/drawable/main_score_leader_jester.png differ diff --git a/app/src/main/res/drawable/main_score_shield_jester.png b/app/src/main/res/drawable/main_score_shield_jester.png new file mode 100644 index 0000000..9793568 Binary files /dev/null and b/app/src/main/res/drawable/main_score_shield_jester.png differ diff --git a/app/src/main/res/drawable/main_score_skull_hat.png b/app/src/main/res/drawable/main_score_skull_hat.png new file mode 100644 index 0000000..322cc38 Binary files /dev/null and b/app/src/main/res/drawable/main_score_skull_hat.png differ diff --git a/app/src/main/res/drawable/main_score_trophy.png b/app/src/main/res/drawable/main_score_trophy.png new file mode 100644 index 0000000..7b3b981 Binary files /dev/null and b/app/src/main/res/drawable/main_score_trophy.png differ diff --git a/app/src/main/res/drawable/menu_achievements.png b/app/src/main/res/drawable/menu_achievements.png new file mode 100644 index 0000000..72033bf Binary files /dev/null and b/app/src/main/res/drawable/menu_achievements.png differ diff --git a/app/src/main/res/drawable/menu_audit.png b/app/src/main/res/drawable/menu_audit.png new file mode 100644 index 0000000..2c1a2e5 Binary files /dev/null and b/app/src/main/res/drawable/menu_audit.png differ diff --git a/app/src/main/res/drawable/menu_continue.png b/app/src/main/res/drawable/menu_continue.png new file mode 100644 index 0000000..545dd19 Binary files /dev/null and b/app/src/main/res/drawable/menu_continue.png differ diff --git a/app/src/main/res/drawable/menu_header_jester.png b/app/src/main/res/drawable/menu_header_jester.png new file mode 100644 index 0000000..77b53fe Binary files /dev/null and b/app/src/main/res/drawable/menu_header_jester.png differ diff --git a/app/src/main/res/drawable/menu_history.png b/app/src/main/res/drawable/menu_history.png new file mode 100644 index 0000000..582e35e Binary files /dev/null and b/app/src/main/res/drawable/menu_history.png differ diff --git a/app/src/main/res/drawable/menu_new_game.png b/app/src/main/res/drawable/menu_new_game.png new file mode 100644 index 0000000..882b37c Binary files /dev/null and b/app/src/main/res/drawable/menu_new_game.png differ diff --git a/app/src/main/res/drawable/menu_score.png b/app/src/main/res/drawable/menu_score.png new file mode 100644 index 0000000..99e6249 Binary files /dev/null and b/app/src/main/res/drawable/menu_score.png differ diff --git a/app/src/main/res/drawable/menu_users.png b/app/src/main/res/drawable/menu_users.png new file mode 100644 index 0000000..7adc743 Binary files /dev/null and b/app/src/main/res/drawable/menu_users.png differ diff --git a/app/src/main/res/drawable/menu_watch.png b/app/src/main/res/drawable/menu_watch.png new file mode 100644 index 0000000..30b327a Binary files /dev/null and b/app/src/main/res/drawable/menu_watch.png differ diff --git a/app/src/main/res/drawable/reaction_clap_jester.webp b/app/src/main/res/drawable/reaction_clap_jester.webp new file mode 100644 index 0000000..4cca729 Binary files /dev/null and b/app/src/main/res/drawable/reaction_clap_jester.webp differ diff --git a/app/src/main/res/drawable/reaction_fire_jester.webp b/app/src/main/res/drawable/reaction_fire_jester.webp new file mode 100644 index 0000000..5b83212 Binary files /dev/null and b/app/src/main/res/drawable/reaction_fire_jester.webp differ diff --git a/app/src/main/res/drawable/reaction_laugh_jester.webp b/app/src/main/res/drawable/reaction_laugh_jester.webp new file mode 100644 index 0000000..4cee1a7 Binary files /dev/null and b/app/src/main/res/drawable/reaction_laugh_jester.webp differ diff --git a/app/src/main/res/drawable/reaction_poop_jester.webp b/app/src/main/res/drawable/reaction_poop_jester.webp new file mode 100644 index 0000000..e581654 Binary files /dev/null and b/app/src/main/res/drawable/reaction_poop_jester.webp differ diff --git a/app/src/main/res/drawable/splash_durakometr.png b/app/src/main/res/drawable/splash_durakometr.png new file mode 100644 index 0000000..7dda768 Binary files /dev/null and b/app/src/main/res/drawable/splash_durakometr.png differ diff --git a/app/src/main/res/drawable/watch_hero_jester.png b/app/src/main/res/drawable/watch_hero_jester.png new file mode 100644 index 0000000..e9da91e Binary files /dev/null and b/app/src/main/res/drawable/watch_hero_jester.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..82cd4a2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..82cd4a2 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..2cec5a2 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..1ddae3e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f0ad25b Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..7280344 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..2d349e8 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..53facc9 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..520ba27 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c16c0d8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8586627 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..044721f Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ccb4e57 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..06a9bc1 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..8e576fe Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..1cf0556 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..43af9d5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..04cab3f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + ДуракоМетр + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..c011950 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + + + +
+

404

+

Page Not Found

+

The specified file was not found on this website. Please check the URL for mistakes and try again.

+

Why am I seeing this?

+

This page was generated by the Firebase Command-Line Interface. To modify it, edit the 404.html file in your project's configured public directory.

+
+ + diff --git a/hosting/index.html b/hosting/index.html new file mode 100644 index 0000000..890e0f0 --- /dev/null +++ b/hosting/index.html @@ -0,0 +1,89 @@ + + + + + + Welcome to Firebase Hosting + + + + + + + + + + + + + + + + + + + +
+

Welcome

+

Firebase Hosting Setup Complete

+

You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!

+ Open Hosting Documentation +
+

Firebase SDK Loading…

+ + + + diff --git a/patch_v111_streak_bugfix.ps1 b/patch_v111_streak_bugfix.ps1 new file mode 100644 index 0000000..cec599e --- /dev/null +++ b/patch_v111_streak_bugfix.ps1 @@ -0,0 +1,260 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== DURAKOMETR V1.11 STREAK BUGFIX ===" + +$root = (Get-Location).Path +$file = Join-Path $root "app\src\main\java\ru\durakscore\app\StreakPraise.kt" +$backupDir = Join-Path $root ".v111-streak-backup" + +if (-not (Test-Path $file)) { + throw "Не найден StreakPraise.kt. Запусти скрипт из корня Android-проекта." +} + +$content = Get-Content -LiteralPath $file -Raw -Encoding UTF8 +$content = $content -replace "`r`n", "`n" +$original = $content + +# ------------------------------------------------------------ +# Strict preflight: patch only the expected current code. +# ------------------------------------------------------------ + +$durationMatches = [regex]::Matches( + $content, + 'const\s+val\s+STREAK_PRAISE_DURATION_MS\s*=\s*[0-9_]+L' +) + +if ($durationMatches.Count -ne 1) { + throw "Ожидалось ровно одно STREAK_PRAISE_DURATION_MS, найдено: $($durationMatches.Count). Ничего не изменено." +} + +$oldPhrase = + 'У {nameGen} серия {streak}. Остальные выглядят как четыре ошибки, случайно севшие за один стол.' + +$newPhrase = + 'У {nameGen} серия {streak}. Остальные выглядят как три ошибки, случайно севшие за один стол.' + +if (-not $content.Contains($oldPhrase) -and -not $content.Contains($newPhrase)) { + throw "Не найдена фраза про четыре/три ошибки. Ничего не изменено." +} + +$oldRender = @' + if ( + activePraises.isNotEmpty() + ) { + + Spacer( + modifier = + androidx.compose.ui.Modifier.height( + topSpacingDp.dp + ) + ) + + if ( + activePraises.size == + 1 + ) { + + StreakPraiseBanner( + praise = + activePraises.first() + ) + + } else { + + MultiStreakPraiseBanner( + praises = + activePraises, + lastLoserName = + history + .lastOrNull() + ?.let { loserId -> + players + .firstOrNull { + it.first == + loserId + } + ?.second + } + ) + } + } +'@ + +$oldRender = $oldRender -replace "`r`n", "`n" + +$newRender = @' + /* + * Плашка стрика теперь рисуется поверх экрана через Popup. + * Она НЕ участвует в высоте PokerScreen/Column, поэтому появление + * и исчезновение больше не сдвигает карточки игроков и кнопки +1. + */ + if ( + activePraises.isNotEmpty() + ) { + + Popup( + alignment = + Alignment.TopCenter, + onDismissRequest = + null, + properties = + PopupProperties( + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + + Box( + modifier = + Modifier + .fillMaxWidth() + .padding( + top = + (92 + topSpacingDp).dp, + start = + 14.dp, + end = + 14.dp + ) + ) { + + if ( + activePraises.size == + 1 + ) { + + StreakPraiseBanner( + praise = + activePraises.first() + ) + + } else { + + MultiStreakPraiseBanner( + praises = + activePraises, + lastLoserName = + history + .lastOrNull() + ?.let { loserId -> + players + .firstOrNull { + it.first == + loserId + } + ?.second + } + ) + } + } + } + } +'@ + +$newRender = $newRender -replace "`r`n", "`n" + +if (-not $content.Contains($oldRender) -and -not $content.Contains($newRender)) { + throw "Не найден ожидаемый блок отображения streak-плашки. Ничего не изменено." +} + +# ------------------------------------------------------------ +# Backup before writing anything. +# ------------------------------------------------------------ + +New-Item -ItemType Directory -Force -Path $backupDir | Out-Null +Copy-Item -LiteralPath $file -Destination (Join-Path $backupDir "StreakPraise.kt") -Force + +# ------------------------------------------------------------ +# 1. Duration: exactly 30 seconds. +# ------------------------------------------------------------ + +$content = [regex]::Replace( + $content, + 'const\s+val\s+STREAK_PRAISE_DURATION_MS\s*=\s*[0-9_]+L', + 'const val STREAK_PRAISE_DURATION_MS = 30_000L', + 1 +) + +# ------------------------------------------------------------ +# 2. Text: with one streak-holder at a 4-player table, +# "остальные" are three people, not four. +# ------------------------------------------------------------ + +$content = $content.Replace( + $oldPhrase, + $newPhrase +) + +# Keep comments truthful. +$content = $content.Replace( + 'Никакой очереди по 20 секунд.', + 'Никакой очереди по 30 секунд.' +) + +$content = $content.Replace( + '20-секундного таймера', + '30-секундного таймера' +) + +# ------------------------------------------------------------ +# 3. No layout shift: render the streak banner as an overlay Popup. +# ------------------------------------------------------------ + +if ($content.Contains($oldRender)) { + $content = $content.Replace( + $oldRender, + $newRender + ) +} + +$importsToAdd = @( + 'import androidx.compose.foundation.layout.Box', + 'import androidx.compose.foundation.layout.fillMaxWidth', + 'import androidx.compose.foundation.layout.padding', + 'import androidx.compose.ui.Alignment', + 'import androidx.compose.ui.Modifier', + 'import androidx.compose.ui.window.Popup', + 'import androidx.compose.ui.window.PopupProperties' +) + +foreach ($importLine in $importsToAdd) { + if (-not $content.Contains($importLine)) { + $content = $content.Replace( + 'import androidx.compose.foundation.layout.Spacer', + "import androidx.compose.foundation.layout.Spacer`n$importLine" + ) + } +} + +# ------------------------------------------------------------ +# Final validation before saving. +# ------------------------------------------------------------ + +if ($content -notmatch 'STREAK_PRAISE_DURATION_MS\s*=\s*30_000L') { + throw "Не удалось выставить 30 секунд." +} + +if ($content.Contains('как четыре ошибки, случайно севшие за один стол')) { + throw "Старая фраза «четыре ошибки» всё ещё осталась." +} + +if (-not $content.Contains('как три ошибки, случайно севшие за один стол')) { + throw "Новая фраза «три ошибки» не записана." +} + +if (-not $content.Contains('Popup(')) { + throw "Overlay Popup не установлен." +} + +Set-Content -LiteralPath $file -Value $content -Encoding UTF8 + +Write-Host "" +Write-Host "=== PATCH COMPLETE ===" +Write-Host "Streak duration: 30 sec" +Write-Host "Phrase: четыре ошибки -> три ошибки" +Write-Host "Streak banner: overlay, layout shift removed" +Write-Host "Backup: $backupDir\StreakPraise.kt" +Write-Host "" +Write-Host "Теперь в Android Studio: Rebuild Project." diff --git a/prepare_v110_production.ps1 b/prepare_v110_production.ps1 new file mode 100644 index 0000000..e0c2380 --- /dev/null +++ b/prepare_v110_production.ps1 @@ -0,0 +1,291 @@ +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host "=== DURAKOMETR V1.10 PRODUCTION PREP ===" + +$root = (Get-Location).Path +$appDir = Join-Path $root "app" +$srcDir = Join-Path $appDir "src\main" +$gradlePath = Join-Path $appDir "build.gradle.kts" +$backupDir = Join-Path $root ".v110-production-backup" + +if (-not (Test-Path $gradlePath)) { + throw "Не найден app\build.gradle.kts. Запусти скрипт из корня Android-проекта." +} + +if (-not (Test-Path $srcDir)) { + throw "Не найден app\src\main. Запусти скрипт из корня Android-проекта." +} + +New-Item -ItemType Directory -Force -Path $backupDir | Out-Null + +# ------------------------------------------------------------ +# 1. Find exactly one TEST_MODE declaration that is still true. +# ------------------------------------------------------------ + +$ktFiles = Get-ChildItem ` + -Path $srcDir ` + -Recurse ` + -File ` + -Filter "*.kt" + +$pattern = '(?m)^(?\s*)(?(?:(?:private|public|internal)\s+)?(?:const\s+)?val\s+TEST_MODE\s*=\s*)true(?\s*(?://.*)?)$' + +$testModeHits = @() + +foreach ($file in $ktFiles) { + + $content = Get-Content ` + -LiteralPath $file.FullName ` + -Raw ` + -Encoding UTF8 + + $matches = [regex]::Matches( + $content, + $pattern + ) + + foreach ($match in $matches) { + $testModeHits += [PSCustomObject]@{ + File = $file.FullName + Match = $match.Value + } + } +} + +if ($testModeHits.Count -ne 1) { + + Write-Host "" + Write-Host "Найдено объявлений TEST_MODE=true: $($testModeHits.Count)" + + if ($testModeHits.Count -gt 0) { + $testModeHits | ForEach-Object { + Write-Host " $($_.File)" + Write-Host " $($_.Match)" + } + } else { + + Write-Host "" + Write-Host "Все места, где встречается TEST_MODE:" + + foreach ($file in $ktFiles) { + + $content = Get-Content ` + -LiteralPath $file.FullName ` + -Raw ` + -Encoding UTF8 + + if ($content -match 'TEST_MODE') { + Write-Host " $($file.FullName)" + } + } + } + + throw "Ожидалось ровно одно объявление TEST_MODE=true. Ничего не изменено." +} + +$testModeFile = $testModeHits[0].File +$testModeBackup = Join-Path $backupDir (Split-Path $testModeFile -Leaf) + +Copy-Item ` + -LiteralPath $testModeFile ` + -Destination $testModeBackup ` + -Force + +$testModeContent = Get-Content ` + -LiteralPath $testModeFile ` + -Raw ` + -Encoding UTF8 + +$testModeUpdated = [regex]::Replace( + $testModeContent, + $pattern, + '${indent}${prefix}false${tail}', + 1 +) + +Set-Content ` + -LiteralPath $testModeFile ` + -Value $testModeUpdated ` + -Encoding UTF8 + +Write-Host "TEST_MODE: true -> false" +Write-Host "Файл: $testModeFile" + +# ------------------------------------------------------------ +# 2. Bump Android release version. +# ------------------------------------------------------------ + +Copy-Item ` + -LiteralPath $gradlePath ` + -Destination (Join-Path $backupDir "build.gradle.kts") ` + -Force + +$gradle = Get-Content ` + -LiteralPath $gradlePath ` + -Raw ` + -Encoding UTF8 + +$codeMatch = [regex]::Match( + $gradle, + 'versionCode\s*=\s*(\d+)' +) + +$nameMatch = [regex]::Match( + $gradle, + 'versionName\s*=\s*"([^"]+)"' +) + +if (-not $codeMatch.Success) { + throw "Не найден versionCode в app\build.gradle.kts" +} + +if (-not $nameMatch.Success) { + throw "Не найден versionName в app\build.gradle.kts" +} + +$currentCode = [int]$codeMatch.Groups[1].Value +$currentName = $nameMatch.Groups[1].Value + +if ($currentCode -gt 25) { + throw "Текущий versionCode=$currentCode уже выше 25. Автоматически понижать версию нельзя." +} + +if ($currentCode -lt 24) { + throw "Неожиданный versionCode=$currentCode. Ожидался 24 или 25." +} + +$gradle = [regex]::Replace( + $gradle, + 'versionCode\s*=\s*\d+', + 'versionCode = 25', + 1 +) + +$gradle = [regex]::Replace( + $gradle, + 'versionName\s*=\s*"[^"]+"', + 'versionName = "0.9.15"', + 1 +) + +Set-Content ` + -LiteralPath $gradlePath ` + -Value $gradle ` + -Encoding UTF8 + +Write-Host "Version: $currentCode / $currentName -> 25 / 0.9.15" + +# ------------------------------------------------------------ +# 3. Remove google-services.json safely if it still exists. +# ------------------------------------------------------------ + +$googleServices = Join-Path $appDir "google-services.json" + +if (Test-Path $googleServices) { + + Move-Item ` + -LiteralPath $googleServices ` + -Destination (Join-Path $backupDir "google-services.json") ` + -Force + + Write-Host "google-services.json: убран в резервную папку" +} else { + Write-Host "google-services.json: уже отсутствует" +} + +# ------------------------------------------------------------ +# 4. Static ZERO FIREBASE verification. +# ------------------------------------------------------------ + +$forbiddenPatterns = @( + 'com\.google\.firebase', + '\bFirebaseAuth\b', + '\bFirebaseFirestore\b', + '\bFirebaseAppDistribution\b', + '\bFirebaseUser\b', + '\bgetIdToken\s*\(' +) + +$badHits = @() + +foreach ($file in $ktFiles) { + + $content = Get-Content ` + -LiteralPath $file.FullName ` + -Raw ` + -Encoding UTF8 + + foreach ($forbidden in $forbiddenPatterns) { + + if ($content -match $forbidden) { + $badHits += "$($file.FullName) -> $forbidden" + } + } +} + +$gradleFiles = Get-ChildItem ` + -Path $root ` + -Recurse ` + -File ` + -Include "*.gradle","*.gradle.kts" + +foreach ($file in $gradleFiles) { + + if ($file.FullName -match '\\build\\') { + continue + } + + $content = Get-Content ` + -LiteralPath $file.FullName ` + -Raw ` + -Encoding UTF8 + + if ($content -match 'firebase-(auth|firestore|appdistribution)') { + $badHits += "$($file.FullName) -> Firebase dependency" + } + + if ( + $file.FullName -eq $gradlePath -and + $content -match 'com\.google\.gms\.google-services' + ) { + $badHits += "$($file.FullName) -> google-services plugin" + } +} + +if ($badHits.Count -gt 0) { + + Write-Host "" + Write-Host "ОСТАЛИСЬ FIREBASE-ХВОСТЫ:" + + $badHits | + Sort-Object -Unique | + ForEach-Object { + Write-Host " $_" + } + + throw "Production prep остановлен: сначала убрать найденные Firebase-хвосты." +} + +# ------------------------------------------------------------ +# 5. Verify TEST_MODE is now false. +# ------------------------------------------------------------ + +$finalTestModeContent = Get-Content ` + -LiteralPath $testModeFile ` + -Raw ` + -Encoding UTF8 + +if ($finalTestModeContent -notmatch '(?m)(?:const\s+)?val\s+TEST_MODE\s*=\s*false') { + throw "TEST_MODE не подтверждён как false." +} + +Write-Host "" +Write-Host "=== PREP COMPLETE ===" +Write-Host "TEST_MODE=false" +Write-Host "versionCode=25" +Write-Host "versionName=0.9.15" +Write-Host "Firebase Android refs=0" +Write-Host "Backup: $backupDir" +Write-Host "" +Write-Host "Теперь: Sync Project with Gradle Files -> Rebuild Project -> Generate Signed APK (release)." diff --git a/publish-release.ps1 b/publish-release.ps1 new file mode 100644 index 0000000..75bde05 --- /dev/null +++ b/publish-release.ps1 @@ -0,0 +1,177 @@ +param( + [Parameter(Mandatory = $true)] + [string]$VersionName, + + [Parameter(Mandatory = $true)] + [int]$VersionCode +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +$env:JAVA_HOME = "C:\Program Files\Android\Android Studio1\jbr" +$env:PATH = "$env:JAVA_HOME\bin;$env:PATH" + +$Server = "root@5.188.21.226" +$BaseUrl = "https://5.188.21.226/updates" +$RemoteDir = "/var/www/html/updates" +$SshKey = Join-Path $env:USERPROFILE ".ssh\durakometr_release" + +if ($VersionName -notmatch '^[0-9A-Za-z._-]+$') { + throw "Unsafe versionName: $VersionName" +} + +if ($VersionCode -le 0) { + throw "Invalid versionCode: $VersionCode" +} + +$ProjectRoot = $PSScriptRoot +$Apk = Join-Path $ProjectRoot "app\build\outputs\apk\release\app-release.apk" +$NotesFile = Join-Path $ProjectRoot "release-notes.txt" + +if (-not (Test-Path $Apk)) { + throw "APK not found: $Apk" +} + +$ApkInfo = Get-Item $Apk + +if ($ApkInfo.Length -lt 1MB) { + throw "APK is suspiciously small: $($ApkInfo.Length) bytes" +} + +if (-not (Test-Path $SshKey)) { + throw "SSH key not found: $SshKey" +} + +$BuildToolsRoot = Join-Path $env:LOCALAPPDATA "Android\Sdk\build-tools" + +if (-not (Test-Path $BuildToolsRoot)) { + throw "Android build-tools not found: $BuildToolsRoot" +} + +$ApkSigner = Get-ChildItem $BuildToolsRoot -Directory | + Sort-Object Name -Descending | + ForEach-Object { + $Candidate = Join-Path $_.FullName "apksigner.bat" + if (Test-Path $Candidate) { + $Candidate + } + } | + Select-Object -First 1 + +if (-not $ApkSigner) { + throw "apksigner.bat not found" +} + +& $ApkSigner verify --verbose $Apk + +if ($LASTEXITCODE -ne 0) { + throw "APK signature verification failed" +} + +$Sha256 = (Get-FileHash -Algorithm SHA256 $Apk).Hash.ToLowerInvariant() + +$Notes = "Application update" + +if (Test-Path $NotesFile) { + $LoadedNotes = (Get-Content $NotesFile -Raw -Encoding UTF8).Trim() + + if (-not [string]::IsNullOrWhiteSpace($LoadedNotes)) { + $Notes = $LoadedNotes + } +} + +$RemoteName = "durakometr-$VersionName.apk" +$RemoteTemp = "$RemoteDir/.$RemoteName.uploading" +$RemoteFinal = "$RemoteDir/$RemoteName" + +$LocalJson = Join-Path $env:TEMP "durakometr-latest-$VersionCode.json" +$RemoteJsonTemp = "$RemoteDir/.latest.json.uploading" +$RemoteJsonFinal = "$RemoteDir/latest.json" + +$Metadata = [ordered]@{ + versionCode = $VersionCode + versionName = $VersionName + apkUrl = "$BaseUrl/$RemoteName" + sha256 = $Sha256 + mandatory = $false + notes = $Notes +} + +$Json = $Metadata | ConvertTo-Json -Depth 5 + +[System.IO.File]::WriteAllText( + $LocalJson, + $Json, + [System.Text.UTF8Encoding]::new($false) +) + +$SshArgs = @( + "-i", $SshKey, + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10" +) + +Write-Host "" +Write-Host "=== Durakometr release $VersionName ($VersionCode) ===" +Write-Host "APK: $([math]::Round($ApkInfo.Length / 1MB, 2)) MB" +Write-Host "SHA256: $Sha256" +Write-Host "" + +& scp.exe @SshArgs $Apk "${Server}:$RemoteTemp" + +if ($LASTEXITCODE -ne 0) { + throw "APK upload failed" +} + +$RemoteSizeLines = & ssh.exe @SshArgs $Server "stat -c%s '$RemoteTemp'" + +if ($LASTEXITCODE -ne 0) { + throw "Failed to read remote APK size" +} + +$RemoteSizeText = ($RemoteSizeLines | Select-Object -Last 1).ToString().Trim() + +[long]$RemoteSize = 0 + +if (-not [long]::TryParse($RemoteSizeText, [ref]$RemoteSize)) { + throw "Invalid remote APK size: $RemoteSizeText" +} + +if ($RemoteSize -ne $ApkInfo.Length) { + & ssh.exe @SshArgs $Server "rm -f '$RemoteTemp'" + throw "Size mismatch. Local=$($ApkInfo.Length), remote=$RemoteSize" +} + +& scp.exe @SshArgs $LocalJson "${Server}:$RemoteJsonTemp" + +if ($LASTEXITCODE -ne 0) { + & ssh.exe @SshArgs $Server "rm -f '$RemoteTemp'" + throw "latest.json upload failed" +} + +$PublishCommand = "set -e; " + + "test -s '$RemoteTemp'; " + + "test -s '$RemoteJsonTemp'; " + + "mv -f '$RemoteTemp' '$RemoteFinal'; " + + "chmod 644 '$RemoteFinal'; " + + "mv -f '$RemoteJsonTemp' '$RemoteJsonFinal'; " + + "chmod 644 '$RemoteJsonFinal'; " + + "stat -c '%n %s bytes' '$RemoteFinal'; " + + "cat '$RemoteJsonFinal'" + +& ssh.exe @SshArgs $Server $PublishCommand + +if ($LASTEXITCODE -ne 0) { + throw "Server publish step failed" +} + +Remove-Item $LocalJson -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "==============================================" +Write-Host "RELEASE PUBLISHED" +Write-Host "Version: $VersionName ($VersionCode)" +Write-Host "APK: $([math]::Round($ApkInfo.Length / 1MB, 2)) MB" +Write-Host "SHA256: $Sha256" +Write-Host "$BaseUrl/$RemoteName" +Write-Host "==============================================" diff --git a/publish_v110_release.sh b/publish_v110_release.sh new file mode 100644 index 0000000..0ea3255 --- /dev/null +++ b/publish_v110_release.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_APK="/root/durakometr-0.9.15.apk" +TARGET_APK="/var/www/html/updates/durakometr-0.9.15.apk" + +if [[ ! -f "$SOURCE_APK" ]]; then + echo "FAIL: не найден $SOURCE_APK" + echo "Сначала загрузи release APK на VPS именно под этим именем." + exit 1 +fi + +echo "=== INSTALL APK ===" + +install \ + -m 0644 \ + "$SOURCE_APK" \ + "$TARGET_APK" + +echo "APK:" +ls -lh "$TARGET_APK" + +echo +echo "=== PUBLISH RELEASE ===" + +/usr/local/bin/durak-release \ + 25 \ + "ZERO FIREBASE: PostgreSQL/VPS авторизация, HTTPS-обновления, production-режим" + +echo +echo "=== VERIFY HTTPS METADATA ===" + +curl -fsS \ + https://5.188.21.226/updates/latest.json + +echo +echo +echo "=== VERIFY APK HEAD ===" + +curl -fsSI \ + https://5.188.21.226/updates/durakometr-0.9.15.apk \ + | head -n 8 + +echo +echo "RELEASE V0.9.15 PUBLISHED" diff --git a/release-notes.txt b/release-notes.txt new file mode 100644 index 0000000..3480a41 --- /dev/null +++ b/release-notes.txt @@ -0,0 +1 @@ +Оптимизация производительности реакций и анимации эмодзи diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ff510ae --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "DurakScore" +include(":app") diff --git a/setup-release-ssh.ps1 b/setup-release-ssh.ps1 new file mode 100644 index 0000000..317ef29 --- /dev/null +++ b/setup-release-ssh.ps1 @@ -0,0 +1,62 @@ +$ErrorActionPreference = "Stop" + +$Key = Join-Path $env:USERPROFILE ".ssh\durakometr_release" +$Pub = "$Key.pub" +$Server = "root@5.188.21.226" + +$SshDir = Split-Path $Key -Parent + +if (-not (Test-Path $SshDir)) { + New-Item -ItemType Directory -Path $SshDir | Out-Null +} + +if (-not (Test-Path $Key)) { + Write-Host "Creating SSH key for Durakometr release..." + + & ssh-keygen.exe -t ed25519 -f $Key -C "durakometr-android-studio" -N "" + + if ($LASTEXITCODE -ne 0) { + throw "ssh-keygen failed" + } +} + +if (-not (Test-Path $Pub)) { + throw "Public key not found: $Pub" +} + +Write-Host "" +Write-Host "The VPS will ask for the root password one time." +Write-Host "" + +$PublicKey = (Get-Content $Pub -Raw).Trim() + +if ([string]::IsNullOrWhiteSpace($PublicKey)) { + throw "Public key is empty" +} + +$RemoteCommand = @" +umask 077 +mkdir -p ~/.ssh +touch ~/.ssh/authorized_keys +grep -qxF '$PublicKey' ~/.ssh/authorized_keys || echo '$PublicKey' >> ~/.ssh/authorized_keys +chmod 700 ~/.ssh +chmod 600 ~/.ssh/authorized_keys +"@ + +& ssh.exe $Server $RemoteCommand + +if ($LASTEXITCODE -ne 0) { + throw "Failed to install SSH public key" +} + +Write-Host "" +Write-Host "Testing passwordless SSH..." + +& ssh.exe -i $Key -o BatchMode=yes -o ConnectTimeout=10 $Server "echo RELEASE_SSH_OK" + +if ($LASTEXITCODE -ne 0) { + throw "SSH key login test failed" +} + +Write-Host "" +Write-Host "SSH setup complete."