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>
|
||||