Initial import of DurakScore

This commit is contained in:
2026-08-21 08:32:55 +03:00
commit 5c36fda011
124 changed files with 26072 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "durakscore"
}
}
+34
View File
@@ -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/
+58
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
/build
+176
View File
@@ -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<Exec>("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)
}
@@ -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)
}
}
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Нужен для проверки latest.json на VPS и сетевых запросов приложения. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DurakScore"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.DurakScore"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

File diff suppressed because it is too large Load Diff
@@ -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<List<PendingAccessRequest>>(emptyList())
}
var members by remember {
mutableStateOf<List<ActiveMember>>(emptyList())
}
var requestsLoaded by remember {
mutableStateOf(false)
}
var membersLoaded by remember {
mutableStateOf(false)
}
var errorText by remember {
mutableStateOf<String?>(null)
}
var actionUid by remember {
mutableStateOf<String?>(null)
}
var actionError by remember {
mutableStateOf<String?>(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<ActiveMember>,
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
)
}
}
}
@@ -0,0 +1,3 @@
package ru.durakscore.app
const val TEST_MODE = false
@@ -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<String>,
val isTest: Boolean = false
)
@Composable
fun AuditLogScreen(
access: UserAccess,
onBack: () -> Unit
) {
var logs by remember { mutableStateOf<List<AuditLogItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorText by remember { mutableStateOf<String?>(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
)
}
}
@@ -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<String?>(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
)
}
}
@@ -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
)
}
}
File diff suppressed because it is too large Load Diff
@@ -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)} МСК"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<GamePlayer>,
val targetScore: Int,
val scores: Map<String, Int>,
val history: List<String>,
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<QueuedGameAction>,
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<String, Int>,
history: List<String>
) -> 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<String, Int>,
history: List<String>
) -> 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<String, Int>,
history: List<String>
) -> 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
)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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
)
}
}
File diff suppressed because it is too large Load Diff
@@ -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<GamePlayer>,
val targetScore: Int
)
@Composable
fun NewGameSetupScreen(
onBack: () -> Unit,
onStartGame: (GameSetup) -> Unit
) {
var permanentPlayers by remember {
mutableStateOf<List<GamePlayer>>(emptyList())
}
var isLoading by remember { mutableStateOf(true) }
var loadError by remember { mutableStateOf<String?>(null) }
var selectedType by remember {
mutableStateOf<GameType?>(null)
}
var selectedPlayerIds by remember {
mutableStateOf<Set<String>>(emptySet())
}
var guestPlayers by remember {
mutableStateOf<List<GamePlayer>>(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
)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<Pair<String, String>>,
history: List<String>
): List<StreakPraiseResult> {
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<Triple<String, String, Int>> {
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<Pair<String, String>>,
history: List<String>
): StreakPraiseResult? =
buildStreakPraises(
players = players,
history = history
).firstOrNull()
@Composable
fun QueuedStreakPraiseBanner(
gameKey: String,
players: List<Pair<String, String>>,
history: List<String>,
includeLatestOnStart: Boolean = false,
topSpacingDp: Int = 12
) {
/*
* НОВАЯ ЛОГИКА:
*
* Никакой очереди по 30 секунд.
*
* Если на одном ходе milestone получили сразу несколько игроков,
* показываем их ОДНОВРЕМЕННО в одном компактном блоке.
*
* Если во время показа прилетел новый milestone —
* старый блок сразу заменяется новым.
*
* В результате игра никогда не ждёт баннеры.
*/
var activePraises by remember(gameKey) {
mutableStateOf<List<StreakPraiseResult>>(
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<StreakPraiseResult>()
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<StreakPraiseResult>,
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
)
}
}
@@ -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
)
}
@@ -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)
@@ -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
)
}
@@ -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
)
*/
)
+12
View File
@@ -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 *;
#}
Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

Some files were not shown because too many files have changed in this diff Show More