Initial import of DurakScore
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "durakscore"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 621 KiB |
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)} МСК"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
*/
|
||||||
|
)
|
||||||
@@ -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 *;
|
||||||
|
#}
|
||||||
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 634 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 604 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.0 MiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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>
|
||||||