Add app source for F-Droid release (v1.0.43)
Adds the full Android source tree with unit tests, fastlane store metadata and the Gradle wrapper. The archive shipped without gradle-wrapper.jar, so the official Gradle 9.7.1 wrapper jar is included to make ./gradlew work from a fresh clone. .gitignore now also excludes build output and keystores. README gains build instructions and an updated structure tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
@@ -1,10 +1,24 @@
|
||||
# Gradle / Android Studio
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.kotlin/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
|
||||
# Signing keys - never commit these
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Local scratch
|
||||
|
||||
@@ -64,14 +64,29 @@ Compare the SHA-256 checksum against the value listed in [CHANGELOG.md](CHANGELO
|
||||
sha256sum Matewawe-v1.0.42.apk
|
||||
```
|
||||
|
||||
## Building from source
|
||||
|
||||
Open the project in Android Studio (a version that supports AGP 9.2) and run the `app` configuration, or:
|
||||
|
||||
```sh
|
||||
./gradlew assembleDebug
|
||||
./gradlew testDebugUnitTest # JVM unit tests
|
||||
```
|
||||
|
||||
Nearby multiplayer needs two physical devices — the emulator has no real Bluetooth, and mDNS discovery is often blocked on emulator networks.
|
||||
|
||||
## Repository structure
|
||||
|
||||
<img src="assets/icon-structure.png" width="36" align="left">
|
||||
|
||||
```
|
||||
.
|
||||
├── app/ # Android app source (Kotlin, Jetpack Compose) and unit tests
|
||||
├── assets/ # Icon and README images
|
||||
│ └── screenshots/ # App screenshots
|
||||
├── fastlane/ # F-Droid store listing, screenshots and changelogs
|
||||
├── gradle/ # Gradle wrapper
|
||||
├── tools/ # Helper scripts (sound effect generation)
|
||||
├── CHANGELOG.md # Per-version build dates, sizes and checksums (links to GitHub Releases)
|
||||
├── LICENSE # GNU GPLv3
|
||||
└── README.md
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "cc.skysparkle.matewave"
|
||||
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cc.skysparkle.matewave"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0.43"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
// F-Droid rejects the Google-encrypted dependency metadata block in APKs.
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
keepDebugSymbols += "**/libandroidx.graphics.path.so"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-opt-in=androidx.compose.material3.ExperimentalMaterial3Api")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.18.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.7")
|
||||
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
implementation("com.google.zxing:core:3.5.4")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Compose, AndroidX and coroutines ship their own consumer rules, and the app uses no
|
||||
# reflection. Only keep line numbers so release crash reports stay readable.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- Scan results are used only to find other players, never to derive location. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".MatewaveApp"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_title"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".presence.PresenceService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
W C50 Italian Game: Giuoco Piano e4 e5 Nf3 Nc6 Bc4 Bc5 c3 Nf6 d4 exd4 cxd4 Bb4 Bd2 Bxd2 Nbxd2 d5 exd5 Nxd5 Qb3
|
||||
W C84 Ruy Lopez: Closed e4 e5 Nf3 Nc6 Bb5 a6 Ba4 Nf6 O-O Be7 Re1 b5 Bb3 d6 c3 O-O h3
|
||||
W C44 Scotch Game e4 e5 Nf3 Nc6 d4 exd4 Nxd4 Nf6 Nxc6 bxc6 e5 Qe7 Qe2 Nd5 c4
|
||||
W C29 Vienna Game: Vienna Gambit e4 e5 Nc3 Nf6 f4 d5 fxe5 Nxe4 Nf3 Be7 d4 O-O Bd3
|
||||
W C39 King's Gambit Accepted: Kieseritzky Gambit e4 e5 f4 exf4 Nf3 g5 h4 g4 Ne5
|
||||
W D60 Queen's Gambit Declined: Orthodox Defense d4 d5 c4 e6 Nc3 Nf6 Bg5 Be7 e3 O-O Nf3 Nbd7 Rc1
|
||||
W D26 Queen's Gambit Accepted: Classical Defense d4 d5 c4 dxc4 Nf3 Nf6 e3 e6 Bxc4 c5 O-O a6 a4
|
||||
W A48 London System d4 d5 Bf4 Nf6 e3 e6 Nf3 c5 c3 Nc6 Nbd2 Bd6 Bg3
|
||||
W A20 English Opening: King's English Variation c4 e5 Nc3 Nf6 g3 d5 cxd5 Nxd5 Bg2 Nb6 Nf3 Nc6 O-O
|
||||
W E01 Catalan Opening: Open Defense d4 Nf6 c4 e6 g3 d5 Bg2 dxc4 Nf3 Be7 O-O O-O Qc2
|
||||
B B90 Sicilian Defense: Najdorf Variation e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Be3 e5 Nb3 Be6 f3 Be7
|
||||
B B70 Sicilian Defense: Dragon Variation e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 g6 Be3 Bg7 f3 O-O Qd2 Nc6
|
||||
B C02 French Defense: Advance Variation e4 e6 d4 d5 e5 c5 c3 Nc6 Nf3 Qb6 a3 c4
|
||||
B B18 Caro-Kann Defense: Classical Variation e4 c6 d4 d5 Nc3 dxe4 Nxe4 Bf5 Ng3 Bg6 h4 h6 Nf3 Nd7
|
||||
B B12 Caro-Kann Defense: Advance Variation e4 c6 d4 d5 e5 Bf5 Nf3 e6 Be2 c5
|
||||
B B01 Scandinavian Defense: Main Line e4 d5 exd5 Qxd5 Nc3 Qa5 d4 Nf6 Nf3 c6 Bc4 Bf5
|
||||
B B08 Pirc Defense: Classical Variation e4 d6 d4 Nf6 Nc3 g6 Nf3 Bg7 Be2 O-O O-O c6
|
||||
B C42 Petrov's Defense: Classical Attack e4 e5 Nf3 Nf6 Nxe5 d6 Nf3 Nxe4 d4 d5 Bd3 Nc6
|
||||
B D17 Slav Defense: Czech Variation d4 d5 c4 c6 Nf3 Nf6 Nc3 dxc4 a4 Bf5 e3 e6 Bxc4 Bb4
|
||||
B E91 King's Indian Defense: Orthodox Variation d4 Nf6 c4 g6 Nc3 Bg7 e4 d6 Nf3 O-O Be2 e5 O-O Nc6 d5 Ne7
|
||||
B E32 Nimzo-Indian Defense: Classical Variation d4 Nf6 c4 e6 Nc3 Bb4 Qc2 O-O a3 Bxc3 Qxc3 d5
|
||||
B D85 Grünfeld Defense: Exchange Variation d4 Nf6 c4 g6 Nc3 d5 cxd5 Nxd5 e4 Nxc3 bxc3 Bg7 Nf3 c5
|
||||
B A86 Dutch Defense: Leningrad Variation d4 f5 g3 Nf6 Bg2 g6 Nf3 Bg7 O-O O-O c4 d6
|
||||
B D32 Queen's Gambit Declined: Tarrasch Defense d4 d5 c4 e6 Nc3 c5 cxd5 exd5 Nf3 Nc6 g3 Nf6 Bg2 Be7
|
||||
|
@@ -0,0 +1,357 @@
|
||||
package cc.skysparkle.matewave
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import cc.skysparkle.matewave.audio.SoundManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.network.TransportType
|
||||
import cc.skysparkle.matewave.ui.screens.AccountScreen
|
||||
import cc.skysparkle.matewave.ui.screens.ConnectionScreen
|
||||
import cc.skysparkle.matewave.ui.screens.FriendsScreen
|
||||
import cc.skysparkle.matewave.ui.screens.GameScreen
|
||||
import cc.skysparkle.matewave.ui.screens.LicensesScreen
|
||||
import cc.skysparkle.matewave.ui.screens.MainMenuScreen
|
||||
import cc.skysparkle.matewave.ui.screens.AppearanceScreen
|
||||
import cc.skysparkle.matewave.ui.screens.PermissionsScreen
|
||||
import cc.skysparkle.matewave.ui.screens.PuzzleSolveScreen
|
||||
import cc.skysparkle.matewave.ui.screens.SettingsScreen
|
||||
import cc.skysparkle.matewave.ui.screens.ShareApkScreen
|
||||
import cc.skysparkle.matewave.ui.screens.VsAiSetupScreen
|
||||
import cc.skysparkle.matewave.ui.theme.MatewaveTheme
|
||||
import cc.skysparkle.matewave.viewmodel.GameViewModel
|
||||
import cc.skysparkle.matewave.presence.PresenceHub
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
/** Asks for the highest refresh rate at the current resolution, for smoother animations. */
|
||||
private fun enableHighRefreshRate() {
|
||||
runCatching {
|
||||
val screen: android.view.Display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay
|
||||
}
|
||||
val current = screen.mode
|
||||
val best = screen.supportedModes
|
||||
.filter { it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight }
|
||||
.maxByOrNull { it.refreshRate } ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = best.modeId }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// The app has one dark look: light system bar icons over the background photo.
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
|
||||
)
|
||||
|
||||
enableHighRefreshRate()
|
||||
SoundManager.init(this)
|
||||
|
||||
val appContext = applicationContext
|
||||
kotlin.concurrent.thread(name = "warmup", isDaemon = true) {
|
||||
runCatching { cc.skysparkle.matewave.data.FriendsStore.preload() }
|
||||
runCatching { cc.skysparkle.matewave.puzzles.PuzzleRepository.load(appContext) }
|
||||
}
|
||||
|
||||
setContent {
|
||||
MatewaveTheme {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxSize()
|
||||
) {
|
||||
cc.skysparkle.matewave.ui.components.AppBackgroundImage()
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = androidx.compose.ui.Modifier
|
||||
.fillMaxSize()
|
||||
.background(cc.skysparkle.matewave.ui.theme.PhotoScrim)
|
||||
)
|
||||
AppNavHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
SoundManager.onAppResumed()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
SoundManager.onAppPaused()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppNavHost() {
|
||||
val navController = rememberNavController()
|
||||
val viewModel: GameViewModel = viewModel()
|
||||
val studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel = viewModel()
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.init(context) }
|
||||
|
||||
// Presence is shared with the background service, so the lists stay the same in both.
|
||||
val presence = remember { PresenceHub.get(context) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
if (cc.skysparkle.matewave.presence.PresenceSettings(context).backgroundEnabled) {
|
||||
cc.skysparkle.matewave.presence.PresenceService.start(context)
|
||||
}
|
||||
PresenceHub.acquire(context)
|
||||
onDispose { PresenceHub.release(context) }
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "menu",
|
||||
enterTransition = {
|
||||
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughIn()
|
||||
else sharedAxisIn(forward = true)
|
||||
},
|
||||
exitTransition = {
|
||||
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughOut()
|
||||
else sharedAxisOut(forward = true)
|
||||
},
|
||||
popEnterTransition = {
|
||||
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughIn()
|
||||
else sharedAxisIn(forward = false)
|
||||
},
|
||||
popExitTransition = {
|
||||
if (isTabSwitch(initialState.destination.route, targetState.destination.route)) fadeThroughOut()
|
||||
else sharedAxisOut(forward = false)
|
||||
}
|
||||
) {
|
||||
composable("menu") {
|
||||
MainMenuScreen(
|
||||
viewModel = viewModel,
|
||||
studyViewModel = studyViewModel,
|
||||
onContinueGame = {
|
||||
if (viewModel.resumeVsAi()) navController.navigate("game")
|
||||
},
|
||||
onQuickGame = {
|
||||
// Quick game repeats the last setup against the AI.
|
||||
val last = cc.skysparkle.matewave.settings.LastGameSetup(context)
|
||||
val color = last.aiColor
|
||||
viewModel.startVsAi(
|
||||
last.aiDifficulty,
|
||||
color ?: listOf(PieceColor.WHITE, PieceColor.BLACK).random(),
|
||||
last.aiTime,
|
||||
randomColor = color == null
|
||||
)
|
||||
navController.navigate("game")
|
||||
},
|
||||
onVsAiClick = { navController.navigate("vsAiSetup") },
|
||||
onPuzzlesClick = {
|
||||
val puzzle = cc.skysparkle.matewave.puzzles.PuzzleRepository.pickForRating(
|
||||
context,
|
||||
cc.skysparkle.matewave.data.ProfileStore.get().elo,
|
||||
cc.skysparkle.matewave.stats.Stats.solvedPuzzles()
|
||||
)
|
||||
if (puzzle != null) {
|
||||
viewModel.startPuzzle(puzzle)
|
||||
navController.navigate("puzzleSolve")
|
||||
}
|
||||
},
|
||||
onStudyClick = { navController.navigate("openingStudy") },
|
||||
onVsPlayerClick = { navController.navigate("connectionNearby") },
|
||||
onSettingsClick = { navController.navigate("settings") },
|
||||
onAccountClick = { navController.navigate("account") },
|
||||
onFriendsClick = { navController.navigate("friends") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
|
||||
onFriendsClick = {
|
||||
navController.popBackStack("menu", inclusive = false)
|
||||
navController.navigate("friends")
|
||||
},
|
||||
onAccountClick = {
|
||||
navController.popBackStack("menu", inclusive = false)
|
||||
navController.navigate("account")
|
||||
},
|
||||
onOpenPermissions = { navController.navigate("permissions") },
|
||||
onOpenAppearance = { navController.navigate("appearance") },
|
||||
onOpenLicenses = { navController.navigate("licenses") },
|
||||
onOpenShare = { navController.navigate("shareApk") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("shareApk") {
|
||||
ShareApkScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable("licenses") {
|
||||
LicensesScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable("permissions") {
|
||||
PermissionsScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable("appearance") {
|
||||
AppearanceScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
|
||||
composable("account") {
|
||||
AccountScreen(
|
||||
studyViewModel = studyViewModel,
|
||||
onStudyClick = { navController.navigate("openingStudy") },
|
||||
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
|
||||
onFriendsClick = {
|
||||
navController.popBackStack("menu", inclusive = false)
|
||||
navController.navigate("friends")
|
||||
},
|
||||
onSettingsClick = {
|
||||
navController.popBackStack("menu", inclusive = false)
|
||||
navController.navigate("settings")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("friends") {
|
||||
FriendsScreen(
|
||||
presence = presence,
|
||||
onPlay = { navController.navigate("connectionNearby") },
|
||||
onHomeClick = { navController.popBackStack("menu", inclusive = false) },
|
||||
onSettingsClick = {
|
||||
navController.popBackStack("menu", inclusive = false)
|
||||
navController.navigate("settings")
|
||||
},
|
||||
onAccountClick = {
|
||||
navController.popBackStack("menu", inclusive = false)
|
||||
navController.navigate("account")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("vsAiSetup") {
|
||||
VsAiSetupScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onStart = { difficulty, color, timeControl ->
|
||||
|
||||
viewModel.startVsAi(
|
||||
difficulty,
|
||||
color ?: listOf(PieceColor.WHITE, PieceColor.BLACK).random(),
|
||||
timeControl,
|
||||
randomColor = color == null
|
||||
)
|
||||
navController.navigate("game")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("game") {
|
||||
GameScreen(
|
||||
viewModel = viewModel,
|
||||
onExit = { navController.popBackStack("menu", inclusive = false) }
|
||||
)
|
||||
}
|
||||
|
||||
composable("openingStudy") {
|
||||
cc.skysparkle.matewave.ui.screens.OpeningStudyScreen(
|
||||
viewModel = studyViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenTrainer = { navController.navigate("openingTrainer") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("openingTrainer") {
|
||||
cc.skysparkle.matewave.ui.screens.OpeningTrainerScreen(
|
||||
viewModel = studyViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
composable("puzzleSolve") {
|
||||
PuzzleSolveScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack("menu", inclusive = false) },
|
||||
onNextPuzzle = { next -> viewModel.startPuzzle(next) }
|
||||
)
|
||||
}
|
||||
|
||||
composable("connectionNearby") {
|
||||
val bleTransport = remember { cc.skysparkle.matewave.network.Transports.ble(context) }
|
||||
val lanTransport = remember { cc.skysparkle.matewave.network.Transports.lan(context) }
|
||||
|
||||
ConnectionScreen(
|
||||
|
||||
presence = presence,
|
||||
onStartHost = { timeControl ->
|
||||
// Wait for the opponent on Wi-Fi and, if allowed, Bluetooth at once.
|
||||
val transports = buildList<cc.skysparkle.matewave.network.ConnectionTransport> {
|
||||
add(lanTransport)
|
||||
if (cc.skysparkle.matewave.network.AppPermissions.allGranted(context, cc.skysparkle.matewave.network.AppPermissions.nearby)) {
|
||||
add(bleTransport)
|
||||
}
|
||||
}
|
||||
val transport = if (transports.size == 1) lanTransport
|
||||
else cc.skysparkle.matewave.network.DualHostTransport(transports)
|
||||
viewModel.startVsPlayerNetwork(transport, PieceColor.WHITE, timeControl, isHost = true)
|
||||
navController.navigate("game")
|
||||
},
|
||||
onJoin = { type, timeControl, targetId ->
|
||||
val transport = if (type == TransportType.BLUETOOTH) bleTransport else lanTransport
|
||||
viewModel.startVsPlayerNetwork(transport, PieceColor.BLACK, timeControl, isHost = false, targetId = targetId)
|
||||
navController.navigate("game")
|
||||
},
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val TAB_ROUTES = setOf("menu", "friends", "settings", "account")
|
||||
|
||||
private fun isTabSwitch(from: String?, to: String?): Boolean =
|
||||
from in TAB_ROUTES && to in TAB_ROUTES && from != to
|
||||
|
||||
private val MotionEasing = androidx.compose.animation.core.CubicBezierEasing(0.2f, 0f, 0f, 1f)
|
||||
|
||||
private fun fadeThroughIn(): androidx.compose.animation.EnterTransition =
|
||||
androidx.compose.animation.fadeIn(
|
||||
androidx.compose.animation.core.tween(durationMillis = 260, delayMillis = 90, easing = MotionEasing)
|
||||
) + androidx.compose.animation.scaleIn(
|
||||
initialScale = 0.97f,
|
||||
animationSpec = androidx.compose.animation.core.tween(durationMillis = 300, delayMillis = 90, easing = MotionEasing)
|
||||
)
|
||||
|
||||
private fun fadeThroughOut(): androidx.compose.animation.ExitTransition =
|
||||
androidx.compose.animation.fadeOut(
|
||||
androidx.compose.animation.core.tween(durationMillis = 110, easing = androidx.compose.animation.core.LinearEasing)
|
||||
)
|
||||
|
||||
private fun sharedAxisIn(forward: Boolean): androidx.compose.animation.EnterTransition =
|
||||
androidx.compose.animation.slideInHorizontally(
|
||||
androidx.compose.animation.core.tween(durationMillis = 340, easing = MotionEasing)
|
||||
) { width -> if (forward) width / 14 else -width / 14 } +
|
||||
androidx.compose.animation.fadeIn(
|
||||
androidx.compose.animation.core.tween(durationMillis = 240, delayMillis = 80, easing = MotionEasing)
|
||||
)
|
||||
|
||||
private fun sharedAxisOut(forward: Boolean): androidx.compose.animation.ExitTransition =
|
||||
androidx.compose.animation.slideOutHorizontally(
|
||||
androidx.compose.animation.core.tween(durationMillis = 340, easing = MotionEasing)
|
||||
) { width -> if (forward) -width / 14 else width / 14 } +
|
||||
androidx.compose.animation.fadeOut(
|
||||
androidx.compose.animation.core.tween(durationMillis = 120, easing = androidx.compose.animation.core.LinearEasing)
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package cc.skysparkle.matewave
|
||||
|
||||
import android.app.Application
|
||||
import cc.skysparkle.matewave.data.FriendsStore
|
||||
import cc.skysparkle.matewave.data.GameSaveStore
|
||||
import cc.skysparkle.matewave.data.ProfileStore
|
||||
import cc.skysparkle.matewave.settings.Haptics
|
||||
import cc.skysparkle.matewave.stats.Stats
|
||||
|
||||
/**
|
||||
* Sets up the stores once per process, before any screen or the background presence service
|
||||
* runs: the service can be restarted by the system without the activity.
|
||||
*/
|
||||
class MatewaveApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ProfileStore.init(this)
|
||||
FriendsStore.init(this)
|
||||
Stats.init(this)
|
||||
GameSaveStore.init(this)
|
||||
Haptics.init(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package cc.skysparkle.matewave.ai
|
||||
|
||||
import cc.skysparkle.matewave.engine.Board
|
||||
import cc.skysparkle.matewave.engine.ChessEngine
|
||||
import cc.skysparkle.matewave.engine.Move
|
||||
import cc.skysparkle.matewave.engine.Notation
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.engine.PieceType
|
||||
|
||||
enum class AiDifficulty(val maxDepth: Int, val timeBudgetMs: Long, val noiseCp: Int) {
|
||||
EASY(2, 400, 150),
|
||||
MEDIUM(3, 1000, 40),
|
||||
HARD(5, 2500, 8),
|
||||
EXPERT(7, 4500, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chess engine opponent: minimax with alpha-beta pruning, iterative deepening and a
|
||||
* quiescence search over captures. The time budget per move is derived from the
|
||||
* remaining clock, and lower difficulty levels add Gaussian noise at the root so the
|
||||
* AI does not play the same game every time (mates are never randomized away).
|
||||
*/
|
||||
object ChessAI {
|
||||
private fun valueOf(type: PieceType): Int = when (type) {
|
||||
PieceType.PAWN -> 100
|
||||
PieceType.KNIGHT -> 320
|
||||
PieceType.BISHOP -> 330
|
||||
PieceType.ROOK -> 500
|
||||
PieceType.QUEEN -> 900
|
||||
PieceType.KING -> 20000
|
||||
}
|
||||
|
||||
private const val MATE = 100_000
|
||||
|
||||
private val pawnTable = intArrayOf(
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
5, 10, 10, -20, -20, 10, 10, 5,
|
||||
5, -5, -10, 0, 0, -10, -5, 5,
|
||||
0, 0, 0, 20, 20, 0, 0, 0,
|
||||
5, 5, 10, 25, 25, 10, 5, 5,
|
||||
10, 10, 20, 30, 30, 20, 10, 10,
|
||||
50, 50, 50, 50, 50, 50, 50, 50,
|
||||
0, 0, 0, 0, 0, 0, 0, 0
|
||||
)
|
||||
|
||||
private val knightTable = intArrayOf(
|
||||
-50, -40, -30, -30, -30, -30, -40, -50,
|
||||
-40, -20, 0, 5, 5, 0, -20, -40,
|
||||
-30, 5, 10, 15, 15, 10, 5, -30,
|
||||
-30, 0, 15, 20, 20, 15, 0, -30,
|
||||
-30, 5, 15, 20, 20, 15, 5, -30,
|
||||
-30, 0, 10, 15, 15, 10, 0, -30,
|
||||
-40, -20, 0, 0, 0, 0, -20, -40,
|
||||
-50, -40, -30, -30, -30, -30, -40, -50
|
||||
)
|
||||
|
||||
private val bishopTable = intArrayOf(
|
||||
-20, -10, -10, -10, -10, -10, -10, -20,
|
||||
-10, 5, 0, 0, 0, 0, 5, -10,
|
||||
-10, 10, 10, 10, 10, 10, 10, -10,
|
||||
-10, 0, 10, 10, 10, 10, 0, -10,
|
||||
-10, 5, 5, 10, 10, 5, 5, -10,
|
||||
-10, 0, 5, 10, 10, 5, 0, -10,
|
||||
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||
-20, -10, -10, -10, -10, -10, -10, -20
|
||||
)
|
||||
|
||||
private val rookTable = intArrayOf(
|
||||
0, 0, 0, 5, 5, 0, 0, 0,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
5, 10, 10, 10, 10, 10, 10, 5,
|
||||
0, 0, 0, 0, 0, 0, 0, 0
|
||||
)
|
||||
|
||||
private val queenTable = intArrayOf(
|
||||
-20, -10, -10, -5, -5, -10, -10, -20,
|
||||
-10, 0, 5, 0, 0, 0, 0, -10,
|
||||
-10, 5, 5, 5, 5, 5, 0, -10,
|
||||
0, 0, 5, 5, 5, 5, 0, -5,
|
||||
-5, 0, 5, 5, 5, 5, 0, -5,
|
||||
-10, 0, 5, 5, 5, 5, 0, -10,
|
||||
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||
-20, -10, -10, -5, -5, -10, -10, -20
|
||||
)
|
||||
|
||||
private val kingMiddleTable = intArrayOf(
|
||||
20, 30, 10, 0, 0, 10, 30, 20,
|
||||
20, 20, 0, 0, 0, 0, 20, 20,
|
||||
-10, -20, -20, -20, -20, -20, -20, -10,
|
||||
-20, -30, -30, -40, -40, -30, -30, -20,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30
|
||||
)
|
||||
|
||||
private val kingEndTable = intArrayOf(
|
||||
-50, -30, -30, -30, -30, -30, -30, -50,
|
||||
-30, -30, 0, 0, 0, 0, -30, -30,
|
||||
-30, -10, 20, 30, 30, 20, -10, -30,
|
||||
-30, -10, 30, 40, 40, 30, -10, -30,
|
||||
-30, -10, 30, 40, 40, 30, -10, -30,
|
||||
-30, -10, 20, 30, 30, 20, -10, -30,
|
||||
-30, -20, -10, 0, 0, -10, -20, -30,
|
||||
-50, -40, -30, -20, -20, -30, -40, -50
|
||||
)
|
||||
|
||||
internal fun evaluate(board: Board): Int {
|
||||
var nonPawnMaterial = 0
|
||||
for (sq in 0 until 64) {
|
||||
val p = board.squares[sq] ?: continue
|
||||
if (p.type != PieceType.PAWN && p.type != PieceType.KING) nonPawnMaterial += valueOf(p.type)
|
||||
}
|
||||
val endgame = nonPawnMaterial <= 1_300
|
||||
|
||||
var score = 0
|
||||
for (sq in 0 until 64) {
|
||||
val piece = board.squares[sq] ?: continue
|
||||
val white = piece.color == PieceColor.WHITE
|
||||
val idx = if (white) sq else sq xor 56
|
||||
val positional = when (piece.type) {
|
||||
PieceType.PAWN -> pawnTable[idx]
|
||||
PieceType.KNIGHT -> knightTable[idx]
|
||||
PieceType.BISHOP -> bishopTable[idx]
|
||||
PieceType.ROOK -> rookTable[idx]
|
||||
PieceType.QUEEN -> queenTable[idx]
|
||||
PieceType.KING -> if (endgame) kingEndTable[idx] else kingMiddleTable[idx]
|
||||
}
|
||||
val value = valueOf(piece.type) + positional
|
||||
score += if (white) value else -value
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
private fun moveScore(board: Board, m: Move): Int {
|
||||
var score = 0
|
||||
val victim = board.squares[m.to]
|
||||
if (victim != null) {
|
||||
val attacker = board.squares[m.from]
|
||||
score = valueOf(victim.type) * 16 - (if (attacker != null) valueOf(attacker.type) / 100 else 0)
|
||||
} else if (m.isEnPassant) {
|
||||
score = valueOf(PieceType.PAWN) * 16
|
||||
}
|
||||
if (m.promotion == PieceType.QUEEN) score += 800
|
||||
return score
|
||||
}
|
||||
|
||||
internal fun orderMoves(board: Board, moves: List<Move>): List<Move> =
|
||||
moves.sortedByDescending { m -> moveScore(board, m) }
|
||||
|
||||
internal fun isTactical(board: Board, m: Move): Boolean =
|
||||
board.squares[m.to] != null || m.isEnPassant || m.promotion != null
|
||||
|
||||
private class Search(private val deadline: Long, val history: Set<String>) {
|
||||
private var nodes = 0
|
||||
var timedOut = false
|
||||
private set
|
||||
|
||||
fun timeUp(): Boolean {
|
||||
if (timedOut) return true
|
||||
nodes++
|
||||
if ((nodes and 1023) == 0 && System.currentTimeMillis() >= deadline) timedOut = true
|
||||
return timedOut
|
||||
}
|
||||
}
|
||||
|
||||
private fun quiesce(
|
||||
board: Board, alphaIn: Int, betaIn: Int, maximizing: Boolean, qDepth: Int, search: Search
|
||||
): Int {
|
||||
val standPat = evaluate(board)
|
||||
if (search.timeUp() || qDepth == 0) return standPat
|
||||
var alpha = alphaIn
|
||||
var beta = betaIn
|
||||
if (maximizing) {
|
||||
if (standPat >= beta) return standPat
|
||||
if (standPat > alpha) alpha = standPat
|
||||
} else {
|
||||
if (standPat <= alpha) return standPat
|
||||
if (standPat < beta) beta = standPat
|
||||
}
|
||||
val captures = ChessEngine.legalMoves(board).filter { isTactical(board, it) }
|
||||
if (captures.isEmpty()) return standPat
|
||||
var best = standPat
|
||||
for (m in orderMoves(board, captures)) {
|
||||
val value = quiesce(ChessEngine.applyMove(board, m), alpha, beta, !maximizing, qDepth - 1, search)
|
||||
if (maximizing) {
|
||||
if (value > best) best = value
|
||||
if (value > alpha) alpha = value
|
||||
} else {
|
||||
if (value < best) best = value
|
||||
if (value < beta) beta = value
|
||||
}
|
||||
if (beta <= alpha || search.timedOut) break
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
private fun minimax(
|
||||
board: Board, depth: Int, alphaIn: Int, betaIn: Int, maximizing: Boolean, ply: Int, search: Search
|
||||
): Int {
|
||||
if (search.timeUp()) return evaluate(board)
|
||||
var alpha = alphaIn
|
||||
var beta = betaIn
|
||||
|
||||
if (ply in 1..2 && Notation.positionKey(board) in search.history) return 0
|
||||
|
||||
if (board.halfmoveClock >= 100 || ChessEngine.isDeadPosition(board)) return 0
|
||||
|
||||
val inCheck = ChessEngine.isInCheck(board, board.sideToMove)
|
||||
if (depth <= 0 && !inCheck) return quiesce(board, alpha, beta, maximizing, QUIESCENCE_DEPTH, search)
|
||||
|
||||
val moves = ChessEngine.legalMoves(board)
|
||||
if (moves.isEmpty()) {
|
||||
return if (inCheck) {
|
||||
if (maximizing) -MATE + ply else MATE - ply
|
||||
} else 0
|
||||
}
|
||||
|
||||
val nextDepth = if (depth <= 0) 0 else depth - 1
|
||||
|
||||
val ordered = orderMoves(board, moves)
|
||||
if (maximizing) {
|
||||
var best = Int.MIN_VALUE
|
||||
for (m in ordered) {
|
||||
val value = minimax(ChessEngine.applyMove(board, m), nextDepth, alpha, beta, false, ply + 1, search)
|
||||
best = maxOf(best, value)
|
||||
alpha = maxOf(alpha, value)
|
||||
if (beta <= alpha || search.timedOut) break
|
||||
}
|
||||
return best
|
||||
} else {
|
||||
var best = Int.MAX_VALUE
|
||||
for (m in ordered) {
|
||||
val value = minimax(ChessEngine.applyMove(board, m), nextDepth, alpha, beta, true, ply + 1, search)
|
||||
best = minOf(best, value)
|
||||
beta = minOf(beta, value)
|
||||
if (beta <= alpha || search.timedOut) break
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
fun bestMove(
|
||||
board: Board,
|
||||
difficulty: AiDifficulty,
|
||||
history: Set<String> = emptySet(),
|
||||
clockMillis: Long? = null,
|
||||
incrementMillis: Long = 0L,
|
||||
random: java.util.Random = java.util.Random()
|
||||
): Move? {
|
||||
val rootMoves = ChessEngine.legalMoves(board)
|
||||
if (rootMoves.isEmpty()) return null
|
||||
if (rootMoves.size == 1) return rootMoves.first()
|
||||
|
||||
val budget = if (clockMillis == null || clockMillis > 24L * 3600 * 1000) {
|
||||
difficulty.timeBudgetMs
|
||||
} else {
|
||||
minOf(difficulty.timeBudgetMs, clockMillis / 30 + incrementMillis * 3 / 4).coerceAtLeast(MIN_BUDGET_MS)
|
||||
}
|
||||
|
||||
val maximizing = board.sideToMove == PieceColor.WHITE
|
||||
val search = Search(System.currentTimeMillis() + budget, history)
|
||||
|
||||
var order = orderMoves(board, rootMoves)
|
||||
var lastScored: List<Pair<Move, Int>> = emptyList()
|
||||
|
||||
var depth = 1
|
||||
while (depth <= difficulty.maxDepth && !search.timedOut) {
|
||||
var alpha = Int.MIN_VALUE
|
||||
var beta = Int.MAX_VALUE
|
||||
var bestValueThisDepth = if (maximizing) Int.MIN_VALUE else Int.MAX_VALUE
|
||||
var bestMoveThisDepth: Move? = null
|
||||
val scored = ArrayList<Pair<Move, Int>>(order.size)
|
||||
var completed = true
|
||||
|
||||
for (m in order) {
|
||||
val next = ChessEngine.applyMove(board, m)
|
||||
|
||||
val a = if (difficulty.noiseCp > 0) Int.MIN_VALUE else alpha
|
||||
val b = if (difficulty.noiseCp > 0) Int.MAX_VALUE else beta
|
||||
val value = minimax(next, depth - 1, a, b, !maximizing, 1, search)
|
||||
|
||||
if (search.timedOut) { completed = false; break }
|
||||
scored.add(m to value)
|
||||
if (maximizing) {
|
||||
if (bestMoveThisDepth == null || value > bestValueThisDepth) {
|
||||
bestValueThisDepth = value; bestMoveThisDepth = m
|
||||
}
|
||||
alpha = maxOf(alpha, bestValueThisDepth)
|
||||
} else {
|
||||
if (bestMoveThisDepth == null || value < bestValueThisDepth) {
|
||||
bestValueThisDepth = value; bestMoveThisDepth = m
|
||||
}
|
||||
beta = minOf(beta, bestValueThisDepth)
|
||||
}
|
||||
}
|
||||
|
||||
if (completed && bestMoveThisDepth != null) {
|
||||
lastScored = if (maximizing) scored.sortedByDescending { it.second }
|
||||
else scored.sortedBy { it.second }
|
||||
order = lastScored.map { it.first }
|
||||
} else {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
}
|
||||
|
||||
if (lastScored.isEmpty()) return order.first()
|
||||
val bestEntry = lastScored.first()
|
||||
if (difficulty.noiseCp <= 0) return bestEntry.first
|
||||
|
||||
if (kotlin.math.abs(bestEntry.second) > MATE / 2) return bestEntry.first
|
||||
|
||||
return lastScored
|
||||
.filter { kotlin.math.abs(it.second) < MATE / 2 }
|
||||
.maxByOrNull { (_, value) ->
|
||||
val fromMover = if (maximizing) value else -value
|
||||
fromMover + (random.nextGaussian() * difficulty.noiseCp).toInt()
|
||||
}?.first ?: bestEntry.first
|
||||
}
|
||||
|
||||
private const val QUIESCENCE_DEPTH = 6
|
||||
private const val MIN_BUDGET_MS = 120L
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package cc.skysparkle.matewave.audio
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.media.SoundPool
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class RadioState { IDLE, CONNECTING, PLAYING, ERROR }
|
||||
|
||||
object SoundManager {
|
||||
private val RADIO_SOURCES = listOf(
|
||||
"http://stream.srg-ssr.ch/srgssr/rsj/aac/192",
|
||||
"https://stream.srg-ssr.ch/srgssr/rsj/mp3/128",
|
||||
"http://stream.srg-ssr.ch/srgssr/rsj/aac/96"
|
||||
)
|
||||
|
||||
private const val PREFS = "chess_sound_prefs"
|
||||
private const val KEY_MUSIC = "music_enabled"
|
||||
private const val KEY_SFX = "sfx_enabled"
|
||||
private const val TAG = "SoundManager"
|
||||
|
||||
private const val RADIO_START_DELAY_MS = 1_500L
|
||||
private const val MAX_RETRIES = 8
|
||||
private const val CONNECT_TIMEOUT_MS = 8_000L
|
||||
private val RETRY_DELAYS_MS = longArrayOf(1_000, 2_000, 4_000, 8_000, 15_000, 30_000)
|
||||
private const val BASE_VOLUME = 0.35f
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var musicPlayer: MediaPlayer? = null
|
||||
/** start/pause are only valid once the stream is prepared; before that they would throw. */
|
||||
private var playerPrepared = false
|
||||
private var initialized = false
|
||||
private var retryCount = 0
|
||||
private var sourceIndex = 0
|
||||
private var connectTimeoutRunnable: Runnable? = null
|
||||
private var audioManager: AudioManager? = null
|
||||
private var focusRequest: AudioFocusRequest? = null
|
||||
private var noisyReceiverRegistered = false
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
var musicEnabled: Boolean = true
|
||||
private set
|
||||
var sfxEnabled: Boolean = true
|
||||
private set
|
||||
|
||||
private val _radioState = MutableStateFlow(RadioState.IDLE)
|
||||
val radioState: StateFlow<RadioState> = _radioState
|
||||
|
||||
private fun pauseRadio() {
|
||||
val player = musicPlayer ?: return
|
||||
if (playerPrepared) runCatching { if (player.isPlaying) player.pause() }
|
||||
}
|
||||
|
||||
private val noisyReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) pauseRadio()
|
||||
}
|
||||
}
|
||||
|
||||
private val focusChangeListener = AudioManager.OnAudioFocusChangeListener { change ->
|
||||
when (change) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> stopRadioInternal()
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseRadio()
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK ->
|
||||
runCatching { musicPlayer?.setVolume(BASE_VOLUME * 0.3f, BASE_VOLUME * 0.3f) }
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
val player = musicPlayer
|
||||
if (player != null && playerPrepared) runCatching {
|
||||
player.setVolume(BASE_VOLUME, BASE_VOLUME)
|
||||
if (!player.isPlaying) player.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
val ctx = context.applicationContext
|
||||
appContext = ctx
|
||||
audioManager = ctx.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
|
||||
val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
musicEnabled = prefs.getBoolean(KEY_MUSIC, true)
|
||||
sfxEnabled = prefs.getBoolean(KEY_SFX, true)
|
||||
|
||||
ensureSoundPool(ctx)
|
||||
|
||||
if (musicEnabled) {
|
||||
mainHandler.postDelayed({ if (musicEnabled) startRadio() }, RADIO_START_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAudioFocus(): Boolean {
|
||||
val am = audioManager ?: return false
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(attrs)
|
||||
.setOnAudioFocusChangeListener(focusChangeListener, mainHandler)
|
||||
.build()
|
||||
focusRequest = request
|
||||
return am.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
val am = audioManager ?: return
|
||||
focusRequest?.let { am.abandonAudioFocusRequest(it) }
|
||||
focusRequest = null
|
||||
}
|
||||
|
||||
private fun registerNoisyReceiver() {
|
||||
val ctx = appContext ?: return
|
||||
if (noisyReceiverRegistered) return
|
||||
val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ctx.registerReceiver(noisyReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
ctx.registerReceiver(noisyReceiver, filter)
|
||||
}
|
||||
noisyReceiverRegistered = true
|
||||
}
|
||||
|
||||
private fun unregisterNoisyReceiver() {
|
||||
val ctx = appContext ?: return
|
||||
if (!noisyReceiverRegistered) return
|
||||
try { ctx.unregisterReceiver(noisyReceiver) } catch (_: IllegalArgumentException) {}
|
||||
noisyReceiverRegistered = false
|
||||
}
|
||||
|
||||
private fun startRadio() {
|
||||
val ctx = appContext ?: return
|
||||
if (musicPlayer != null) return
|
||||
if (!requestAudioFocus()) {
|
||||
Log.w(TAG, "Audio focus denied, radio not started")
|
||||
_radioState.value = RadioState.ERROR
|
||||
return
|
||||
}
|
||||
registerNoisyReceiver()
|
||||
retryCount = 0
|
||||
connect(ctx)
|
||||
}
|
||||
|
||||
private fun connect(ctx: Context) {
|
||||
_radioState.value = RadioState.CONNECTING
|
||||
val url = RADIO_SOURCES[sourceIndex % RADIO_SOURCES.size]
|
||||
try {
|
||||
val player = MediaPlayer()
|
||||
musicPlayer = player
|
||||
playerPrepared = false
|
||||
player.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
)
|
||||
player.setVolume(BASE_VOLUME, BASE_VOLUME)
|
||||
player.setDataSource(url)
|
||||
player.setOnPreparedListener {
|
||||
cancelConnectTimeout()
|
||||
playerPrepared = true
|
||||
it.start()
|
||||
retryCount = 0
|
||||
_radioState.value = RadioState.PLAYING
|
||||
}
|
||||
player.setOnErrorListener { mp, _, _ ->
|
||||
cancelConnectTimeout()
|
||||
try { mp.release() } catch (_: Exception) {}
|
||||
if (musicPlayer === mp) {
|
||||
musicPlayer = null
|
||||
playerPrepared = false
|
||||
}
|
||||
scheduleRetry(ctx)
|
||||
true
|
||||
}
|
||||
// A live stream only "completes" when the connection drops: reconnect.
|
||||
player.setOnCompletionListener { mp ->
|
||||
try { mp.release() } catch (_: Exception) {}
|
||||
if (musicPlayer === mp) {
|
||||
musicPlayer = null
|
||||
playerPrepared = false
|
||||
retryCount = 0
|
||||
scheduleRetry(ctx)
|
||||
}
|
||||
}
|
||||
player.prepareAsync()
|
||||
scheduleConnectTimeout(ctx, player, url)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Radio connection error ($url): ${e.message}")
|
||||
try { musicPlayer?.release() } catch (_: Exception) {}
|
||||
musicPlayer = null
|
||||
playerPrepared = false
|
||||
scheduleRetry(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleConnectTimeout(ctx: Context, player: MediaPlayer, url: String) {
|
||||
cancelConnectTimeout()
|
||||
val runnable = Runnable {
|
||||
if (musicPlayer === player) {
|
||||
Log.w(TAG, "Radio connection timed out ($url)")
|
||||
try { player.release() } catch (_: Exception) {}
|
||||
musicPlayer = null
|
||||
playerPrepared = false
|
||||
scheduleRetry(ctx)
|
||||
}
|
||||
}
|
||||
connectTimeoutRunnable = runnable
|
||||
mainHandler.postDelayed(runnable, CONNECT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private fun cancelConnectTimeout() {
|
||||
connectTimeoutRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||
connectTimeoutRunnable = null
|
||||
}
|
||||
|
||||
private fun scheduleRetry(ctx: Context) {
|
||||
cancelConnectTimeout()
|
||||
if (!musicEnabled) {
|
||||
_radioState.value = RadioState.IDLE
|
||||
return
|
||||
}
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
_radioState.value = RadioState.ERROR
|
||||
Log.w(TAG, "Could not connect to the radio after $MAX_RETRIES attempts")
|
||||
return
|
||||
}
|
||||
sourceIndex++
|
||||
val delay = RETRY_DELAYS_MS.getOrElse(retryCount) { RETRY_DELAYS_MS.last() }
|
||||
retryCount++
|
||||
_radioState.value = RadioState.CONNECTING
|
||||
mainHandler.postDelayed({
|
||||
if (musicEnabled && musicPlayer == null) connect(ctx)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private fun stopRadioInternal() {
|
||||
cancelConnectTimeout()
|
||||
try { musicPlayer?.release() } catch (_: Exception) {}
|
||||
musicPlayer = null
|
||||
playerPrepared = false
|
||||
unregisterNoisyReceiver()
|
||||
abandonAudioFocus()
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
_radioState.value = RadioState.IDLE
|
||||
}
|
||||
|
||||
fun onAppResumed() {
|
||||
if (musicEnabled && musicPlayer == null) startRadio()
|
||||
}
|
||||
|
||||
fun onAppPaused() {
|
||||
stopRadioInternal()
|
||||
}
|
||||
|
||||
fun setMusicEnabled(enabled: Boolean) {
|
||||
musicEnabled = enabled
|
||||
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
|
||||
?.putBoolean(KEY_MUSIC, enabled)?.apply()
|
||||
if (enabled) startRadio() else stopRadioInternal()
|
||||
}
|
||||
|
||||
fun setSfxEnabled(enabled: Boolean) {
|
||||
sfxEnabled = enabled
|
||||
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
|
||||
?.putBoolean(KEY_SFX, enabled)?.apply()
|
||||
}
|
||||
|
||||
private var soundPool: SoundPool? = null
|
||||
private val sfxSoundIds = HashMap<Int, Int>()
|
||||
private val sfxLoaded = HashSet<Int>()
|
||||
|
||||
private fun ensureSoundPool(ctx: Context): SoundPool {
|
||||
soundPool?.let { return it }
|
||||
val attributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_GAME)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
val pool = SoundPool.Builder().setMaxStreams(4).setAudioAttributes(attributes).build()
|
||||
pool.setOnLoadCompleteListener { _, soundId, status ->
|
||||
if (status == 0) synchronized(sfxLoaded) { sfxLoaded.add(soundId) }
|
||||
}
|
||||
for (resId in intArrayOf(
|
||||
cc.skysparkle.matewave.R.raw.sfx_move,
|
||||
cc.skysparkle.matewave.R.raw.sfx_capture,
|
||||
cc.skysparkle.matewave.R.raw.sfx_select
|
||||
)) {
|
||||
sfxSoundIds[resId] = pool.load(ctx, resId, 1)
|
||||
}
|
||||
soundPool = pool
|
||||
return pool
|
||||
}
|
||||
|
||||
private fun playSfx(resId: Int) {
|
||||
if (!sfxEnabled) return
|
||||
val ctx = appContext ?: return
|
||||
val pool = ensureSoundPool(ctx)
|
||||
val soundId = sfxSoundIds[resId] ?: return
|
||||
val ready = synchronized(sfxLoaded) { soundId in sfxLoaded }
|
||||
if (ready) pool.play(soundId, 1f, 1f, 1, 0, 1f)
|
||||
}
|
||||
|
||||
fun playMove() { playSfx(cc.skysparkle.matewave.R.raw.sfx_move) }
|
||||
fun playCapture() { playSfx(cc.skysparkle.matewave.R.raw.sfx_capture) }
|
||||
fun playSelect() { playSfx(cc.skysparkle.matewave.R.raw.sfx_select) }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package cc.skysparkle.matewave.clock
|
||||
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ClockState(
|
||||
val whiteMillis: Long,
|
||||
val blackMillis: Long,
|
||||
val running: PieceColor?,
|
||||
val flagged: PieceColor? = null
|
||||
)
|
||||
|
||||
/** Chess clock with increment; state is exposed as a StateFlow for the UI. */
|
||||
class GameClock(private val timeControl: TimeControl, private val scope: CoroutineScope) {
|
||||
private val unlimited = timeControl.initialSeconds == Int.MAX_VALUE
|
||||
|
||||
private var whiteLeft = timeControl.initialSeconds * 1000L
|
||||
private var blackLeft = timeControl.initialSeconds * 1000L
|
||||
private var running: PieceColor? = null
|
||||
private var flagged: PieceColor? = null
|
||||
private var lastAccountedNs = System.nanoTime()
|
||||
|
||||
private val _state = MutableStateFlow(ClockState(whiteLeft, blackLeft, null))
|
||||
val state: StateFlow<ClockState> = _state
|
||||
|
||||
private var tickJob: Job? = null
|
||||
|
||||
private fun account() {
|
||||
val now = System.nanoTime()
|
||||
val elapsedMs = (now - lastAccountedNs) / 1_000_000L
|
||||
lastAccountedNs = now
|
||||
val side = running ?: return
|
||||
if (unlimited || flagged != null || elapsedMs <= 0) return
|
||||
if (side == PieceColor.WHITE) whiteLeft = (whiteLeft - elapsedMs).coerceAtLeast(0)
|
||||
else blackLeft = (blackLeft - elapsedMs).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
_state.value = ClockState(whiteLeft, blackLeft, running, flagged)
|
||||
}
|
||||
|
||||
fun start(side: PieceColor) {
|
||||
tickJob?.cancel()
|
||||
account()
|
||||
running = side
|
||||
publish()
|
||||
if (unlimited) return
|
||||
tickJob = scope.launch {
|
||||
while (isActive) {
|
||||
val current = running ?: break
|
||||
val left = if (current == PieceColor.WHITE) whiteLeft else blackLeft
|
||||
|
||||
val sinceAccount = (System.nanoTime() - lastAccountedNs) / 1_000_000L
|
||||
delay(((left - sinceAccount) % 1000L + 1L).coerceIn(1L, 1000L))
|
||||
account()
|
||||
val nowLeft = if (current == PieceColor.WHITE) whiteLeft else blackLeft
|
||||
if (nowLeft <= 0L) {
|
||||
flagged = current
|
||||
publish()
|
||||
break
|
||||
}
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMoveMade(movedSide: PieceColor) {
|
||||
account()
|
||||
val incMs = timeControl.incrementSeconds * 1000L
|
||||
if (!unlimited) {
|
||||
if (movedSide == PieceColor.WHITE) whiteLeft += incMs else blackLeft += incMs
|
||||
}
|
||||
start(if (movedSide == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE)
|
||||
}
|
||||
|
||||
fun applyRemoteTime(side: PieceColor, remainingMillis: Long) {
|
||||
if (remainingMillis < 0 || unlimited) return
|
||||
account()
|
||||
|
||||
if (flagged == side && remainingMillis > 0) {
|
||||
// The opponent's move arrived in time after all: unflag and keep the clock ticking.
|
||||
flagged = null
|
||||
if (side == PieceColor.WHITE) whiteLeft = remainingMillis else blackLeft = remainingMillis
|
||||
lastAccountedNs = System.nanoTime()
|
||||
val current = running
|
||||
if (current != null) start(current) else publish()
|
||||
return
|
||||
}
|
||||
val local = if (side == PieceColor.WHITE) whiteLeft else blackLeft
|
||||
if (kotlin.math.abs(local - remainingMillis) < SYNC_THRESHOLD_MS) return
|
||||
if (side == PieceColor.WHITE) whiteLeft = remainingMillis else blackLeft = remainingMillis
|
||||
publish()
|
||||
}
|
||||
|
||||
fun restore(whiteMillis: Long, blackMillis: Long) {
|
||||
if (unlimited) return
|
||||
account()
|
||||
if (whiteMillis >= 0) whiteLeft = whiteMillis
|
||||
if (blackMillis >= 0) blackLeft = blackMillis
|
||||
publish()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
tickJob?.cancel()
|
||||
account()
|
||||
running = null
|
||||
publish()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
tickJob?.cancel()
|
||||
account()
|
||||
running = null
|
||||
publish()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SYNC_THRESHOLD_MS = 1_500L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cc.skysparkle.matewave.clock
|
||||
|
||||
data class TimeControl(
|
||||
val label: String,
|
||||
val initialSeconds: Int,
|
||||
val incrementSeconds: Int
|
||||
) {
|
||||
companion object {
|
||||
val BULLET_1_0 = TimeControl("Bullet 1+0", 60, 0)
|
||||
val BULLET_2_1 = TimeControl("Bullet 2+1", 120, 1)
|
||||
val BLITZ_3_0 = TimeControl("Blitz 3+0", 180, 0)
|
||||
val BLITZ_5_0 = TimeControl("Blitz 5+0", 300, 0)
|
||||
val BLITZ_5_3 = TimeControl("Blitz 5+3", 300, 3)
|
||||
val RAPID_10_0 = TimeControl("Rapid 10+0", 600, 0)
|
||||
val RAPID_15_10 = TimeControl("Rapid 15+10", 900, 10)
|
||||
val CLASSICAL_30_0 = TimeControl("Classical 30+0", 1800, 0)
|
||||
val CLASSICAL_30_20 = TimeControl("Classical 30+20", 1800, 20)
|
||||
val NO_LIMIT = TimeControl("No time limit", Int.MAX_VALUE, 0)
|
||||
|
||||
val presets = listOf(
|
||||
BULLET_1_0, BULLET_2_1,
|
||||
BLITZ_3_0, BLITZ_5_0, BLITZ_5_3,
|
||||
RAPID_10_0, RAPID_15_10,
|
||||
CLASSICAL_30_0, CLASSICAL_30_20,
|
||||
NO_LIMIT
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cc.skysparkle.matewave.data
|
||||
|
||||
import android.content.Context
|
||||
import java.util.UUID
|
||||
|
||||
object DeviceIdentity {
|
||||
private const val PREFS = "device_identity"
|
||||
private const val KEY_DEVICE_ID = "deviceId"
|
||||
|
||||
@Volatile private var cached: String? = null
|
||||
|
||||
fun deviceId(context: Context): String {
|
||||
cached?.let { return it }
|
||||
synchronized(this) {
|
||||
cached?.let { return it }
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val existing = prefs.getString(KEY_DEVICE_ID, null)
|
||||
val id = existing ?: UUID.randomUUID().toString().also {
|
||||
prefs.edit().putString(KEY_DEVICE_ID, it).apply()
|
||||
}
|
||||
cached = id
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package cc.skysparkle.matewave.data
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
|
||||
data class SavedGame(
|
||||
val fen: String,
|
||||
val myColorWhite: Boolean,
|
||||
val difficultyName: String,
|
||||
val initialSeconds: Int,
|
||||
val incrementSeconds: Int,
|
||||
val whiteMillis: Long = -1,
|
||||
val blackMillis: Long = -1,
|
||||
/** Moves from the standard start in UCI, space separated; empty for saves made before version 3. */
|
||||
val movesUci: String = ""
|
||||
)
|
||||
|
||||
/** Persists the unfinished game against the AI: moves, position and remaining clock time. */
|
||||
object GameSaveStore {
|
||||
private const val DB_NAME = "chess_save.db"
|
||||
private const val DB_VERSION = 3
|
||||
private const val TABLE = "saved_game"
|
||||
|
||||
@Volatile private var helper: DbHelper? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (helper == null) synchronized(this) {
|
||||
if (helper == null) helper = DbHelper(context.applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
private class DbHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE $TABLE (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
fen TEXT NOT NULL,
|
||||
my_color_white INTEGER NOT NULL,
|
||||
difficulty TEXT NOT NULL,
|
||||
initial_seconds INTEGER NOT NULL,
|
||||
increment_seconds INTEGER NOT NULL,
|
||||
white_ms INTEGER NOT NULL DEFAULT -1,
|
||||
black_ms INTEGER NOT NULL DEFAULT -1,
|
||||
moves TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion < 2) {
|
||||
db.execSQL("ALTER TABLE $TABLE ADD COLUMN white_ms INTEGER NOT NULL DEFAULT -1")
|
||||
db.execSQL("ALTER TABLE $TABLE ADD COLUMN black_ms INTEGER NOT NULL DEFAULT -1")
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
db.execSQL("ALTER TABLE $TABLE ADD COLUMN moves TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(game: SavedGame) {
|
||||
val db = runCatching { helper?.writableDatabase }.getOrNull() ?: return
|
||||
val values = ContentValues().apply {
|
||||
put("id", 1)
|
||||
put("fen", game.fen)
|
||||
put("my_color_white", if (game.myColorWhite) 1 else 0)
|
||||
put("difficulty", game.difficultyName)
|
||||
put("initial_seconds", game.initialSeconds)
|
||||
put("increment_seconds", game.incrementSeconds)
|
||||
put("white_ms", game.whiteMillis)
|
||||
put("black_ms", game.blackMillis)
|
||||
put("moves", game.movesUci)
|
||||
}
|
||||
runCatching { db.insertWithOnConflict(TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE) }
|
||||
}
|
||||
|
||||
fun load(): SavedGame? {
|
||||
val db = runCatching { helper?.readableDatabase }.getOrNull() ?: return null
|
||||
return runCatching {
|
||||
db.query(TABLE, null, "id = 1", null, null, null, null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
SavedGame(
|
||||
fen = cursor.getString(cursor.getColumnIndexOrThrow("fen")),
|
||||
myColorWhite = cursor.getInt(cursor.getColumnIndexOrThrow("my_color_white")) == 1,
|
||||
difficultyName = cursor.getString(cursor.getColumnIndexOrThrow("difficulty")),
|
||||
initialSeconds = cursor.getInt(cursor.getColumnIndexOrThrow("initial_seconds")),
|
||||
incrementSeconds = cursor.getInt(cursor.getColumnIndexOrThrow("increment_seconds")),
|
||||
whiteMillis = cursor.getLong(cursor.getColumnIndexOrThrow("white_ms")),
|
||||
blackMillis = cursor.getLong(cursor.getColumnIndexOrThrow("black_ms")),
|
||||
movesUci = cursor.getString(cursor.getColumnIndexOrThrow("moves")) ?: ""
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
runCatching { helper?.writableDatabase?.delete(TABLE, "id = 1", null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package cc.skysparkle.matewave.data
|
||||
|
||||
import cc.skysparkle.matewave.ai.AiDifficulty
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class Profile(
|
||||
|
||||
val userId: String,
|
||||
val name: String,
|
||||
val avatarBase64: String?,
|
||||
val backgroundBase64: String?,
|
||||
val elo: Int,
|
||||
val levelChosen: Boolean
|
||||
)
|
||||
|
||||
object ProfileStore {
|
||||
private const val PREFS = "chess_profile_prefs"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
private const val KEY_NAME = "name"
|
||||
private const val KEY_AVATAR = "avatar"
|
||||
private const val KEY_BACKGROUND = "background"
|
||||
private const val KEY_ELO = "elo"
|
||||
private const val KEY_LEVEL_CHOSEN = "level_chosen"
|
||||
private const val K_FACTOR = 32.0
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private fun prefs() = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun userId(): String {
|
||||
val p = prefs() ?: return "local"
|
||||
p.getString(KEY_USER_ID, null)?.let { return it }
|
||||
val fresh = java.util.UUID.randomUUID().toString()
|
||||
p.edit().putString(KEY_USER_ID, fresh).apply()
|
||||
return fresh
|
||||
}
|
||||
|
||||
fun get(): Profile {
|
||||
val p = prefs()
|
||||
return Profile(
|
||||
userId = userId(),
|
||||
|
||||
name = p?.getString(KEY_NAME, null)
|
||||
?: appContext?.getString(cc.skysparkle.matewave.R.string.player_default_name)
|
||||
?: "Player",
|
||||
avatarBase64 = p?.getString(KEY_AVATAR, null),
|
||||
backgroundBase64 = p?.getString(KEY_BACKGROUND, null),
|
||||
elo = p?.getInt(KEY_ELO, 1200) ?: 1200,
|
||||
levelChosen = p?.getBoolean(KEY_LEVEL_CHOSEN, false) ?: false
|
||||
)
|
||||
}
|
||||
|
||||
fun setName(name: String) {
|
||||
prefs()?.edit()?.putString(KEY_NAME, name)?.apply()
|
||||
}
|
||||
|
||||
fun setAvatar(base64: String?) {
|
||||
prefs()?.edit()?.putString(KEY_AVATAR, base64)?.apply()
|
||||
}
|
||||
|
||||
fun resetAvatarToDefault() = setAvatar(null)
|
||||
|
||||
fun setBackground(base64: String?) {
|
||||
prefs()?.edit()?.putString(KEY_BACKGROUND, base64)?.apply()
|
||||
}
|
||||
|
||||
fun setStartingElo(elo: Int) {
|
||||
prefs()?.edit()
|
||||
?.putInt(KEY_ELO, elo)
|
||||
?.putBoolean(KEY_LEVEL_CHOSEN, true)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
fun applyEloDelta(opponentElo: Int, result: Double) {
|
||||
val current = get().elo
|
||||
val expected = 1.0 / (1.0 + 10.0.pow((opponentElo - current) / 400.0))
|
||||
val newElo = (current + K_FACTOR * (result - expected)).roundToInt().coerceIn(100, 3000)
|
||||
prefs()?.edit()?.putInt(KEY_ELO, newElo)?.apply()
|
||||
cc.skysparkle.matewave.stats.Stats.recordRating(current, newElo)
|
||||
}
|
||||
|
||||
fun aiNominalElo(difficulty: AiDifficulty): Int = when (difficulty) {
|
||||
AiDifficulty.EASY -> 800
|
||||
AiDifficulty.MEDIUM -> 1200
|
||||
AiDifficulty.HARD -> 1600
|
||||
AiDifficulty.EXPERT -> 2000
|
||||
}
|
||||
}
|
||||
|
||||
data class FriendEntry(
|
||||
|
||||
val userId: String,
|
||||
val name: String,
|
||||
val avatarBase64: String?,
|
||||
val backgroundBase64: String?,
|
||||
val elo: Int,
|
||||
val lastPlayedMs: Long,
|
||||
|
||||
val favorite: Boolean = false,
|
||||
/** Last time the friend was seen nearby; together with the last game it gives "last seen". */
|
||||
val lastSeenMs: Long = 0
|
||||
)
|
||||
|
||||
object FriendsStore {
|
||||
private const val PREFS = "chess_friends_prefs"
|
||||
private const val KEY_LIST = "friends_json"
|
||||
private const val MAX_FRIENDS = 30
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var cached: List<FriendEntry>? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
cached = null
|
||||
}
|
||||
|
||||
private fun prefs() = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun list(): List<FriendEntry> = synchronized(lock) { readAll() }
|
||||
|
||||
private fun readAll(): List<FriendEntry> {
|
||||
cached?.let { return it }
|
||||
val raw = prefs()?.getString(KEY_LIST, null) ?: return emptyList()
|
||||
val arr = try {
|
||||
JSONArray(raw)
|
||||
} catch (e: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
val result = ArrayList<FriendEntry>()
|
||||
for (i in 0 until arr.length()) {
|
||||
try {
|
||||
val o = arr.getJSONObject(i)
|
||||
result.add(
|
||||
FriendEntry(
|
||||
userId = o.optString("uid", "").ifBlank { o.getString("name") },
|
||||
name = o.getString("name"),
|
||||
avatarBase64 = if (o.has("avatar")) o.getString("avatar") else null,
|
||||
backgroundBase64 = if (o.has("background")) o.getString("background") else null,
|
||||
elo = o.optInt("elo", 1200),
|
||||
lastPlayedMs = o.optLong("t", 0),
|
||||
favorite = o.optBoolean("fav", false),
|
||||
lastSeenMs = o.optLong("s", 0)
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
val sorted = result.sortedByDescending { it.lastPlayedMs }
|
||||
cached = sorted
|
||||
return sorted
|
||||
}
|
||||
|
||||
private fun writeAll(entries: List<FriendEntry>) {
|
||||
val p = prefs() ?: return
|
||||
val arr = JSONArray()
|
||||
entries.forEach { f ->
|
||||
val o = JSONObject()
|
||||
o.put("uid", f.userId)
|
||||
o.put("name", f.name)
|
||||
if (f.avatarBase64 != null) o.put("avatar", f.avatarBase64)
|
||||
if (f.backgroundBase64 != null) o.put("background", f.backgroundBase64)
|
||||
o.put("elo", f.elo)
|
||||
o.put("t", f.lastPlayedMs)
|
||||
if (f.favorite) o.put("fav", true)
|
||||
if (f.lastSeenMs > 0) o.put("s", f.lastSeenMs)
|
||||
arr.put(o)
|
||||
}
|
||||
p.edit().putString(KEY_LIST, arr.toString()).apply()
|
||||
cached = entries.sortedByDescending { it.lastPlayedMs }
|
||||
}
|
||||
|
||||
fun preload() {
|
||||
synchronized(lock) { readAll() }
|
||||
}
|
||||
|
||||
fun updateProfile(
|
||||
userId: String,
|
||||
name: String,
|
||||
elo: Int,
|
||||
avatarBase64: String? = null,
|
||||
backgroundBase64: String? = null
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val existing = readAll().firstOrNull { it.userId == userId } ?: return
|
||||
val updated = existing.copy(
|
||||
name = name.ifBlank { existing.name },
|
||||
elo = if (elo > 0) elo else existing.elo,
|
||||
avatarBase64 = avatarBase64 ?: existing.avatarBase64,
|
||||
backgroundBase64 = backgroundBase64 ?: existing.backgroundBase64
|
||||
)
|
||||
if (updated == existing) return
|
||||
recordOpponent(updated)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(userId: String) {
|
||||
synchronized(lock) {
|
||||
val existing = readAll().firstOrNull { it.userId == userId } ?: return
|
||||
recordOpponent(existing.copy(favorite = !existing.favorite))
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeOpponent(
|
||||
userId: String,
|
||||
name: String,
|
||||
elo: Int,
|
||||
avatarBase64: String? = null,
|
||||
backgroundBase64: String? = null
|
||||
): FriendEntry = synchronized(lock) {
|
||||
val existing = readAll().firstOrNull { it.userId == userId }
|
||||
val merged = FriendEntry(
|
||||
userId = userId,
|
||||
name = name.ifBlank { existing?.name ?: name },
|
||||
avatarBase64 = avatarBase64 ?: existing?.avatarBase64,
|
||||
backgroundBase64 = backgroundBase64 ?: existing?.backgroundBase64,
|
||||
elo = if (elo > 0) elo else (existing?.elo ?: 1200),
|
||||
lastPlayedMs = System.currentTimeMillis(),
|
||||
favorite = existing?.favorite ?: false,
|
||||
lastSeenMs = existing?.lastSeenMs ?: 0
|
||||
)
|
||||
recordOpponent(merged)
|
||||
merged
|
||||
}
|
||||
|
||||
/** Notes that a friend is nearby now. Written at most once a minute per friend. */
|
||||
fun markSeen(userId: String, at: Long) {
|
||||
synchronized(lock) {
|
||||
val all = readAll()
|
||||
val index = all.indexOfFirst { it.userId == userId }
|
||||
if (index < 0 || at - all[index].lastSeenMs < 60_000) return
|
||||
// Order is kept: being seen is not a game, so the friend does not jump to the top.
|
||||
writeAll(all.toMutableList().also { it[index] = all[index].copy(lastSeenMs = at) })
|
||||
}
|
||||
}
|
||||
|
||||
fun recordOpponent(entry: FriendEntry) {
|
||||
synchronized(lock) {
|
||||
val current = readAll().filter { it.userId != entry.userId }
|
||||
writeAll((listOf(entry) + current).take(MAX_FRIENDS))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package cc.skysparkle.matewave.engine
|
||||
|
||||
data class Board(
|
||||
val squares: Array<Piece?>,
|
||||
val sideToMove: PieceColor,
|
||||
val whiteKingSideCastle: Boolean,
|
||||
val whiteQueenSideCastle: Boolean,
|
||||
val blackKingSideCastle: Boolean,
|
||||
val blackQueenSideCastle: Boolean,
|
||||
val enPassantTarget: Int?,
|
||||
val halfmoveClock: Int,
|
||||
val fullmoveNumber: Int
|
||||
) {
|
||||
fun pieceAt(sq: Int): Piece? = squares[sq]
|
||||
|
||||
fun deepCopySquares(): Array<Piece?> = squares.copyOf()
|
||||
|
||||
fun kingSquare(color: PieceColor): Int {
|
||||
for (i in 0 until 64) {
|
||||
val p = squares[i]
|
||||
if (p != null && p.type == PieceType.KING && p.color == color) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Board) return false
|
||||
return squares.contentEquals(other.squares) &&
|
||||
sideToMove == other.sideToMove &&
|
||||
whiteKingSideCastle == other.whiteKingSideCastle &&
|
||||
whiteQueenSideCastle == other.whiteQueenSideCastle &&
|
||||
blackKingSideCastle == other.blackKingSideCastle &&
|
||||
blackQueenSideCastle == other.blackQueenSideCastle &&
|
||||
enPassantTarget == other.enPassantTarget
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = squares.contentHashCode()
|
||||
result = 31 * result + sideToMove.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun startPosition(): Board = fromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
||||
|
||||
fun fromFen(fen: String): Board {
|
||||
val parts = fen.trim().split(" ")
|
||||
val rows = parts[0].split("/")
|
||||
val squares = arrayOfNulls<Piece?>(64)
|
||||
for (r in 0 until 8) {
|
||||
val rank = 7 - r
|
||||
var file = 0
|
||||
for (c in rows[r]) {
|
||||
if (c.isDigit()) {
|
||||
file += c.digitToInt()
|
||||
} else {
|
||||
val color = if (c.isUpperCase()) PieceColor.WHITE else PieceColor.BLACK
|
||||
val type = when (c.uppercaseChar()) {
|
||||
'P' -> PieceType.PAWN
|
||||
'N' -> PieceType.KNIGHT
|
||||
'B' -> PieceType.BISHOP
|
||||
'R' -> PieceType.ROOK
|
||||
'Q' -> PieceType.QUEEN
|
||||
'K' -> PieceType.KING
|
||||
else -> throw IllegalArgumentException("Bad FEN char $c")
|
||||
}
|
||||
squares[rank * 8 + file] = Piece(type, color)
|
||||
file++
|
||||
}
|
||||
}
|
||||
}
|
||||
val side = if (parts.getOrElse(1) { "w" } == "w") PieceColor.WHITE else PieceColor.BLACK
|
||||
val castling = parts.getOrElse(2) { "-" }
|
||||
val ep = parts.getOrElse(3) { "-" }
|
||||
val halfmove = parts.getOrElse(4) { "0" }.toIntOrNull() ?: 0
|
||||
val fullmove = parts.getOrElse(5) { "1" }.toIntOrNull() ?: 1
|
||||
return Board(
|
||||
squares = squares,
|
||||
sideToMove = side,
|
||||
whiteKingSideCastle = castling.contains('K'),
|
||||
whiteQueenSideCastle = castling.contains('Q'),
|
||||
blackKingSideCastle = castling.contains('k'),
|
||||
blackQueenSideCastle = castling.contains('q'),
|
||||
enPassantTarget = if (ep == "-") null else Move.squareFromName(ep),
|
||||
halfmoveClock = halfmove,
|
||||
fullmoveNumber = fullmove
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toFen(): String {
|
||||
val sb = StringBuilder()
|
||||
for (r in 7 downTo 0) {
|
||||
var empty = 0
|
||||
for (f in 0 until 8) {
|
||||
val p = squares[r * 8 + f]
|
||||
if (p == null) {
|
||||
empty++
|
||||
} else {
|
||||
if (empty > 0) { sb.append(empty); empty = 0 }
|
||||
sb.append(p.fenChar())
|
||||
}
|
||||
}
|
||||
if (empty > 0) sb.append(empty)
|
||||
if (r != 0) sb.append('/')
|
||||
}
|
||||
sb.append(if (sideToMove == PieceColor.WHITE) " w " else " b ")
|
||||
val castle = buildString {
|
||||
if (whiteKingSideCastle) append('K')
|
||||
if (whiteQueenSideCastle) append('Q')
|
||||
if (blackKingSideCastle) append('k')
|
||||
if (blackQueenSideCastle) append('q')
|
||||
}
|
||||
sb.append(if (castle.isEmpty()) "-" else castle)
|
||||
sb.append(' ')
|
||||
sb.append(if (enPassantTarget == null) "-" else Move.squareName(enPassantTarget))
|
||||
sb.append(' ').append(halfmoveClock).append(' ').append(fullmoveNumber)
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package cc.skysparkle.matewave.engine
|
||||
|
||||
enum class GameStatus {
|
||||
ONGOING, CHECKMATE, STALEMATE, DRAW_50_MOVES, DRAW_REPETITION, DRAW_MATERIAL, TIMEOUT,
|
||||
/** A flag fell, but the other side has too little material to ever mate. */
|
||||
TIMEOUT_DRAW,
|
||||
RESIGNATION, DRAW_AGREEMENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules of chess: legal move generation (castling, en passant, promotion), move
|
||||
* application and game-status detection (mate, stalemate, 50-move rule, threefold
|
||||
* repetition, insufficient material).
|
||||
*/
|
||||
object ChessEngine {
|
||||
private fun file(sq: Int) = sq % 8
|
||||
private fun rank(sq: Int) = sq / 8
|
||||
private fun inBounds(f: Int, r: Int) = f in 0..7 && r in 0..7
|
||||
private fun sq(f: Int, r: Int) = r * 8 + f
|
||||
|
||||
private val KNIGHT_DF = intArrayOf(1, 2, 2, 1, -1, -2, -2, -1)
|
||||
private val KNIGHT_DR = intArrayOf(2, 1, -1, -2, -2, -1, 1, 2)
|
||||
private val DIAG_DF = intArrayOf(1, 1, -1, -1)
|
||||
private val DIAG_DR = intArrayOf(1, -1, 1, -1)
|
||||
private val ORTHO_DF = intArrayOf(1, -1, 0, 0)
|
||||
private val ORTHO_DR = intArrayOf(0, 0, 1, -1)
|
||||
private val ALL_DF = intArrayOf(1, -1, 0, 0, 1, 1, -1, -1)
|
||||
private val ALL_DR = intArrayOf(0, 0, 1, -1, 1, -1, 1, -1)
|
||||
private val PAWN_DF = intArrayOf(-1, 1)
|
||||
|
||||
private fun pseudoLegalMoves(board: Board): List<Move> {
|
||||
val moves = ArrayList<Move>(48)
|
||||
for (from in 0 until 64) {
|
||||
val piece = board.squares[from] ?: continue
|
||||
if (piece.color != board.sideToMove) continue
|
||||
when (piece.type) {
|
||||
PieceType.PAWN -> generatePawnMoves(board, from, piece, moves)
|
||||
PieceType.KNIGHT -> generateKnightMoves(board, from, piece, moves)
|
||||
PieceType.BISHOP -> generateSlidingMoves(board, from, piece, moves, DIAG_DF, DIAG_DR)
|
||||
PieceType.ROOK -> generateSlidingMoves(board, from, piece, moves, ORTHO_DF, ORTHO_DR)
|
||||
PieceType.QUEEN -> generateSlidingMoves(board, from, piece, moves, ALL_DF, ALL_DR)
|
||||
PieceType.KING -> generateKingMoves(board, from, piece, moves)
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private fun generatePawnMoves(board: Board, from: Int, piece: Piece, out: MutableList<Move>) {
|
||||
val dir = if (piece.color == PieceColor.WHITE) 1 else -1
|
||||
val startRank = if (piece.color == PieceColor.WHITE) 1 else 6
|
||||
val promoRank = if (piece.color == PieceColor.WHITE) 7 else 0
|
||||
val f = file(from); val r = rank(from)
|
||||
|
||||
val oneR = r + dir
|
||||
if (inBounds(f, oneR) && board.squares[sq(f, oneR)] == null) {
|
||||
addPawnMoveOrPromotion(from, sq(f, oneR), oneR, promoRank, out)
|
||||
val twoR = r + 2 * dir
|
||||
if (r == startRank && board.squares[sq(f, twoR)] == null) {
|
||||
out.add(Move(from, sq(f, twoR)))
|
||||
}
|
||||
}
|
||||
for (df in PAWN_DF) {
|
||||
val nf = f + df; val nr = r + dir
|
||||
if (!inBounds(nf, nr)) continue
|
||||
val target = sq(nf, nr)
|
||||
val targetPiece = board.squares[target]
|
||||
if (targetPiece != null && targetPiece.color != piece.color) {
|
||||
addPawnMoveOrPromotion(from, target, nr, promoRank, out)
|
||||
} else if (target == board.enPassantTarget) {
|
||||
out.add(Move(from, target, isEnPassant = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPawnMoveOrPromotion(from: Int, to: Int, toRank: Int, promoRank: Int, out: MutableList<Move>) {
|
||||
if (toRank == promoRank) {
|
||||
out.add(Move(from, to, promotion = PieceType.QUEEN))
|
||||
out.add(Move(from, to, promotion = PieceType.ROOK))
|
||||
out.add(Move(from, to, promotion = PieceType.BISHOP))
|
||||
out.add(Move(from, to, promotion = PieceType.KNIGHT))
|
||||
} else {
|
||||
out.add(Move(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateKnightMoves(board: Board, from: Int, piece: Piece, out: MutableList<Move>) {
|
||||
val f = file(from); val r = rank(from)
|
||||
for (i in 0 until 8) {
|
||||
val nf = f + KNIGHT_DF[i]; val nr = r + KNIGHT_DR[i]
|
||||
if (!inBounds(nf, nr)) continue
|
||||
val target = sq(nf, nr)
|
||||
val targetPiece = board.squares[target]
|
||||
if (targetPiece == null || targetPiece.color != piece.color) out.add(Move(from, target))
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateSlidingMoves(
|
||||
board: Board, from: Int, piece: Piece, out: MutableList<Move>, dfs: IntArray, drs: IntArray
|
||||
) {
|
||||
val f = file(from); val r = rank(from)
|
||||
for (i in dfs.indices) {
|
||||
val df = dfs[i]; val dr = drs[i]
|
||||
var nf = f + df; var nr = r + dr
|
||||
while (inBounds(nf, nr)) {
|
||||
val target = sq(nf, nr)
|
||||
val targetPiece = board.squares[target]
|
||||
if (targetPiece == null) {
|
||||
out.add(Move(from, target))
|
||||
} else {
|
||||
if (targetPiece.color != piece.color) out.add(Move(from, target))
|
||||
break
|
||||
}
|
||||
nf += df; nr += dr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateKingMoves(board: Board, from: Int, piece: Piece, out: MutableList<Move>) {
|
||||
val f = file(from); val r = rank(from)
|
||||
for (df in -1..1) for (dr in -1..1) {
|
||||
if (df == 0 && dr == 0) continue
|
||||
val nf = f + df; val nr = r + dr
|
||||
if (!inBounds(nf, nr)) continue
|
||||
val target = sq(nf, nr)
|
||||
val targetPiece = board.squares[target]
|
||||
if (targetPiece == null || targetPiece.color != piece.color) out.add(Move(from, target))
|
||||
}
|
||||
val backRank = if (piece.color == PieceColor.WHITE) 0 else 7
|
||||
if (from == sq(4, backRank)) {
|
||||
val ownRook = Piece(PieceType.ROOK, piece.color)
|
||||
val kingSide = (if (piece.color == PieceColor.WHITE) board.whiteKingSideCastle else board.blackKingSideCastle) &&
|
||||
board.squares[sq(7, backRank)] == ownRook
|
||||
val queenSide = (if (piece.color == PieceColor.WHITE) board.whiteQueenSideCastle else board.blackQueenSideCastle) &&
|
||||
board.squares[sq(0, backRank)] == ownRook
|
||||
val enemy = piece.color.opposite()
|
||||
if (kingSide &&
|
||||
board.squares[sq(5, backRank)] == null && board.squares[sq(6, backRank)] == null &&
|
||||
!isSquareAttacked(board, sq(4, backRank), enemy) &&
|
||||
!isSquareAttacked(board, sq(5, backRank), enemy) &&
|
||||
!isSquareAttacked(board, sq(6, backRank), enemy)
|
||||
) {
|
||||
out.add(Move(from, sq(6, backRank), isCastleKingSide = true))
|
||||
}
|
||||
if (queenSide &&
|
||||
board.squares[sq(3, backRank)] == null && board.squares[sq(2, backRank)] == null && board.squares[sq(1, backRank)] == null &&
|
||||
!isSquareAttacked(board, sq(4, backRank), enemy) &&
|
||||
!isSquareAttacked(board, sq(3, backRank), enemy) &&
|
||||
!isSquareAttacked(board, sq(2, backRank), enemy)
|
||||
) {
|
||||
out.add(Move(from, sq(2, backRank), isCastleQueenSide = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSquareAttacked(board: Board, target: Int, byColor: PieceColor): Boolean =
|
||||
attacked(board.squares, target, byColor)
|
||||
|
||||
private fun attacked(squares: Array<Piece?>, target: Int, byColor: PieceColor): Boolean {
|
||||
val tf = file(target); val tr = rank(target)
|
||||
val pawnDir = if (byColor == PieceColor.WHITE) -1 else 1
|
||||
for (df in PAWN_DF) {
|
||||
val f = tf + df; val r = tr + pawnDir
|
||||
if (inBounds(f, r)) {
|
||||
val p = squares[sq(f, r)]
|
||||
if (p != null && p.color == byColor && p.type == PieceType.PAWN) return true
|
||||
}
|
||||
}
|
||||
for (i in 0 until 8) {
|
||||
val f = tf + KNIGHT_DF[i]; val r = tr + KNIGHT_DR[i]
|
||||
if (inBounds(f, r)) {
|
||||
val p = squares[sq(f, r)]
|
||||
if (p != null && p.color == byColor && p.type == PieceType.KNIGHT) return true
|
||||
}
|
||||
}
|
||||
for (i in 0 until 8) {
|
||||
val f = tf + ALL_DF[i]; val r = tr + ALL_DR[i]
|
||||
if (inBounds(f, r)) {
|
||||
val p = squares[sq(f, r)]
|
||||
if (p != null && p.color == byColor && p.type == PieceType.KING) return true
|
||||
}
|
||||
}
|
||||
for (i in 0 until 4) {
|
||||
val df = DIAG_DF[i]; val dr = DIAG_DR[i]
|
||||
var f = tf + df; var r = tr + dr
|
||||
while (inBounds(f, r)) {
|
||||
val p = squares[sq(f, r)]
|
||||
if (p != null) {
|
||||
if (p.color == byColor && (p.type == PieceType.BISHOP || p.type == PieceType.QUEEN)) return true
|
||||
break
|
||||
}
|
||||
f += df; r += dr
|
||||
}
|
||||
}
|
||||
for (i in 0 until 4) {
|
||||
val df = ORTHO_DF[i]; val dr = ORTHO_DR[i]
|
||||
var f = tf + df; var r = tr + dr
|
||||
while (inBounds(f, r)) {
|
||||
val p = squares[sq(f, r)]
|
||||
if (p != null) {
|
||||
if (p.color == byColor && (p.type == PieceType.ROOK || p.type == PieceType.QUEEN)) return true
|
||||
break
|
||||
}
|
||||
f += df; r += dr
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isInCheck(board: Board, color: PieceColor): Boolean {
|
||||
val kingSq = board.kingSquare(color)
|
||||
if (kingSq == -1) return false
|
||||
return isSquareAttacked(board, kingSq, color.opposite())
|
||||
}
|
||||
|
||||
fun legalMoves(board: Board): List<Move> {
|
||||
val pseudo = pseudoLegalMoves(board)
|
||||
val side = board.sideToMove
|
||||
val enemy = side.opposite()
|
||||
val work = board.squares.copyOf()
|
||||
val ownKing = board.kingSquare(side)
|
||||
val result = ArrayList<Move>(pseudo.size)
|
||||
for (m in pseudo) {
|
||||
val moving = work[m.from] ?: continue
|
||||
val captured = work[m.to]
|
||||
|
||||
var epIdx = -1
|
||||
var epPiece: Piece? = null
|
||||
if (m.isEnPassant) {
|
||||
val capturedRank = if (moving.color == PieceColor.WHITE) rank(m.to) - 1 else rank(m.to) + 1
|
||||
epIdx = sq(file(m.to), capturedRank)
|
||||
epPiece = work[epIdx]
|
||||
work[epIdx] = null
|
||||
}
|
||||
work[m.to] = if (m.promotion != null) Piece(m.promotion, moving.color) else moving
|
||||
work[m.from] = null
|
||||
|
||||
var rookFrom = -1
|
||||
var rookTo = -1
|
||||
var rook: Piece? = null
|
||||
if (m.isCastleKingSide) {
|
||||
val backRank = rank(m.from); rookFrom = sq(7, backRank); rookTo = sq(5, backRank)
|
||||
} else if (m.isCastleQueenSide) {
|
||||
val backRank = rank(m.from); rookFrom = sq(0, backRank); rookTo = sq(3, backRank)
|
||||
}
|
||||
if (rookFrom >= 0) {
|
||||
rook = work[rookFrom]
|
||||
work[rookTo] = rook
|
||||
work[rookFrom] = null
|
||||
}
|
||||
|
||||
val kingSq = if (moving.type == PieceType.KING) m.to else ownKing
|
||||
val safe = kingSq == -1 || !attacked(work, kingSq, enemy)
|
||||
|
||||
if (rookFrom >= 0) {
|
||||
work[rookFrom] = rook
|
||||
work[rookTo] = null
|
||||
}
|
||||
work[m.from] = moving
|
||||
work[m.to] = captured
|
||||
if (epIdx >= 0) work[epIdx] = epPiece
|
||||
|
||||
if (safe) result.add(m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun legalMovesFrom(board: Board, from: Int): List<Move> = legalMoves(board).filter { it.from == from }
|
||||
|
||||
fun applyMove(board: Board, move: Move): Board {
|
||||
val squares = board.deepCopySquares()
|
||||
val moving = squares[move.from] ?: return board
|
||||
val isPawn = moving.type == PieceType.PAWN
|
||||
val isCapture = squares[move.to] != null
|
||||
|
||||
if (move.isEnPassant) {
|
||||
val capturedRank = if (moving.color == PieceColor.WHITE) rank(move.to) - 1 else rank(move.to) + 1
|
||||
squares[sq(file(move.to), capturedRank)] = null
|
||||
}
|
||||
|
||||
squares[move.to] = if (move.promotion != null) Piece(move.promotion, moving.color) else moving
|
||||
squares[move.from] = null
|
||||
|
||||
if (move.isCastleKingSide) {
|
||||
val backRank = rank(move.from)
|
||||
squares[sq(5, backRank)] = squares[sq(7, backRank)]
|
||||
squares[sq(7, backRank)] = null
|
||||
}
|
||||
if (move.isCastleQueenSide) {
|
||||
val backRank = rank(move.from)
|
||||
squares[sq(3, backRank)] = squares[sq(0, backRank)]
|
||||
squares[sq(0, backRank)] = null
|
||||
}
|
||||
|
||||
var wk = board.whiteKingSideCastle
|
||||
var wq = board.whiteQueenSideCastle
|
||||
var bk = board.blackKingSideCastle
|
||||
var bq = board.blackQueenSideCastle
|
||||
if (moving.type == PieceType.KING) {
|
||||
if (moving.color == PieceColor.WHITE) { wk = false; wq = false } else { bk = false; bq = false }
|
||||
}
|
||||
if (move.from == sq(0, 0) || move.to == sq(0, 0)) wq = false
|
||||
if (move.from == sq(7, 0) || move.to == sq(7, 0)) wk = false
|
||||
if (move.from == sq(0, 7) || move.to == sq(0, 7)) bq = false
|
||||
if (move.from == sq(7, 7) || move.to == sq(7, 7)) bk = false
|
||||
|
||||
val newEnPassant = if (isPawn && kotlin.math.abs(rank(move.to) - rank(move.from)) == 2) {
|
||||
sq(file(move.from), (rank(move.from) + rank(move.to)) / 2)
|
||||
} else null
|
||||
|
||||
val newHalfmove = if (isPawn || isCapture) 0 else board.halfmoveClock + 1
|
||||
val newFullmove = if (board.sideToMove == PieceColor.BLACK) board.fullmoveNumber + 1 else board.fullmoveNumber
|
||||
|
||||
return Board(
|
||||
squares = squares,
|
||||
sideToMove = board.sideToMove.opposite(),
|
||||
whiteKingSideCastle = wk,
|
||||
whiteQueenSideCastle = wq,
|
||||
blackKingSideCastle = bk,
|
||||
blackQueenSideCastle = bq,
|
||||
enPassantTarget = newEnPassant,
|
||||
halfmoveClock = newHalfmove,
|
||||
fullmoveNumber = newFullmove
|
||||
)
|
||||
}
|
||||
|
||||
fun gameStatus(board: Board, positionHistory: List<String> = emptyList()): GameStatus {
|
||||
val moves = legalMoves(board)
|
||||
if (moves.isEmpty()) {
|
||||
return if (isInCheck(board, board.sideToMove)) GameStatus.CHECKMATE else GameStatus.STALEMATE
|
||||
}
|
||||
if (board.halfmoveClock >= 100) return GameStatus.DRAW_50_MOVES
|
||||
val key = Notation.positionKey(board)
|
||||
if (positionHistory.count { it == key } >= 3) return GameStatus.DRAW_REPETITION
|
||||
if (isDeadPosition(board)) return GameStatus.DRAW_MATERIAL
|
||||
return GameStatus.ONGOING
|
||||
}
|
||||
|
||||
/**
|
||||
* Neither side can ever mate: kings alone, a single minor piece, or only bishops that all
|
||||
* stand on squares of one colour.
|
||||
*/
|
||||
fun isDeadPosition(board: Board): Boolean {
|
||||
val minors = ArrayList<Int>(4)
|
||||
for (i in 0 until 64) {
|
||||
val p = board.squares[i] ?: continue
|
||||
when (p.type) {
|
||||
PieceType.KING -> Unit
|
||||
PieceType.BISHOP, PieceType.KNIGHT -> minors.add(i)
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
if (minors.size <= 1) return true
|
||||
if (minors.any { board.squares[it]?.type != PieceType.BISHOP }) return false
|
||||
val shade = (file(minors[0]) + rank(minors[0])) % 2
|
||||
return minors.all { (file(it) + rank(it)) % 2 == shade }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [color] has enough material to mate at all. A player whose flag falls loses only if
|
||||
* the opponent could still mate; otherwise the game is drawn.
|
||||
*/
|
||||
fun canMate(board: Board, color: PieceColor): Boolean {
|
||||
var minors = 0
|
||||
for (p in board.squares) {
|
||||
if (p == null || p.color != color) continue
|
||||
when (p.type) {
|
||||
PieceType.KING -> Unit
|
||||
PieceType.BISHOP, PieceType.KNIGHT -> minors++
|
||||
else -> return true
|
||||
}
|
||||
}
|
||||
return minors >= 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cc.skysparkle.matewave.engine
|
||||
|
||||
data class MaterialBalance(
|
||||
val capturedByWhite: List<PieceType>,
|
||||
val capturedByBlack: List<PieceType>,
|
||||
val advantage: Int
|
||||
)
|
||||
|
||||
/** Captured pieces and material balance, shown next to the board. */
|
||||
object Material {
|
||||
fun points(type: PieceType): Int = when (type) {
|
||||
PieceType.PAWN -> 1
|
||||
PieceType.KNIGHT -> 3
|
||||
PieceType.BISHOP -> 3
|
||||
PieceType.ROOK -> 5
|
||||
PieceType.QUEEN -> 9
|
||||
PieceType.KING -> 0
|
||||
}
|
||||
|
||||
private fun initialCount(type: PieceType): Int = when (type) {
|
||||
PieceType.PAWN -> 8
|
||||
PieceType.KNIGHT, PieceType.BISHOP, PieceType.ROOK -> 2
|
||||
PieceType.QUEEN, PieceType.KING -> 1
|
||||
}
|
||||
|
||||
private val DISPLAY_ORDER = listOf(
|
||||
PieceType.PAWN, PieceType.KNIGHT, PieceType.BISHOP, PieceType.ROOK, PieceType.QUEEN
|
||||
)
|
||||
|
||||
fun balance(board: Board): MaterialBalance {
|
||||
val white = IntArray(PieceType.values().size)
|
||||
val black = IntArray(PieceType.values().size)
|
||||
var whitePoints = 0
|
||||
var blackPoints = 0
|
||||
for (piece in board.squares) {
|
||||
if (piece == null) continue
|
||||
if (piece.color == PieceColor.WHITE) {
|
||||
white[piece.type.ordinal]++
|
||||
whitePoints += points(piece.type)
|
||||
} else {
|
||||
black[piece.type.ordinal]++
|
||||
blackPoints += points(piece.type)
|
||||
}
|
||||
}
|
||||
return MaterialBalance(
|
||||
capturedByWhite = missing(black),
|
||||
capturedByBlack = missing(white),
|
||||
advantage = whitePoints - blackPoints
|
||||
)
|
||||
}
|
||||
|
||||
private fun missing(counts: IntArray): List<PieceType> {
|
||||
val promoted = DISPLAY_ORDER.filter { it != PieceType.PAWN }
|
||||
.sumOf { maxOf(0, counts[it.ordinal] - initialCount(it)) }
|
||||
val result = ArrayList<PieceType>()
|
||||
for (type in DISPLAY_ORDER) {
|
||||
val gone = if (type == PieceType.PAWN) {
|
||||
initialCount(type) - counts[type.ordinal] - promoted
|
||||
} else {
|
||||
initialCount(type) - counts[type.ordinal]
|
||||
}
|
||||
repeat(maxOf(0, gone)) { result.add(type) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cc.skysparkle.matewave.engine
|
||||
|
||||
data class Move(
|
||||
val from: Int,
|
||||
val to: Int,
|
||||
val promotion: PieceType? = null,
|
||||
val isCastleKingSide: Boolean = false,
|
||||
val isCastleQueenSide: Boolean = false,
|
||||
val isEnPassant: Boolean = false
|
||||
) {
|
||||
fun toUci(): String {
|
||||
val promo = promotion?.symbol?.lowercaseChar()?.toString() ?: ""
|
||||
return squareName(from) + squareName(to) + promo
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun squareName(sq: Int): String {
|
||||
val file = sq % 8
|
||||
val rank = sq / 8
|
||||
return "${'a' + file}${rank + 1}"
|
||||
}
|
||||
|
||||
fun squareFromName(name: String): Int {
|
||||
val file = name[0] - 'a'
|
||||
val rank = name[1] - '1'
|
||||
return rank * 8 + file
|
||||
}
|
||||
|
||||
fun fromUci(uci: String): Move {
|
||||
val from = squareFromName(uci.substring(0, 2))
|
||||
val to = squareFromName(uci.substring(2, 4))
|
||||
val promo = if (uci.length > 4) {
|
||||
when (uci[4].lowercaseChar()) {
|
||||
'q' -> PieceType.QUEEN
|
||||
'r' -> PieceType.ROOK
|
||||
'b' -> PieceType.BISHOP
|
||||
'n' -> PieceType.KNIGHT
|
||||
else -> null
|
||||
}
|
||||
} else null
|
||||
return Move(from, to, promo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cc.skysparkle.matewave.engine
|
||||
|
||||
/**
|
||||
* Standard Algebraic Notation. SAN produced here matches Lichess notation without check marks
|
||||
* (e.g. "Nbd7", "exd6", "e8=Q", "O-O"), which is the key format of the opening data.
|
||||
*/
|
||||
object Notation {
|
||||
|
||||
fun isCastle(board: Board, move: Move): Boolean {
|
||||
if (move.isCastleKingSide || move.isCastleQueenSide) return true
|
||||
val piece = board.squares[move.from] ?: return false
|
||||
return piece.type == PieceType.KING && kotlin.math.abs(move.to % 8 - move.from % 8) == 2
|
||||
}
|
||||
|
||||
fun toSan(
|
||||
board: Board,
|
||||
move: Move,
|
||||
legal: List<Move> = ChessEngine.legalMoves(board),
|
||||
withCheck: Boolean = false
|
||||
): String {
|
||||
val piece = board.squares[move.from] ?: return move.toUci()
|
||||
val base = if (isCastle(board, move)) {
|
||||
if (move.to % 8 > move.from % 8) "O-O" else "O-O-O"
|
||||
} else {
|
||||
val capture = board.squares[move.to] != null || move.isEnPassant
|
||||
val dest = Move.squareName(move.to)
|
||||
buildString {
|
||||
if (piece.type == PieceType.PAWN) {
|
||||
if (capture) append('a' + move.from % 8).append('x')
|
||||
append(dest)
|
||||
move.promotion?.let { append('=').append(it.symbol) }
|
||||
} else {
|
||||
append(piece.type.symbol)
|
||||
val rivals = legal.filter {
|
||||
it.to == move.to && it.from != move.from &&
|
||||
board.squares[it.from]?.type == piece.type
|
||||
}
|
||||
if (rivals.isNotEmpty()) {
|
||||
val sameFile = rivals.any { it.from % 8 == move.from % 8 }
|
||||
val sameRank = rivals.any { it.from / 8 == move.from / 8 }
|
||||
when {
|
||||
!sameFile -> append('a' + move.from % 8)
|
||||
!sameRank -> append('1' + move.from / 8)
|
||||
else -> append(Move.squareName(move.from))
|
||||
}
|
||||
}
|
||||
if (capture) append('x')
|
||||
append(dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!withCheck) return base
|
||||
val after = ChessEngine.applyMove(board, move)
|
||||
if (!ChessEngine.isInCheck(after, after.sideToMove)) return base
|
||||
return base + if (ChessEngine.legalMoves(after).isEmpty()) "#" else "+"
|
||||
}
|
||||
|
||||
/** Strips check marks, annotations and "=" so SAN from any source compares equal. */
|
||||
fun normalizeSan(san: String): String =
|
||||
san.trim()
|
||||
.replace("0-0-0", "O-O-O").replace("0-0", "O-O")
|
||||
.trimEnd('+', '#', '!', '?')
|
||||
.replace("=", "")
|
||||
.trim()
|
||||
|
||||
fun fromSan(board: Board, san: String, legal: List<Move> = ChessEngine.legalMoves(board)): Move? {
|
||||
val wanted = normalizeSan(san)
|
||||
if (wanted.isEmpty()) return null
|
||||
legal.firstOrNull { normalizeSan(toSan(board, it, legal)) == wanted }?.let { return it }
|
||||
|
||||
// Over-disambiguated or loosely written SAN, e.g. "Ngf3" or "e8Q".
|
||||
val m = Regex("^([KQRBN])?([a-h])?([1-8])?x?([a-h][1-8])([QRBN])?$").find(wanted) ?: return null
|
||||
val type = when (m.groupValues[1]) {
|
||||
"K" -> PieceType.KING; "Q" -> PieceType.QUEEN; "R" -> PieceType.ROOK
|
||||
"B" -> PieceType.BISHOP; "N" -> PieceType.KNIGHT; else -> PieceType.PAWN
|
||||
}
|
||||
val fromFile = m.groupValues[2].firstOrNull()?.let { it - 'a' }
|
||||
val fromRank = m.groupValues[3].firstOrNull()?.let { it - '1' }
|
||||
val to = Move.squareFromName(m.groupValues[4])
|
||||
val promo = when (m.groupValues[5]) {
|
||||
"Q" -> PieceType.QUEEN; "R" -> PieceType.ROOK; "B" -> PieceType.BISHOP; "N" -> PieceType.KNIGHT
|
||||
else -> null
|
||||
}
|
||||
return legal.filter {
|
||||
it.to == to && board.squares[it.from]?.type == type && it.promotion == promo &&
|
||||
(fromFile == null || it.from % 8 == fromFile) &&
|
||||
(fromRank == null || it.from / 8 == fromRank)
|
||||
}.singleOrNull()
|
||||
}
|
||||
|
||||
/** Position part of a FEN without move counters, used to compare positions. */
|
||||
fun positionKey(board: Board): String =
|
||||
board.toFen().substringBeforeLast(' ').substringBeforeLast(' ')
|
||||
|
||||
fun isStandardStart(board: Board): Boolean = positionKey(board) == positionKey(Board.startPosition())
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cc.skysparkle.matewave.engine
|
||||
|
||||
enum class PieceColor { WHITE, BLACK }
|
||||
|
||||
enum class PieceType(val symbol: Char) {
|
||||
PAWN('P'), KNIGHT('N'), BISHOP('B'), ROOK('R'), QUEEN('Q'), KING('K')
|
||||
}
|
||||
|
||||
data class Piece(val type: PieceType, val color: PieceColor) {
|
||||
fun drawableRes(): Int = when (color) {
|
||||
PieceColor.WHITE -> when (type) {
|
||||
PieceType.PAWN -> cc.skysparkle.matewave.R.drawable.piece_white_pawn
|
||||
PieceType.KNIGHT -> cc.skysparkle.matewave.R.drawable.piece_white_knight
|
||||
PieceType.BISHOP -> cc.skysparkle.matewave.R.drawable.piece_white_bishop
|
||||
PieceType.ROOK -> cc.skysparkle.matewave.R.drawable.piece_white_rook
|
||||
PieceType.QUEEN -> cc.skysparkle.matewave.R.drawable.piece_white_queen
|
||||
PieceType.KING -> cc.skysparkle.matewave.R.drawable.piece_white_king
|
||||
}
|
||||
PieceColor.BLACK -> when (type) {
|
||||
PieceType.PAWN -> cc.skysparkle.matewave.R.drawable.piece_black_pawn
|
||||
PieceType.KNIGHT -> cc.skysparkle.matewave.R.drawable.piece_black_knight
|
||||
PieceType.BISHOP -> cc.skysparkle.matewave.R.drawable.piece_black_bishop
|
||||
PieceType.ROOK -> cc.skysparkle.matewave.R.drawable.piece_black_rook
|
||||
PieceType.QUEEN -> cc.skysparkle.matewave.R.drawable.piece_black_queen
|
||||
PieceType.KING -> cc.skysparkle.matewave.R.drawable.piece_black_king
|
||||
}
|
||||
}
|
||||
|
||||
fun unicode(): String = when (color) {
|
||||
PieceColor.WHITE -> when (type) {
|
||||
PieceType.PAWN -> "\u2659"
|
||||
PieceType.KNIGHT -> "\u2658"
|
||||
PieceType.BISHOP -> "\u2657"
|
||||
PieceType.ROOK -> "\u2656"
|
||||
PieceType.QUEEN -> "\u2655"
|
||||
PieceType.KING -> "\u2654"
|
||||
}
|
||||
PieceColor.BLACK -> when (type) {
|
||||
PieceType.PAWN -> "\u265F"
|
||||
PieceType.KNIGHT -> "\u265E"
|
||||
PieceType.BISHOP -> "\u265D"
|
||||
PieceType.ROOK -> "\u265C"
|
||||
PieceType.QUEEN -> "\u265B"
|
||||
PieceType.KING -> "\u265A"
|
||||
}
|
||||
}
|
||||
|
||||
fun fenChar(): Char =
|
||||
if (color == PieceColor.WHITE) type.symbol else type.symbol.lowercaseChar()
|
||||
}
|
||||
|
||||
fun PieceColor.opposite(): PieceColor =
|
||||
if (this == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE
|
||||
@@ -0,0 +1,24 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
object AppPermissions {
|
||||
/** Runtime permissions for Bluetooth play: scan, advertise and connect (location before Android 12). */
|
||||
val nearby: List<String> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_ADVERTISE
|
||||
)
|
||||
} else {
|
||||
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
fun allGranted(context: Context, permissions: List<String>): Boolean =
|
||||
permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
object BatteryOptimization {
|
||||
fun isIgnoring(context: Context): Boolean {
|
||||
val power = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return true
|
||||
return runCatching { power.isIgnoringBatteryOptimizations(context.packageName) }
|
||||
.getOrDefault(true)
|
||||
}
|
||||
|
||||
fun openSettings(context: Context) {
|
||||
val intents = listOf(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.parse("package:${context.packageName}")),
|
||||
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
)
|
||||
for (intent in intents) {
|
||||
val ok = runCatching {
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}.isSuccess
|
||||
if (ok) return
|
||||
}
|
||||
NetworkLog.log("SYS", "Could not open battery settings")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class TransportType { BLUETOOTH, LAN }
|
||||
|
||||
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, FAILED }
|
||||
|
||||
/**
|
||||
* Wire protocol shared by all transports: one JSON object per message.
|
||||
* Game messages travel inside [SignedMsg] once the handshake is done; only
|
||||
* [ProfileMsg] and [PingMsg] are sent in the clear. Bump [PROTOCOL_VERSION] on any
|
||||
* incompatible change: peers with an older version are rejected during the handshake.
|
||||
*/
|
||||
sealed class GameMessage {
|
||||
data class MoveMsg(
|
||||
val uci: String,
|
||||
val remainingMillisSelf: Long,
|
||||
val gameId: String = "",
|
||||
val ply: Int = 0
|
||||
) : GameMessage()
|
||||
|
||||
data class ResignMsg(val byWhite: Boolean, val gameId: String = "") : GameMessage()
|
||||
|
||||
data class DrawOfferMsg(val accept: Boolean? = null, val gameId: String = "") : GameMessage()
|
||||
|
||||
data class ChatMsg(val text: String? = null, val imageBase64: String? = null, val gameId: String = "") : GameMessage()
|
||||
|
||||
data class ProfileMsg(
|
||||
|
||||
val userId: String,
|
||||
val name: String,
|
||||
val avatarBase64: String?,
|
||||
val backgroundBase64: String?,
|
||||
val elo: Int,
|
||||
|
||||
val publicKey: String = "",
|
||||
val signature: String = "",
|
||||
|
||||
val protocolVersion: Int = PROTOCOL_VERSION,
|
||||
|
||||
val ephemeralKey: String = "",
|
||||
|
||||
val initialSeconds: Int = 0,
|
||||
val incrementSeconds: Int = 0,
|
||||
|
||||
val gameId: String = ""
|
||||
) : GameMessage()
|
||||
|
||||
data class TimeoutMsg(val flaggedWhite: Boolean, val gameId: String = "") : GameMessage()
|
||||
|
||||
data class LeaveMsg(val byWhite: Boolean, val gameId: String = "") : GameMessage()
|
||||
|
||||
data class ProfileImagesMsg(
|
||||
val avatarBase64: String? = null,
|
||||
val backgroundBase64: String? = null,
|
||||
val gameId: String = ""
|
||||
) : GameMessage()
|
||||
|
||||
data class PingMsg(val reply: Boolean = false, val sessionId: Int = 0) : GameMessage()
|
||||
|
||||
data class SignedMsg(val inner: String, val tag: String, val seq: Int = 0) : GameMessage()
|
||||
|
||||
data class RematchMsg(
|
||||
val action: String,
|
||||
val gameId: String = "",
|
||||
val newGameId: String = ""
|
||||
) : GameMessage()
|
||||
|
||||
fun toJson(): String {
|
||||
val o = JSONObject()
|
||||
when (this) {
|
||||
is MoveMsg -> {
|
||||
o.put("type", "move"); o.put("uci", uci); o.put("t", remainingMillisSelf)
|
||||
o.put("gameId", gameId); o.put("ply", ply)
|
||||
}
|
||||
is ResignMsg -> { o.put("type", "resign"); o.put("white", byWhite); o.put("gameId", gameId) }
|
||||
is DrawOfferMsg -> {
|
||||
o.put("type", "draw"); o.put("gameId", gameId)
|
||||
if (accept != null) o.put("accept", accept)
|
||||
}
|
||||
is ChatMsg -> {
|
||||
o.put("type", "chat"); o.put("gameId", gameId)
|
||||
if (text != null) o.put("text", text)
|
||||
if (imageBase64 != null) o.put("image", imageBase64)
|
||||
}
|
||||
is LeaveMsg -> { o.put("type", "leave"); o.put("byWhite", byWhite); o.put("gameId", gameId) }
|
||||
is ProfileImagesMsg -> {
|
||||
o.put("type", "profile_images"); o.put("gameId", gameId)
|
||||
avatarBase64?.let { o.put("avatar", it) }
|
||||
backgroundBase64?.let { o.put("background", it) }
|
||||
}
|
||||
is PingMsg -> { o.put("type", "ping"); o.put("reply", reply); o.put("sid", sessionId) }
|
||||
is TimeoutMsg -> { o.put("type", "timeout"); o.put("white", flaggedWhite); o.put("gameId", gameId) }
|
||||
is SignedMsg -> {
|
||||
o.put("type", "signed"); o.put("inner", inner); o.put("tag", tag); o.put("seq", seq)
|
||||
}
|
||||
is RematchMsg -> {
|
||||
o.put("type", "rematch"); o.put("action", action); o.put("gameId", gameId)
|
||||
if (newGameId.isNotEmpty()) o.put("newGameId", newGameId)
|
||||
}
|
||||
is ProfileMsg -> {
|
||||
o.put("type", "profile")
|
||||
o.put("userId", userId)
|
||||
o.put("name", name)
|
||||
o.put("pv", protocolVersion)
|
||||
if (initialSeconds > 0) {
|
||||
o.put("ti", initialSeconds)
|
||||
o.put("tx", incrementSeconds)
|
||||
}
|
||||
if (ephemeralKey.isNotEmpty()) o.put("ek", ephemeralKey)
|
||||
if (gameId.isNotEmpty()) o.put("gid", gameId)
|
||||
if (publicKey.isNotEmpty()) o.put("pk", publicKey)
|
||||
if (signature.isNotEmpty()) o.put("sig", signature)
|
||||
if (avatarBase64 != null) o.put("avatar", avatarBase64)
|
||||
if (backgroundBase64 != null) o.put("background", backgroundBase64)
|
||||
o.put("elo", elo)
|
||||
}
|
||||
}
|
||||
return o.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROTOCOL_VERSION = 5
|
||||
|
||||
fun fromJson(raw: String): GameMessage {
|
||||
val o = JSONObject(raw)
|
||||
return when (o.getString("type")) {
|
||||
"move" -> MoveMsg(
|
||||
o.getString("uci"), o.optLong("t", 0),
|
||||
o.optString("gameId", ""), o.optInt("ply", 0)
|
||||
)
|
||||
"resign" -> ResignMsg(o.getBoolean("white"), o.optString("gameId", ""))
|
||||
"draw" -> DrawOfferMsg(
|
||||
if (o.has("accept")) o.getBoolean("accept") else null,
|
||||
o.optString("gameId", "")
|
||||
)
|
||||
"chat" -> ChatMsg(
|
||||
text = if (o.has("text")) o.getString("text") else null,
|
||||
imageBase64 = if (o.has("image")) o.getString("image") else null,
|
||||
gameId = o.optString("gameId", "")
|
||||
)
|
||||
"leave" -> LeaveMsg(o.getBoolean("byWhite"), o.optString("gameId", ""))
|
||||
"profile_images" -> ProfileImagesMsg(
|
||||
if (o.has("avatar")) o.getString("avatar") else null,
|
||||
if (o.has("background")) o.getString("background") else null,
|
||||
gameId = o.optString("gameId", "")
|
||||
)
|
||||
"ping" -> PingMsg(o.optBoolean("reply", false), o.optInt("sid", 0))
|
||||
"timeout" -> TimeoutMsg(o.getBoolean("white"), o.optString("gameId", ""))
|
||||
"signed" -> SignedMsg(o.getString("inner"), o.getString("tag"), o.optInt("seq", 0))
|
||||
"rematch" -> RematchMsg(
|
||||
o.getString("action"),
|
||||
o.optString("gameId", ""),
|
||||
o.optString("newGameId", "")
|
||||
)
|
||||
"profile" -> ProfileMsg(
|
||||
|
||||
userId = o.optString("userId", "").ifBlank { o.getString("name") },
|
||||
protocolVersion = o.optInt("pv", 0),
|
||||
initialSeconds = o.optInt("ti", 0),
|
||||
incrementSeconds = o.optInt("tx", 0),
|
||||
ephemeralKey = o.optString("ek", ""),
|
||||
gameId = o.optString("gid", ""),
|
||||
publicKey = o.optString("pk", ""),
|
||||
signature = o.optString("sig", ""),
|
||||
name = o.getString("name"),
|
||||
avatarBase64 = if (o.has("avatar")) o.getString("avatar") else null,
|
||||
backgroundBase64 = if (o.has("background")) o.getString("background") else null,
|
||||
elo = o.optInt("elo", 1200)
|
||||
)
|
||||
else -> throw IllegalArgumentException("Unknown message type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single peer-to-peer game connection (Bluetooth LE or LAN). */
|
||||
interface ConnectionTransport {
|
||||
val type: TransportType
|
||||
val connectionState: kotlinx.coroutines.flow.StateFlow<ConnectionState>
|
||||
|
||||
fun startHosting()
|
||||
fun startDiscoveryAndConnect(targetId: String? = null)
|
||||
|
||||
fun send(message: GameMessage)
|
||||
|
||||
fun addMessageListener(listener: (GameMessage) -> Unit): () -> Unit
|
||||
|
||||
fun disconnect()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Hosts a game on every available transport at once (LAN and, with permission, Bluetooth), so
|
||||
* a guest can join over whichever channel found the host. The first transport to connect wins;
|
||||
* hosting on the others is stopped and from then on this behaves exactly like the winner.
|
||||
*/
|
||||
class DualHostTransport(transports: List<ConnectionTransport>) : ConnectionTransport {
|
||||
|
||||
private val all = transports
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var active: ConnectionTransport? = null
|
||||
private val listeners = CopyOnWriteArrayList<(GameMessage) -> Unit>()
|
||||
private val unsubscribes = mutableListOf<() -> Unit>()
|
||||
private var watchJob: Job? = null
|
||||
|
||||
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
override val connectionState: StateFlow<ConnectionState> = _state
|
||||
|
||||
override val type: TransportType get() = active?.type ?: all.first().type
|
||||
|
||||
override fun startHosting() {
|
||||
synchronized(lock) {
|
||||
active = null
|
||||
unsubscribes.forEach { it() }
|
||||
unsubscribes.clear()
|
||||
all.forEach { t ->
|
||||
unsubscribes += t.addMessageListener { message ->
|
||||
// The first message can arrive before the state change is observed: claim here too.
|
||||
if (active == null && t.connectionState.value == ConnectionState.CONNECTED) claim(t)
|
||||
if (active === t) listeners.forEach { it(message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
_state.value = ConnectionState.CONNECTING
|
||||
all.forEach { runCatching { it.startHosting() } }
|
||||
|
||||
watchJob?.cancel()
|
||||
watchJob = scope.launch {
|
||||
combine(all.map { it.connectionState }) { it.toList() }.collect { states ->
|
||||
val current = active
|
||||
if (current == null) {
|
||||
val index = states.indexOfFirst { it == ConnectionState.CONNECTED }
|
||||
when {
|
||||
index >= 0 -> claim(all[index])
|
||||
states.all { it == ConnectionState.FAILED } -> _state.value = ConnectionState.FAILED
|
||||
else -> _state.value = ConnectionState.CONNECTING
|
||||
}
|
||||
} else {
|
||||
_state.value = states[all.indexOf(current)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun claim(winner: ConnectionTransport) {
|
||||
val losers = synchronized(lock) {
|
||||
if (active != null) return
|
||||
active = winner
|
||||
all.filter { it !== winner }
|
||||
}
|
||||
losers.forEach { runCatching { it.disconnect() } }
|
||||
_state.value = ConnectionState.CONNECTED
|
||||
}
|
||||
|
||||
override fun startDiscoveryAndConnect(targetId: String?) {
|
||||
all.first().startDiscoveryAndConnect(targetId)
|
||||
}
|
||||
|
||||
override fun send(message: GameMessage) {
|
||||
active?.send(message)
|
||||
}
|
||||
|
||||
override fun addMessageListener(listener: (GameMessage) -> Unit): () -> Unit {
|
||||
listeners.add(listener)
|
||||
return { listeners.remove(listener) }
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
watchJob?.cancel()
|
||||
watchJob = null
|
||||
synchronized(lock) {
|
||||
unsubscribes.forEach { it() }
|
||||
unsubscribes.clear()
|
||||
active = null
|
||||
}
|
||||
all.forEach { runCatching { it.disconnect() } }
|
||||
_state.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Periodic ping that detects a silently dropped connection. */
|
||||
class Heartbeat(
|
||||
private val scope: CoroutineScope,
|
||||
private val transport: ConnectionTransport
|
||||
) {
|
||||
private var job: Job? = null
|
||||
|
||||
private val sessionId = java.util.concurrent.ThreadLocalRandom.current().nextInt(1, Int.MAX_VALUE)
|
||||
private var unsubscribe: (() -> Unit)? = null
|
||||
|
||||
@Volatile private var lastReplyAt = 0L
|
||||
@Volatile private var lastTrafficAt = 0L
|
||||
|
||||
fun start() {
|
||||
if (job != null) return
|
||||
lastReplyAt = System.currentTimeMillis()
|
||||
lastTrafficAt = lastReplyAt
|
||||
|
||||
unsubscribe = transport.addMessageListener { msg ->
|
||||
if (msg is GameMessage.PingMsg) {
|
||||
if (msg.reply) {
|
||||
if (msg.sessionId == sessionId) lastReplyAt = System.currentTimeMillis()
|
||||
} else {
|
||||
transport.send(GameMessage.PingMsg(reply = true, sessionId = msg.sessionId))
|
||||
}
|
||||
} else {
|
||||
lastTrafficAt = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
job = scope.launch {
|
||||
var wasConnected = false
|
||||
while (true) {
|
||||
delay(INTERVAL_MS)
|
||||
val connected = transport.connectionState.value == ConnectionState.CONNECTED
|
||||
if (!connected) {
|
||||
wasConnected = false
|
||||
continue
|
||||
}
|
||||
if (!wasConnected) {
|
||||
val now = System.currentTimeMillis()
|
||||
lastReplyAt = now
|
||||
lastTrafficAt = now
|
||||
wasConnected = true
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val silence = now - lastReplyAt
|
||||
|
||||
if (silence > TIMEOUT_MS && now - lastTrafficAt > TIMEOUT_MS) {
|
||||
NetworkLog.reason(
|
||||
transport.type.name,
|
||||
DisconnectReason.REMOTE_DISCONNECTED,
|
||||
"no reply for ${silence / 1000} s"
|
||||
)
|
||||
transport.disconnect()
|
||||
continue
|
||||
}
|
||||
|
||||
if (silence > TIMEOUT_MS) {
|
||||
NetworkLog.log(transport.type.name, "No heartbeat reply for ${silence / 1000} s, but data is flowing")
|
||||
}
|
||||
transport.send(GameMessage.PingMsg(reply = false, sessionId = sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
unsubscribe?.invoke()
|
||||
unsubscribe = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INTERVAL_MS = 5_000L
|
||||
|
||||
const val TIMEOUT_MS = 16_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import android.content.Context
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
|
||||
data class LanPeer(
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val playerId: String = "",
|
||||
val displayName: String = "",
|
||||
val elo: Int = 0,
|
||||
val fingerprint: String = "",
|
||||
|
||||
val signature: String = "",
|
||||
/** Raw NSD service name, used to remove the peer when the service is lost. */
|
||||
val serviceName: String = ""
|
||||
) {
|
||||
val id: String get() = "$host:$port"
|
||||
}
|
||||
|
||||
/**
|
||||
* LAN transport: plain TCP sockets carrying newline-delimited JSON, with the game
|
||||
* announced over NSD (mDNS). The listening socket stays open for discovery, but
|
||||
* incoming connections are accepted only while the user is hosting a game.
|
||||
*/
|
||||
class LanTransport(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope
|
||||
) : ConnectionTransport {
|
||||
companion object {
|
||||
// Wire identifiers from before the rename; changing them would split old and new installs.
|
||||
const val SERVICE_TYPE = "_chessapp._tcp."
|
||||
const val SERVICE_NAME = "ChessApp"
|
||||
|
||||
const val CONNECT_TIMEOUT_MS = 8_000
|
||||
|
||||
const val MAX_MESSAGE_CHARS = 1_400_000
|
||||
|
||||
const val ACCEPT_POLL_MS = 1_000
|
||||
|
||||
const val RESOLVE_RETRY_MS = 400L
|
||||
|
||||
fun presencePayload(playerId: String, name: String, elo: Int): String =
|
||||
"presence|$playerId|$name|$elo"
|
||||
}
|
||||
|
||||
override val type = TransportType.LAN
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
override val connectionState: StateFlow<ConnectionState> = _connectionState
|
||||
|
||||
private val nsdManager: NsdManager? =
|
||||
context.getSystemService(Context.NSD_SERVICE) as? NsdManager
|
||||
|
||||
private var serverSocket: ServerSocket? = null
|
||||
private var socket: Socket? = null
|
||||
|
||||
private val socketLock = Any()
|
||||
|
||||
private fun claimSocket(candidate: Socket): Boolean = synchronized(socketLock) {
|
||||
val existing = socket
|
||||
if (existing != null && !existing.isClosed) return false
|
||||
socket = candidate
|
||||
true
|
||||
}
|
||||
private var output: OutputStream? = null
|
||||
private val dispatcher = MessageDispatcher()
|
||||
|
||||
@Volatile private var hosting = false
|
||||
|
||||
private var registrationListener: NsdManager.RegistrationListener? = null
|
||||
|
||||
private var registeredServiceName: String? = null
|
||||
|
||||
private val ownServiceName: String by lazy {
|
||||
val fingerprint = cc.skysparkle.matewave.security.DeviceKeys.publicKeyFingerprint(context)
|
||||
?: cc.skysparkle.matewave.data.DeviceIdentity.deviceId(context).take(12)
|
||||
"$SERVICE_NAME-$fingerprint"
|
||||
}
|
||||
|
||||
fun fingerprintFromServiceName(serviceName: String): String? =
|
||||
serviceName.substringAfter("$SERVICE_NAME-", "").ifEmpty { null }
|
||||
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||
|
||||
@Volatile private var discoveryGeneration = 0
|
||||
|
||||
private val resolveQueue = java.util.ArrayDeque<Pair<Int, NsdServiceInfo>>()
|
||||
private var resolving = false
|
||||
|
||||
var lastError: String? = null
|
||||
private set
|
||||
|
||||
private val outbox = Channel<String>(capacity = Channel.UNLIMITED)
|
||||
private var writerStarted = false
|
||||
|
||||
override fun addMessageListener(listener: (GameMessage) -> Unit): () -> Unit =
|
||||
dispatcher.add(listener)
|
||||
|
||||
private fun announceAllowed(): Boolean =
|
||||
hosting || cc.skysparkle.matewave.presence.PresenceSettings(context).announceEnabled
|
||||
|
||||
fun applyAnnounceSetting() {
|
||||
val port = serverSocket?.localPort
|
||||
when {
|
||||
!announceAllowed() -> unregisterService()
|
||||
port != null -> registerService(port)
|
||||
else -> startPresence()
|
||||
}
|
||||
}
|
||||
|
||||
fun republishProfile() {
|
||||
val port = serverSocket?.localPort ?: return
|
||||
unregisterService()
|
||||
if (!announceAllowed()) return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
kotlinx.coroutines.delay(400)
|
||||
registerService(port)
|
||||
}
|
||||
}
|
||||
|
||||
private val presenceLock = Any()
|
||||
private var presenceStarting = false
|
||||
|
||||
fun startPresence() {
|
||||
synchronized(presenceLock) {
|
||||
if (serverSocket != null || presenceStarting) return
|
||||
presenceStarting = true
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val server = ServerSocket()
|
||||
server.reuseAddress = true
|
||||
|
||||
server.bind(InetSocketAddress(0))
|
||||
|
||||
server.soTimeout = ACCEPT_POLL_MS
|
||||
synchronized(presenceLock) { serverSocket = server; presenceStarting = false }
|
||||
if (announceAllowed()) registerService(server.localPort)
|
||||
acceptLoop(server)
|
||||
} catch (e: IOException) {
|
||||
synchronized(presenceLock) { presenceStarting = false }
|
||||
lastError = "Could not open port: ${e.message}"
|
||||
NetworkLog.reason("LAN", DisconnectReason.PORT_BUSY, e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acceptLoop(server: ServerSocket) {
|
||||
while (!server.isClosed) {
|
||||
try {
|
||||
val client = server.accept()
|
||||
if (!hosting) {
|
||||
try { client.close() } catch (_: IOException) {}
|
||||
continue
|
||||
}
|
||||
|
||||
if (socket != null && socket?.isClosed == false) {
|
||||
try { client.close() } catch (_: IOException) {}
|
||||
continue
|
||||
}
|
||||
client.tcpNoDelay = true
|
||||
client.keepAlive = true
|
||||
if (!claimSocket(client)) {
|
||||
try { client.close() } catch (_: IOException) {}
|
||||
continue
|
||||
}
|
||||
onConnected(client)
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
continue
|
||||
} catch (e: IOException) {
|
||||
if (server.isClosed) return
|
||||
NetworkLog.log("LAN", "Accept failed: ${e.message}")
|
||||
kotlinx.coroutines.delay(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startHosting() {
|
||||
hosting = true
|
||||
_connectionState.value = ConnectionState.CONNECTING
|
||||
lastError = null
|
||||
|
||||
val port = serverSocket?.localPort
|
||||
if (port != null) registerService(port) else startPresence()
|
||||
}
|
||||
|
||||
private fun onHostingEnded() {
|
||||
if (!announceAllowed()) unregisterService()
|
||||
}
|
||||
|
||||
private fun registerService(port: Int) {
|
||||
val nsd = nsdManager ?: return
|
||||
unregisterService()
|
||||
val info = NsdServiceInfo().apply {
|
||||
serviceName = ownServiceName
|
||||
serviceType = SERVICE_TYPE
|
||||
setPort(port)
|
||||
|
||||
val profile = cc.skysparkle.matewave.data.ProfileStore.get()
|
||||
runCatching {
|
||||
val shownName = profile.name.take(40)
|
||||
setAttribute("id", profile.userId)
|
||||
setAttribute("name", shownName)
|
||||
setAttribute("elo", profile.elo.toString())
|
||||
setAttribute("fp", cc.skysparkle.matewave.security.DeviceKeys.publicKeyFingerprint(context) ?: "")
|
||||
|
||||
cc.skysparkle.matewave.security.DeviceKeys.sign(
|
||||
context, presencePayload(profile.userId, shownName, profile.elo)
|
||||
)?.let { setAttribute("sig", it) }
|
||||
}
|
||||
}
|
||||
val listener = object : NsdManager.RegistrationListener {
|
||||
override fun onServiceRegistered(info: NsdServiceInfo?) {
|
||||
registeredServiceName = info?.serviceName
|
||||
}
|
||||
override fun onRegistrationFailed(info: NsdServiceInfo?, errorCode: Int) {
|
||||
lastError = "Network service registration failed (code $errorCode). Connect by address manually."
|
||||
}
|
||||
override fun onServiceUnregistered(info: NsdServiceInfo?) {}
|
||||
override fun onUnregistrationFailed(info: NsdServiceInfo?, errorCode: Int) {}
|
||||
}
|
||||
registrationListener = listener
|
||||
try {
|
||||
nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
} catch (e: Exception) {
|
||||
lastError = "Service registration: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterService() {
|
||||
val nsd = nsdManager ?: return
|
||||
registrationListener?.let {
|
||||
try { nsd.unregisterService(it) } catch (_: Exception) {}
|
||||
}
|
||||
registrationListener = null
|
||||
registeredServiceName = null
|
||||
}
|
||||
|
||||
fun discoverPeers(onFound: (List<LanPeer>) -> Unit) {
|
||||
val nsd = nsdManager ?: return
|
||||
stopDiscovery()
|
||||
discoveryGeneration += 1
|
||||
val myGeneration = discoveryGeneration
|
||||
val found = LinkedHashMap<String, LanPeer>()
|
||||
synchronized(resolveQueue) { resolveQueue.clear(); resolving = false }
|
||||
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onDiscoveryStarted(serviceType: String?) {}
|
||||
|
||||
override fun onServiceFound(info: NsdServiceInfo) {
|
||||
if (myGeneration != discoveryGeneration) return
|
||||
if (info.serviceType?.contains("chessapp") != true) return
|
||||
|
||||
if (isSelf(info.serviceName)) return
|
||||
|
||||
synchronized(resolveQueue) { resolveQueue.addLast(myGeneration to info) }
|
||||
drainResolveQueue(nsd, found, onFound)
|
||||
}
|
||||
|
||||
override fun onServiceLost(info: NsdServiceInfo?) {
|
||||
if (myGeneration != discoveryGeneration) return
|
||||
val name = info?.serviceName ?: return
|
||||
// Match the raw service name: LanPeer.name is a cleaned-up display form and never
|
||||
// matched, so peers who left stayed "online".
|
||||
val left = synchronized(found) { found.entries.removeAll { it.value.serviceName == name }; found.values.toList() }
|
||||
scope.launch(Dispatchers.Main) { onFound(left) }
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String?) {}
|
||||
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
lastError = "Network discovery failed (code $errorCode). Enter the address manually."
|
||||
NetworkLog.log("LAN", "Network discovery failed to start, code $errorCode")
|
||||
}
|
||||
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
|
||||
}
|
||||
discoveryListener = listener
|
||||
try {
|
||||
nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
} catch (e: Exception) {
|
||||
lastError = "Network discovery: ${e.message}"
|
||||
NetworkLog.log("LAN", "Network discovery: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.RequiresApi(34)
|
||||
private fun resolveModern(
|
||||
nsd: NsdManager,
|
||||
info: NsdServiceInfo,
|
||||
myGeneration: Int,
|
||||
found: LinkedHashMap<String, LanPeer>,
|
||||
onFound: (List<LanPeer>) -> Unit,
|
||||
finish: () -> Unit
|
||||
) {
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
var done = false
|
||||
val callback = object : NsdManager.ServiceInfoCallback {
|
||||
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
|
||||
NetworkLog.log("LAN", "Could not get peer address, code $errorCode")
|
||||
executor.shutdown(); finish()
|
||||
}
|
||||
|
||||
override fun onServiceUpdated(updated: NsdServiceInfo) {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
if (myGeneration == discoveryGeneration) {
|
||||
val host = hostAddressOf(updated)
|
||||
if (host != null && !isSelfEndpoint(host, updated.port)) {
|
||||
val peer = peerFrom(updated, host)
|
||||
val now = synchronized(found) { found[peer.id] = peer; found.values.toList() }
|
||||
scope.launch(Dispatchers.Main) { onFound(now) }
|
||||
}
|
||||
}
|
||||
try { nsd.unregisterServiceInfoCallback(this) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun onServiceLost() {
|
||||
if (myGeneration != discoveryGeneration) return
|
||||
val left = synchronized(found) { found.entries.removeAll { it.value.serviceName == info.serviceName }; found.values.toList() }
|
||||
scope.launch(Dispatchers.Main) { onFound(left) }
|
||||
}
|
||||
|
||||
override fun onServiceInfoCallbackUnregistered() {
|
||||
executor.shutdown(); finish()
|
||||
}
|
||||
}
|
||||
try {
|
||||
nsd.registerServiceInfoCallback(info, executor, callback)
|
||||
} catch (e: Exception) {
|
||||
NetworkLog.log("LAN", "Address resolution: ${e.message}")
|
||||
executor.shutdown(); finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun peerFrom(info: NsdServiceInfo, host: String): LanPeer {
|
||||
fun attr(key: String): String = runCatching {
|
||||
info.attributes[key]?.toString(Charsets.UTF_8) ?: ""
|
||||
}.getOrDefault("")
|
||||
return LanPeer(
|
||||
name = displayNameOf(info.serviceName),
|
||||
host = host,
|
||||
port = info.port,
|
||||
playerId = attr("id"),
|
||||
displayName = attr("name"),
|
||||
elo = attr("elo").toIntOrNull() ?: 0,
|
||||
|
||||
fingerprint = attr("fp").ifEmpty { fingerprintFromServiceName(info.serviceName ?: "") ?: "" },
|
||||
signature = attr("sig"),
|
||||
serviceName = info.serviceName ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
private fun displayNameOf(serviceName: String?): String {
|
||||
val raw = serviceName ?: return SERVICE_NAME
|
||||
return raw.substringBefore("-").ifBlank { SERVICE_NAME }
|
||||
}
|
||||
|
||||
private fun isSelf(serviceName: String?): Boolean {
|
||||
if (serviceName == null) return false
|
||||
registeredServiceName?.let { if (serviceName == it) return true }
|
||||
|
||||
if (serviceName == ownServiceName) return true
|
||||
|
||||
return Regex("^${Regex.escape(ownServiceName)} \\(\\d+\\)$").matches(serviceName)
|
||||
}
|
||||
|
||||
private fun isSelfEndpoint(host: String, port: Int): Boolean {
|
||||
val ourPort = serverSocket?.localPort ?: return false
|
||||
if (port != ourPort) return false
|
||||
|
||||
val ourAddress = localIpAddress()
|
||||
return ourAddress == null || ourAddress == host
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun hostAddressOf(info: NsdServiceInfo): String? {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
||||
val ipv4 = info.hostAddresses.firstOrNull { it is java.net.Inet4Address }
|
||||
if (ipv4 != null) return ipv4.hostAddress
|
||||
return info.hostAddresses.firstOrNull()?.hostAddress
|
||||
}
|
||||
return info.host?.hostAddress
|
||||
}
|
||||
|
||||
private fun drainResolveQueue(
|
||||
nsd: NsdManager,
|
||||
found: LinkedHashMap<String, LanPeer>,
|
||||
onFound: (List<LanPeer>) -> Unit
|
||||
) {
|
||||
val nextGeneration: Int
|
||||
val next: NsdServiceInfo
|
||||
synchronized(resolveQueue) {
|
||||
if (resolving) return
|
||||
val entry = resolveQueue.pollFirst() ?: return
|
||||
nextGeneration = entry.first
|
||||
next = entry.second
|
||||
resolving = true
|
||||
}
|
||||
|
||||
val finish = {
|
||||
synchronized(resolveQueue) { resolving = false }
|
||||
drainResolveQueue(nsd, found, onFound)
|
||||
}
|
||||
|
||||
if (nextGeneration != discoveryGeneration) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= 34) {
|
||||
resolveModern(nsd, next, nextGeneration, found, onFound, finish)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
nsd.resolveService(next, object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(info: NsdServiceInfo?, errorCode: Int) {
|
||||
if (errorCode == NsdManager.FAILURE_ALREADY_ACTIVE) {
|
||||
synchronized(resolveQueue) { resolveQueue.addLast(nextGeneration to next) }
|
||||
} else {
|
||||
NetworkLog.log("LAN", "Could not resolve peer address, code $errorCode")
|
||||
}
|
||||
scope.launch { kotlinx.coroutines.delay(RESOLVE_RETRY_MS); finish() }
|
||||
}
|
||||
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
if (nextGeneration == discoveryGeneration) {
|
||||
val host = hostAddressOf(resolved)
|
||||
if (host != null && !isSelfEndpoint(host, resolved.port)) {
|
||||
val peer = peerFrom(resolved, host)
|
||||
val now = synchronized(found) { found[peer.id] = peer; found.values.toList() }
|
||||
scope.launch(Dispatchers.Main) { onFound(now) }
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
NetworkLog.log("LAN", "Address resolution: ${e.message}")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun stopDiscovery() {
|
||||
discoveryGeneration += 1
|
||||
val nsd = nsdManager ?: return
|
||||
discoveryListener?.let {
|
||||
try { nsd.stopServiceDiscovery(it) } catch (_: Exception) {}
|
||||
}
|
||||
discoveryListener = null
|
||||
}
|
||||
|
||||
override fun startDiscoveryAndConnect(targetId: String?) {
|
||||
if (targetId.isNullOrBlank()) {
|
||||
_connectionState.value = ConnectionState.FAILED
|
||||
return
|
||||
}
|
||||
val host = targetId.substringBeforeLast(':', targetId).trim()
|
||||
|
||||
val port = targetId.substringAfterLast(':', "").toIntOrNull() ?: run {
|
||||
lastError = "Address must look like 192.168.1.5:port"
|
||||
NetworkLog.reason("LAN", DisconnectReason.PEER_NOT_FOUND)
|
||||
_connectionState.value = ConnectionState.FAILED
|
||||
return
|
||||
}
|
||||
|
||||
_connectionState.value = ConnectionState.CONNECTING
|
||||
lastError = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val s = Socket()
|
||||
|
||||
s.tcpNoDelay = true
|
||||
|
||||
s.keepAlive = true
|
||||
s.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
|
||||
if (!claimSocket(s)) {
|
||||
// Another connection won the race; report its state instead of hanging here.
|
||||
try { s.close() } catch (_: IOException) {}
|
||||
_connectionState.value =
|
||||
if (socket?.isClosed == false) ConnectionState.CONNECTED else ConnectionState.FAILED
|
||||
return@launch
|
||||
}
|
||||
onConnected(s)
|
||||
} catch (e: IOException) {
|
||||
lastError = "Could not connect to $host:$port: ${e.message}"
|
||||
NetworkLog.reason("LAN", DisconnectReason.CONNECT_TIMEOUT, "$host:$port")
|
||||
_connectionState.value = ConnectionState.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onConnected(sock: Socket) {
|
||||
output = sock.getOutputStream()
|
||||
startWriterIfNeeded()
|
||||
_connectionState.value = ConnectionState.CONNECTED
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
|
||||
while (true) {
|
||||
val read = readBoundedLine(reader)
|
||||
if (read is LineResult.EndOfStream) break
|
||||
if (sock !== socket) break
|
||||
if (read is LineResult.TooLong) {
|
||||
NetworkLog.reason("LAN", DisconnectReason.MESSAGE_TOO_LARGE)
|
||||
break
|
||||
}
|
||||
val line = (read as LineResult.Line).text
|
||||
try {
|
||||
dispatcher.dispatch(GameMessage.fromJson(line))
|
||||
} catch (e: Exception) {
|
||||
NetworkLog.log("LAN", "Malformed message skipped")
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
} finally {
|
||||
releaseConnection(sock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseConnection(sock: Socket) {
|
||||
try { sock.close() } catch (_: IOException) {}
|
||||
val wasCurrent = synchronized(socketLock) {
|
||||
if (sock === socket) {
|
||||
socket = null
|
||||
output = null
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (wasCurrent) {
|
||||
hosting = false
|
||||
onHostingEnded()
|
||||
_connectionState.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
private fun startWriterIfNeeded() {
|
||||
if (writerStarted) return
|
||||
writerStarted = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
for (message in outbox) {
|
||||
val currentSocket = socket
|
||||
val currentOutput = output
|
||||
if (currentSocket == null || currentOutput == null || currentSocket.isClosed) {
|
||||
NetworkLog.log("LAN", "Message not sent: no connection")
|
||||
continue
|
||||
}
|
||||
try {
|
||||
currentOutput.write((message + "\n").toByteArray(Charsets.UTF_8))
|
||||
currentOutput.flush()
|
||||
} catch (e: IOException) {
|
||||
releaseConnection(currentSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LineResult {
|
||||
data class Line(val text: String) : LineResult()
|
||||
object TooLong : LineResult()
|
||||
object EndOfStream : LineResult()
|
||||
}
|
||||
|
||||
private fun readBoundedLine(reader: BufferedReader): LineResult {
|
||||
val builder = StringBuilder()
|
||||
while (true) {
|
||||
val ch = reader.read()
|
||||
if (ch == -1) {
|
||||
return if (builder.isEmpty()) LineResult.EndOfStream
|
||||
else LineResult.Line(builder.toString())
|
||||
}
|
||||
if (ch == '\n'.code) return LineResult.Line(builder.toString())
|
||||
if (ch == '\r'.code) continue
|
||||
if (builder.length >= MAX_MESSAGE_CHARS) return LineResult.TooLong
|
||||
builder.append(ch.toChar())
|
||||
}
|
||||
}
|
||||
|
||||
override fun send(message: GameMessage) {
|
||||
val json = message.toJson()
|
||||
val size = json.length
|
||||
if (size > MAX_MESSAGE_CHARS) {
|
||||
NetworkLog.reason("LAN", DisconnectReason.MESSAGE_TOO_LARGE, "$size chars")
|
||||
return
|
||||
}
|
||||
outbox.trySend(json)
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
val wasHosting = hosting
|
||||
hosting = false
|
||||
if (wasHosting) onHostingEnded()
|
||||
|
||||
synchronized(socketLock) {
|
||||
try { socket?.close() } catch (_: IOException) {}
|
||||
socket = null
|
||||
output = null
|
||||
}
|
||||
|
||||
var dropped = 0
|
||||
while (outbox.tryReceive().isSuccess) dropped++
|
||||
if (dropped > 0) {
|
||||
repeat(dropped) { NetworkMetrics.messageDropped() }
|
||||
NetworkLog.log("LAN", "Dropped unsent messages: $dropped")
|
||||
}
|
||||
_connectionState.value = ConnectionState.DISCONNECTED
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
stopDiscovery()
|
||||
unregisterService()
|
||||
disconnect()
|
||||
try { serverSocket?.close() } catch (_: IOException) {}
|
||||
synchronized(presenceLock) { serverSocket = null; presenceStarting = false }
|
||||
}
|
||||
|
||||
private fun localIpAddress(): String? {
|
||||
try {
|
||||
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||
@Suppress("DEPRECATION")
|
||||
val ip = wifi?.connectionInfo?.ipAddress ?: 0
|
||||
if (ip != 0) {
|
||||
return "${ip and 0xff}.${ip shr 8 and 0xff}.${ip shr 16 and 0xff}.${ip shr 24 and 0xff}"
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
try {
|
||||
for (nif in NetworkInterface.getNetworkInterfaces()) {
|
||||
if (!nif.isUp || nif.isLoopback) continue
|
||||
val name = nif.name.lowercase()
|
||||
if (name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("rmnet")) continue
|
||||
for (addr in nif.inetAddresses) {
|
||||
if (!addr.isLoopbackAddress && addr.hostAddress?.contains(':') == false) {
|
||||
return addr.hostAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class MessageDispatcher {
|
||||
private val listeners = CopyOnWriteArrayList<(GameMessage) -> Unit>()
|
||||
|
||||
fun add(listener: (GameMessage) -> Unit): () -> Unit {
|
||||
listeners.add(listener)
|
||||
return { listeners.remove(listener) }
|
||||
}
|
||||
|
||||
fun dispatch(message: GameMessage) {
|
||||
for (listener in listeners) {
|
||||
try {
|
||||
listener(message)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
listeners.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
enum class DisconnectReason(val userText: String, val textRes: Int) {
|
||||
NONE("—", cc.skysparkle.matewave.R.string.reason_unknown),
|
||||
PERMISSION_DENIED("No nearby devices permission", cc.skysparkle.matewave.R.string.reason_permission_denied),
|
||||
BLUETOOTH_OFF("Bluetooth is off", cc.skysparkle.matewave.R.string.reason_bluetooth_off),
|
||||
WIFI_UNAVAILABLE("Phone is not connected to Wi-Fi", cc.skysparkle.matewave.R.string.reason_wifi_unavailable),
|
||||
PEER_NOT_FOUND("Opponent not found", cc.skysparkle.matewave.R.string.reason_peer_not_found),
|
||||
CONNECT_TIMEOUT("Opponent did not respond in time", cc.skysparkle.matewave.R.string.reason_connect_timeout),
|
||||
SERVICE_NOT_FOUND("The other device does not have this app", cc.skysparkle.matewave.R.string.reason_service_not_found),
|
||||
SUBSCRIBE_FAILED("Could not enable data reception", cc.skysparkle.matewave.R.string.reason_subscribe_failed),
|
||||
WRITE_FAILED("Could not send data", cc.skysparkle.matewave.R.string.reason_write_failed),
|
||||
MESSAGE_TOO_LARGE("Received message is too large", cc.skysparkle.matewave.R.string.reason_message_too_large),
|
||||
REMOTE_DISCONNECTED("Opponent disconnected", cc.skysparkle.matewave.R.string.reason_remote_disconnected),
|
||||
PORT_BUSY("Could not open port", cc.skysparkle.matewave.R.string.reason_port_busy),
|
||||
UNKNOWN("Unknown connection error", cc.skysparkle.matewave.R.string.reason_unknown)
|
||||
}
|
||||
|
||||
data class NetworkEvent(
|
||||
val at: Long,
|
||||
val transport: String,
|
||||
val text: String
|
||||
)
|
||||
|
||||
/** In-memory connection log, copied into the diagnostics report from Settings. */
|
||||
object NetworkLog {
|
||||
private const val CAPACITY = 60
|
||||
|
||||
private val _events = MutableStateFlow<List<NetworkEvent>>(emptyList())
|
||||
|
||||
private val _lastReason = MutableStateFlow(DisconnectReason.NONE)
|
||||
val lastReason: StateFlow<DisconnectReason> = _lastReason
|
||||
|
||||
fun log(transport: String, text: String) {
|
||||
val event = NetworkEvent(System.currentTimeMillis(), transport, text)
|
||||
_events.update { (it + event).takeLast(CAPACITY) }
|
||||
}
|
||||
|
||||
fun reason(transport: String, reason: DisconnectReason, detail: String? = null) {
|
||||
_lastReason.value = reason
|
||||
log(transport, reason.userText + (detail?.let { ": $it" } ?: ""))
|
||||
}
|
||||
|
||||
fun asText(): String = _events.value.joinToString("\n") { e ->
|
||||
val time = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
|
||||
.format(java.util.Date(e.at))
|
||||
"$time [${e.transport}] ${e.text}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
object NetworkMetrics {
|
||||
private val connectAttempts = AtomicInteger()
|
||||
private val connectSuccesses = AtomicInteger()
|
||||
|
||||
private val chunksSent = AtomicLong()
|
||||
private val chunkRetries = AtomicLong()
|
||||
private val duplicateChunks = AtomicLong()
|
||||
private val corruptedMessages = AtomicLong()
|
||||
private val messagesDropped = AtomicLong()
|
||||
|
||||
private val lastConnectMillis = AtomicLong()
|
||||
|
||||
private val _updated = MutableStateFlow(0)
|
||||
|
||||
val updated: StateFlow<Int> = _updated
|
||||
|
||||
private fun touch() { _updated.update { it + 1 } }
|
||||
|
||||
fun connectStarted() { connectAttempts.incrementAndGet(); touch() }
|
||||
|
||||
fun connectSucceeded(millis: Long) {
|
||||
connectSuccesses.incrementAndGet()
|
||||
lastConnectMillis.set(millis)
|
||||
touch()
|
||||
}
|
||||
|
||||
fun chunkSent() { chunksSent.incrementAndGet() }
|
||||
fun chunkRetried(count: Int) { chunkRetries.addAndGet(count.toLong()); touch() }
|
||||
fun duplicateChunk() { duplicateChunks.incrementAndGet() }
|
||||
fun messageCorrupted() { corruptedMessages.incrementAndGet(); touch() }
|
||||
fun messageDropped() { messagesDropped.incrementAndGet(); touch() }
|
||||
|
||||
private fun retryPercent(): Int {
|
||||
val sent = chunksSent.get()
|
||||
if (sent == 0L) return 0
|
||||
return ((chunkRetries.get() * 100) / sent).toInt()
|
||||
}
|
||||
|
||||
fun asText(res: android.content.res.Resources): String = buildString {
|
||||
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_connect_attempts,
|
||||
connectAttempts.get(), connectSuccesses.get()))
|
||||
val last = lastConnectMillis.get()
|
||||
if (last > 0) appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_last_connect, last))
|
||||
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_chunks,
|
||||
chunksSent.get(), chunkRetries.get(), retryPercent()))
|
||||
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_duplicates, duplicateChunks.get()))
|
||||
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_corrupted, corruptedMessages.get()))
|
||||
appendLine(res.getString(cc.skysparkle.matewave.R.string.metrics_dropped, messagesDropped.get()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
data class NetworkStatus(
|
||||
val bluetoothSupported: Boolean,
|
||||
val bluetoothOn: Boolean,
|
||||
val wifiConnected: Boolean,
|
||||
val permissionsGranted: Boolean,
|
||||
val notificationsGranted: Boolean
|
||||
) {
|
||||
val bluetoothReady: Boolean
|
||||
get() = bluetoothSupported && bluetoothOn && permissionsGranted
|
||||
|
||||
val lanReady: Boolean get() = wifiConnected
|
||||
|
||||
val anyChannelReady: Boolean get() = bluetoothReady || lanReady
|
||||
|
||||
val onlineBlocked: Boolean get() = !anyChannelReady
|
||||
}
|
||||
|
||||
object NetworkRequirements {
|
||||
fun check(context: Context): NetworkStatus {
|
||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
|
||||
val adapter = manager?.adapter
|
||||
val supported = adapter != null &&
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
|
||||
val on = try { adapter?.isEnabled == true } catch (e: SecurityException) { false }
|
||||
|
||||
return NetworkStatus(
|
||||
bluetoothSupported = supported,
|
||||
bluetoothOn = on,
|
||||
wifiConnected = isWifiConnected(context),
|
||||
permissionsGranted = AppPermissions.nearby.all { granted(context, it) },
|
||||
notificationsGranted = notificationsGranted(context)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isWifiConnected(context: Context): Boolean {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
|
||||
return caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
}
|
||||
|
||||
private fun notificationsGranted(context: Context): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
granted(context, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else true
|
||||
|
||||
private fun granted(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cc.skysparkle.matewave.network
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/** Process-wide transport instances shared by presence and games. */
|
||||
object Transports {
|
||||
private val scope = CoroutineScope(SupervisorJob())
|
||||
|
||||
@Volatile private var ble: BleTransport? = null
|
||||
@Volatile private var lan: LanTransport? = null
|
||||
|
||||
fun ble(context: Context): BleTransport =
|
||||
ble ?: synchronized(this) {
|
||||
ble ?: BleTransport(context.applicationContext, scope).also { ble = it }
|
||||
}
|
||||
|
||||
fun lan(context: Context): LanTransport =
|
||||
lan ?: synchronized(this) {
|
||||
lan ?: LanTransport(context.applicationContext, scope).also { lan = it }
|
||||
}
|
||||
|
||||
fun applyAnnounceSetting() {
|
||||
lan?.applyAnnounceSetting()
|
||||
ble?.applyAnnounceSetting()
|
||||
}
|
||||
|
||||
fun releaseForGame() {
|
||||
ble?.stopDiscovery()
|
||||
lan?.stopDiscovery()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cc.skysparkle.matewave.openings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
data class OpeningName(val eco: String, val name: String)
|
||||
|
||||
/**
|
||||
* Opening theory from the Lichess game database (May 2013, CC0), used to tell book moves from
|
||||
* deviations, and opening names from lichess-org/chess-openings (CC0). Both are keyed by SAN move
|
||||
* sequences from the standard starting position, so no position hashing is needed at runtime.
|
||||
*
|
||||
* explorer.txt: header "#games N", then one node per line "parentId san white draws black";
|
||||
* node ids are line numbers (the root is 0) and parents always come before children. Only the
|
||||
* tree shape is used here.
|
||||
*/
|
||||
object OpeningBook {
|
||||
|
||||
private class Data(
|
||||
val san: Array<String>,
|
||||
val children: Array<IntArray>,
|
||||
val names: Map<String, OpeningName>,
|
||||
/** Length in plies of the longest named line; longer prefixes are never looked up. */
|
||||
val longestName: Int
|
||||
)
|
||||
|
||||
@Volatile private var data: Data? = null
|
||||
|
||||
/** Reads both files; call from a background thread. Safe to call repeatedly. */
|
||||
fun load(context: Context) {
|
||||
if (data != null) return
|
||||
synchronized(this) {
|
||||
if (data != null) return
|
||||
val assets = context.applicationContext.assets
|
||||
val parents = ArrayList<Int>(40_000)
|
||||
val sans = ArrayList<String>(40_000)
|
||||
parents.add(-1); sans.add("")
|
||||
assets.open("openings/explorer.txt").bufferedReader(Charsets.UTF_8).useLines { lines ->
|
||||
for (line in lines) {
|
||||
if (line.isBlank() || line.startsWith("#")) continue
|
||||
val f = line.split(' ')
|
||||
if (f.size < 2) continue
|
||||
parents.add(f[0].toInt()); sans.add(f[1])
|
||||
}
|
||||
}
|
||||
val childLists = Array(parents.size) { ArrayList<Int>(0) }
|
||||
for (i in 1 until parents.size) childLists[parents[i]].add(i)
|
||||
|
||||
val names = HashMap<String, OpeningName>(4_000)
|
||||
var longest = 0
|
||||
assets.open("openings/names.tsv").bufferedReader(Charsets.UTF_8).useLines { lines ->
|
||||
for (line in lines) {
|
||||
val f = line.split('\t')
|
||||
if (f.size < 3) continue
|
||||
names.putIfAbsent(f[2], OpeningName(f[0], f[1]))
|
||||
longest = maxOf(longest, f[2].count { it == ' ' } + 1)
|
||||
}
|
||||
}
|
||||
data = Data(
|
||||
san = sans.toTypedArray(),
|
||||
children = Array(childLists.size) { childLists[it].toIntArray() },
|
||||
names = names,
|
||||
longestName = longest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of leading moves of [path] that are opening theory: played in the game database
|
||||
* or part of a named opening line.
|
||||
*/
|
||||
fun bookDepth(path: List<String>): Int {
|
||||
val d = data ?: return 0
|
||||
var current = 0
|
||||
var depth = 0
|
||||
for (san in path) {
|
||||
current = d.children[current].firstOrNull { d.san[it] == san } ?: break
|
||||
depth++
|
||||
}
|
||||
for (len in minOf(path.size, d.longestName) downTo depth + 1) {
|
||||
if (d.names.containsKey(path.subList(0, len).joinToString(" "))) return len
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
/** Deepest ply covered by the game database; beyond it "out of book" cannot be judged. */
|
||||
const val MAX_TREE_PLY = 20
|
||||
|
||||
/** Name of the most specific known opening along [path]. */
|
||||
fun openingName(path: List<String>): OpeningName? {
|
||||
val d = data ?: return null
|
||||
for (len in minOf(path.size, d.longestName) downTo 1) {
|
||||
d.names[path.subList(0, len).joinToString(" ")]?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package cc.skysparkle.matewave.openings
|
||||
|
||||
import android.content.Context
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.engine.Board
|
||||
import cc.skysparkle.matewave.engine.ChessEngine
|
||||
import cc.skysparkle.matewave.engine.Move
|
||||
import cc.skysparkle.matewave.engine.Notation
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
|
||||
/** An opening line to learn, played from [side]'s point of view. */
|
||||
data class OpeningCourse(
|
||||
val id: String,
|
||||
val side: PieceColor,
|
||||
val eco: String,
|
||||
val name: String,
|
||||
val sans: List<String>,
|
||||
val moves: List<Move>
|
||||
) {
|
||||
fun isLearnerMove(ply: Int): Boolean = (ply % 2 == 0) == (side == PieceColor.WHITE)
|
||||
}
|
||||
|
||||
/** Artwork for the course card. */
|
||||
fun OpeningCourse.artRes(): Int = when (id) {
|
||||
"Sicilian Defense: Najdorf Variation" -> R.drawable.art_najdorf
|
||||
"Sicilian Defense: Dragon Variation" -> R.drawable.art_dragon
|
||||
"French Defense: Advance Variation" -> R.drawable.art_french_advance
|
||||
"Caro-Kann Defense: Classical Variation" -> R.drawable.art_caro_kann_classical
|
||||
"King's Indian Defense: Orthodox Variation" -> R.drawable.art_kings_indian
|
||||
"Grünfeld Defense: Exchange Variation" -> R.drawable.art_grunfeld
|
||||
"Italian Game: Giuoco Piano" -> R.drawable.art_italian
|
||||
"Ruy Lopez: Closed" -> R.drawable.art_ruy_lopez
|
||||
"King's Gambit Accepted: Kieseritzky Gambit" -> R.drawable.art_kings_gambit
|
||||
"Queen's Gambit Declined: Orthodox Defense" -> R.drawable.art_qgd_orthodox
|
||||
"London System" -> R.drawable.art_london
|
||||
"Catalan Opening: Open Defense" -> R.drawable.art_catalan
|
||||
"Caro-Kann Defense: Advance Variation" -> R.drawable.art_caro_kann_advance
|
||||
"Scandinavian Defense: Main Line" -> R.drawable.art_scandinavian
|
||||
"Pirc Defense: Classical Variation" -> R.drawable.art_pirc
|
||||
"Petrov's Defense: Classical Attack" -> R.drawable.art_petrov
|
||||
"Slav Defense: Czech Variation" -> R.drawable.art_slav
|
||||
"Nimzo-Indian Defense: Classical Variation" -> R.drawable.art_nimzo_indian
|
||||
"Dutch Defense: Leningrad Variation" -> R.drawable.art_dutch
|
||||
"Queen's Gambit Declined: Tarrasch Defense" -> R.drawable.art_tarrasch
|
||||
"Scotch Game" -> R.drawable.art_scotch
|
||||
"Vienna Game: Vienna Gambit" -> R.drawable.art_vienna
|
||||
"English Opening: King's English Variation" -> R.drawable.art_english
|
||||
"Queen's Gambit Accepted: Classical Defense" -> R.drawable.art_qga
|
||||
else -> R.drawable.ic_book
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated main lines (assets/openings/courses.tsv: side, ECO, name, SAN line). Every line
|
||||
* ends with the learner's move and was checked for legality when the file was built.
|
||||
*/
|
||||
object OpeningCourses {
|
||||
@Volatile private var cached: List<OpeningCourse>? = null
|
||||
|
||||
fun load(context: Context): List<OpeningCourse> {
|
||||
cached?.let { return it }
|
||||
synchronized(this) {
|
||||
cached?.let { return it }
|
||||
val result = context.applicationContext.assets.open("openings/courses.tsv")
|
||||
.bufferedReader(Charsets.UTF_8).useLines { lines ->
|
||||
lines.filter { it.isNotBlank() }.mapNotNull { line ->
|
||||
val f = line.split('\t')
|
||||
if (f.size < 4) return@mapNotNull null
|
||||
val sans = f[3].split(' ')
|
||||
val moves = toMoves(sans) ?: return@mapNotNull null
|
||||
OpeningCourse(
|
||||
id = f[2],
|
||||
side = if (f[0] == "W") PieceColor.WHITE else PieceColor.BLACK,
|
||||
eco = f[1],
|
||||
name = f[2],
|
||||
sans = sans,
|
||||
moves = moves
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
cached = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun toMoves(sans: List<String>): List<Move>? {
|
||||
var board = Board.startPosition()
|
||||
val moves = ArrayList<Move>(sans.size)
|
||||
for (san in sans) {
|
||||
val move = Notation.fromSan(board, san) ?: return null
|
||||
moves.add(move)
|
||||
board = ChessEngine.applyMove(board, move)
|
||||
}
|
||||
return moves
|
||||
}
|
||||
}
|
||||
|
||||
enum class CourseStatus { NEW, LOCKED, LEARNING, DUE, WAITING }
|
||||
|
||||
/** Stars before and after a review, and flawless reviews still needed for the next star. */
|
||||
data class StarChange(val before: Int, val after: Int, val reviewsToNext: Int)
|
||||
|
||||
/** A course with its spaced-repetition state at a given moment. */
|
||||
data class CourseInfo(
|
||||
val course: OpeningCourse,
|
||||
val status: CourseStatus,
|
||||
/** Learner moves already learned (level 1 or higher). */
|
||||
val learned: Int,
|
||||
val total: Int,
|
||||
val dueCards: Int,
|
||||
val nextDueAt: Long?,
|
||||
/** 0..3: 1 = fully learned, then one star per [OpeningSrs.REVIEWS_PER_STAR] flawless reviews. */
|
||||
val stars: Int,
|
||||
/** Flawless reviews still needed for the next star; 0 at three stars. */
|
||||
val reviewsToNext: Int = 0
|
||||
) {
|
||||
val mastered: Boolean get() = stars >= 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Spaced repetition for opening lines. Every learner move of a course is a card with a level
|
||||
* and a due time. A correct first answer on a due card raises its level; a wrong answer sends
|
||||
* that card back to level 1. Intervals grow from 4 hours to 90 days.
|
||||
*/
|
||||
class OpeningSrs(private val store: CardStore) {
|
||||
|
||||
/** Where cards are kept: SharedPreferences in the app, a map in tests. */
|
||||
interface CardStore {
|
||||
fun get(key: String): String?
|
||||
fun put(key: String, value: String)
|
||||
}
|
||||
|
||||
constructor(context: Context) : this(object : CardStore {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("opening_srs", Context.MODE_PRIVATE)
|
||||
override fun get(key: String): String? = prefs.getString(key, null)
|
||||
override fun put(key: String, value: String) { prefs.edit().putString(key, value).apply() }
|
||||
})
|
||||
|
||||
private fun key(course: OpeningCourse, ply: Int) = "${course.id}|$ply"
|
||||
|
||||
fun level(course: OpeningCourse, ply: Int): Int = read(course, ply).first
|
||||
|
||||
fun isDue(course: OpeningCourse, ply: Int, now: Long): Boolean {
|
||||
val (level, due) = read(course, ply)
|
||||
return level > 0 && due <= now
|
||||
}
|
||||
|
||||
private fun read(course: OpeningCourse, ply: Int): Pair<Int, Long> {
|
||||
val raw = store.get(key(course, ply)) ?: return 0 to 0L
|
||||
val parts = raw.split(',')
|
||||
return (parts.getOrNull(0)?.toIntOrNull() ?: 0) to (parts.getOrNull(1)?.toLongOrNull() ?: 0L)
|
||||
}
|
||||
|
||||
private fun write(course: OpeningCourse, ply: Int, level: Int, due: Long) {
|
||||
store.put(key(course, ply), "$level,$due")
|
||||
}
|
||||
|
||||
/** First correct play of a new move. */
|
||||
fun learn(course: OpeningCourse, ply: Int, now: Long) {
|
||||
if (level(course, ply) == 0) write(course, ply, 1, now + INTERVALS[1])
|
||||
}
|
||||
|
||||
/** Correct first answer. Only a due card moves up: practising early does not skip reviews. */
|
||||
fun success(course: OpeningCourse, ply: Int, now: Long) {
|
||||
val (level, due) = read(course, ply)
|
||||
if (level == 0 || due > now) return
|
||||
val next = minOf(MAX_LEVEL, level + 1)
|
||||
write(course, ply, next, now + INTERVALS[next])
|
||||
}
|
||||
|
||||
fun fail(course: OpeningCourse, ply: Int, now: Long) {
|
||||
if (level(course, ply) == 0) return
|
||||
write(course, ply, 1, now + RELEARN_MS)
|
||||
}
|
||||
|
||||
private fun reviewsKey(course: OpeningCourse) = "${course.id}|reviews"
|
||||
|
||||
private fun reviewCount(course: OpeningCourse): Int = store.get(reviewsKey(course))?.toIntOrNull() ?: 0
|
||||
|
||||
private fun starsFor(reviews: Int): Int = 1 + minOf(2, reviews / REVIEWS_PER_STAR)
|
||||
|
||||
private fun reviewsToNext(reviews: Int): Int =
|
||||
if (reviews >= 2 * REVIEWS_PER_STAR) 0 else REVIEWS_PER_STAR - reviews % REVIEWS_PER_STAR
|
||||
|
||||
/**
|
||||
* Records a finished review of due moves. A flawless one counts towards the next star; a
|
||||
* mistake or hint takes one star away (the first star, "learned", is never lost) and the
|
||||
* lost star has to be earned again from the start.
|
||||
*/
|
||||
fun recordReview(course: OpeningCourse, flawless: Boolean): StarChange {
|
||||
val reviews = reviewCount(course)
|
||||
val before = starsFor(reviews)
|
||||
val after = if (flawless) {
|
||||
minOf(reviews + 1, 2 * REVIEWS_PER_STAR)
|
||||
} else {
|
||||
// Back to the start of the previous star.
|
||||
maxOf(0, (before - 2) * REVIEWS_PER_STAR)
|
||||
}
|
||||
store.put(reviewsKey(course), after.toString())
|
||||
return StarChange(before, starsFor(after), reviewsToNext(after))
|
||||
}
|
||||
|
||||
fun info(course: OpeningCourse, now: Long, anotherInProgress: Boolean): CourseInfo {
|
||||
val plies = course.moves.indices.filter { course.isLearnerMove(it) }
|
||||
val cards = plies.map { read(course, it) }
|
||||
val learned = cards.count { it.first > 0 }
|
||||
val due = cards.count { it.first > 0 && it.second <= now }
|
||||
val nextDue = cards.filter { it.first > 0 }.minOfOrNull { it.second }
|
||||
val reviews = reviewCount(course)
|
||||
val stars = if (learned < plies.size) 0 else starsFor(reviews)
|
||||
val status = when {
|
||||
learned == 0 && anotherInProgress -> CourseStatus.LOCKED
|
||||
learned == 0 -> CourseStatus.NEW
|
||||
learned < plies.size -> CourseStatus.LEARNING
|
||||
due > 0 -> CourseStatus.DUE
|
||||
else -> CourseStatus.WAITING
|
||||
}
|
||||
return CourseInfo(course, status, learned, plies.size, due, nextDue, stars, if (stars == 0) 0 else reviewsToNext(reviews))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_LEVEL = 7
|
||||
/** Flawless reviews needed for each of the second and third stars. */
|
||||
const val REVIEWS_PER_STAR = 3
|
||||
private const val HOUR = 3_600_000L
|
||||
private const val DAY = 24 * HOUR
|
||||
/** Interval after reaching each level (index = level). */
|
||||
val INTERVALS = longArrayOf(0, 4 * HOUR, DAY, 3 * DAY, 7 * DAY, 16 * DAY, 35 * DAY, 90 * DAY)
|
||||
const val RELEARN_MS = 10 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package cc.skysparkle.matewave.presence
|
||||
|
||||
import android.content.Context
|
||||
import cc.skysparkle.matewave.data.FriendsStore
|
||||
import cc.skysparkle.matewave.network.LanPeer
|
||||
import cc.skysparkle.matewave.network.NetworkLog
|
||||
import cc.skysparkle.matewave.network.Transports
|
||||
import cc.skysparkle.matewave.security.DeviceKeys
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DiscoveredPeer(
|
||||
val playerId: String,
|
||||
val name: String,
|
||||
val elo: Int,
|
||||
val fingerprint: String,
|
||||
|
||||
val address: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows who is nearby on the same network. Announcements carry an ECDSA signature so a
|
||||
* known friend's name and rating cannot be overwritten by someone copying their ID.
|
||||
*/
|
||||
class LocalPresence(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
private val _peers = MutableStateFlow<Map<String, DiscoveredPeer>>(emptyMap())
|
||||
val peers: StateFlow<Map<String, DiscoveredPeer>> = _peers
|
||||
|
||||
private var job: Job? = null
|
||||
private var gameWatchJob: Job? = null
|
||||
|
||||
private val knownPeers by lazy { cc.skysparkle.matewave.security.KnownPeers(context) }
|
||||
|
||||
/** Start of the current discovery session and time of its last result. */
|
||||
@Volatile private var sessionStart = 0L
|
||||
@Volatile private var lastResultAt = 0L
|
||||
private var pruneJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (job != null) return
|
||||
val lan = Transports.lan(context)
|
||||
|
||||
if (PresenceSettings(context).announceEnabled) lan.startPresence()
|
||||
beginSession(lan)
|
||||
|
||||
job = scope.launch {
|
||||
while (true) {
|
||||
delay(WATCHDOG_INTERVAL_MS)
|
||||
|
||||
if (lanBusy(lan)) continue
|
||||
|
||||
runCatching { restartDiscovery(lan) }
|
||||
}
|
||||
}
|
||||
|
||||
gameWatchJob = scope.launch {
|
||||
var wasBusy = false
|
||||
lan.connectionState.collect { state ->
|
||||
val busy = state == cc.skysparkle.matewave.network.ConnectionState.CONNECTED ||
|
||||
state == cc.skysparkle.matewave.network.ConnectionState.CONNECTING
|
||||
if (wasBusy && !busy) {
|
||||
runCatching { restartDiscovery(lan) }
|
||||
}
|
||||
wasBusy = busy
|
||||
}
|
||||
}
|
||||
NetworkLog.log("PRESENCE", "LAN presence started")
|
||||
}
|
||||
|
||||
private fun lanBusy(lan: cc.skysparkle.matewave.network.LanTransport): Boolean {
|
||||
val state = lan.connectionState.value
|
||||
return state == cc.skysparkle.matewave.network.ConnectionState.CONNECTED ||
|
||||
state == cc.skysparkle.matewave.network.ConnectionState.CONNECTING
|
||||
}
|
||||
|
||||
/** Fresh discovery without clearing the list first, so statuses do not blink. */
|
||||
fun refresh() {
|
||||
val lan = Transports.lan(context)
|
||||
if (lanBusy(lan)) return
|
||||
scope.launch { runCatching { restartDiscovery(lan) } }
|
||||
}
|
||||
|
||||
private suspend fun restartDiscovery(lan: cc.skysparkle.matewave.network.LanTransport) {
|
||||
lan.stopDiscovery()
|
||||
delay(RESTART_DELAY_MS)
|
||||
if (PresenceSettings(context).announceEnabled) lan.startPresence()
|
||||
beginSession(lan)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a discovery session. A new session reports only what it finds; if it finds nobody
|
||||
* it reports nothing at all, so without this check players who left stayed in the list.
|
||||
*/
|
||||
private fun beginSession(lan: cc.skysparkle.matewave.network.LanTransport) {
|
||||
sessionStart = System.currentTimeMillis()
|
||||
lan.discoverPeers { found -> replaceAll(found) }
|
||||
pruneJob?.cancel()
|
||||
pruneJob = scope.launch {
|
||||
delay(PRUNE_AFTER_MS)
|
||||
if (lastResultAt < sessionStart) _peers.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
gameWatchJob?.cancel()
|
||||
gameWatchJob = null
|
||||
pruneJob?.cancel()
|
||||
pruneJob = null
|
||||
|
||||
runCatching {
|
||||
val lan = Transports.lan(context)
|
||||
val busy = lan.connectionState.value == cc.skysparkle.matewave.network.ConnectionState.CONNECTED ||
|
||||
lan.connectionState.value == cc.skysparkle.matewave.network.ConnectionState.CONNECTING
|
||||
if (busy) lan.stopDiscovery() else lan.shutdown()
|
||||
}
|
||||
_peers.value = emptyMap()
|
||||
NetworkLog.log("PRESENCE", "LAN presence stopped")
|
||||
}
|
||||
|
||||
private fun replaceAll(found: List<LanPeer>) {
|
||||
lastResultAt = System.currentTimeMillis()
|
||||
val mine = DeviceKeys.publicKeyFingerprint(context)
|
||||
val result = LinkedHashMap<String, DiscoveredPeer>()
|
||||
found.forEach { peer ->
|
||||
val id = peer.playerId.ifEmpty { peer.fingerprint }
|
||||
if (id.isEmpty()) return@forEach
|
||||
|
||||
if (peer.fingerprint.isNotEmpty() && peer.fingerprint == mine) return@forEach
|
||||
|
||||
val knownKey = knownPeers.publicKeyOf(id)
|
||||
if (knownKey != null) {
|
||||
val authentic = peer.signature.isNotEmpty() && DeviceKeys.verify(
|
||||
knownKey,
|
||||
cc.skysparkle.matewave.network.LanTransport.presencePayload(peer.playerId, peer.displayName, peer.elo),
|
||||
peer.signature
|
||||
)
|
||||
if (!authentic) {
|
||||
NetworkLog.log("PRESENCE", "Announcement for $id failed signature check, hidden")
|
||||
return@forEach
|
||||
}
|
||||
FriendsStore.updateProfile(
|
||||
userId = id,
|
||||
name = peer.displayName.ifEmpty { peer.name },
|
||||
elo = peer.elo
|
||||
)
|
||||
FriendsStore.markSeen(id, lastResultAt)
|
||||
}
|
||||
result[id] = DiscoveredPeer(
|
||||
playerId = id,
|
||||
name = peer.displayName.ifEmpty { peer.name },
|
||||
elo = peer.elo,
|
||||
fingerprint = peer.fingerprint,
|
||||
address = peer.id
|
||||
)
|
||||
}
|
||||
_peers.value = result
|
||||
}
|
||||
|
||||
fun isOnline(playerId: String): Boolean = _peers.value.containsKey(playerId)
|
||||
|
||||
private companion object {
|
||||
const val WATCHDOG_INTERVAL_MS = 30_000L
|
||||
|
||||
/** A session that found nobody within this time means nobody is around. */
|
||||
const val PRUNE_AFTER_MS = 8_000L
|
||||
|
||||
const val RESTART_DELAY_MS = 600L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cc.skysparkle.matewave.presence
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/**
|
||||
* The one [LocalPresence] of the process. The app's screens and the background service share it,
|
||||
* so two discovery sessions never compete for the network service, and it runs while at least
|
||||
* one of them holds it.
|
||||
*/
|
||||
object PresenceHub {
|
||||
private val scope = CoroutineScope(SupervisorJob())
|
||||
|
||||
@Volatile private var instance: LocalPresence? = null
|
||||
private var holders = 0
|
||||
|
||||
fun get(context: Context): LocalPresence =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: LocalPresence(context.applicationContext, scope).also { instance = it }
|
||||
}
|
||||
|
||||
fun acquire(context: Context) {
|
||||
val presence = get(context)
|
||||
val first = synchronized(this) { holders++ == 0 }
|
||||
if (first) presence.start()
|
||||
}
|
||||
|
||||
fun release(context: Context) {
|
||||
val presence = get(context)
|
||||
val last = synchronized(this) {
|
||||
if (holders == 0) return
|
||||
--holders == 0
|
||||
}
|
||||
if (last) presence.stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cc.skysparkle.matewave.presence
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import cc.skysparkle.matewave.MainActivity
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.network.NetworkLog
|
||||
|
||||
/** Optional foreground service that keeps the player visible while the app is in the background. */
|
||||
class PresenceService : Service() {
|
||||
private var holdsPresence = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (holdsPresence) return START_STICKY
|
||||
|
||||
createChannel()
|
||||
val started = runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
}.isSuccess
|
||||
|
||||
if (!started) {
|
||||
NetworkLog.log("PRESENCE", "Background service not started: rejected by the system")
|
||||
PresenceSettings(applicationContext).backgroundEnabled = false
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
PresenceHub.acquire(applicationContext)
|
||||
holdsPresence = true
|
||||
NetworkLog.log("PRESENCE", "Background presence started")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (holdsPresence) {
|
||||
holdsPresence = false
|
||||
PresenceHub.release(applicationContext)
|
||||
NetworkLog.log("PRESENCE", "Background presence stopped")
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.presence_service_channel),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pending = PendingIntent.getActivity(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.presence_service_title))
|
||||
.setContentText(getString(R.string.presence_service_text))
|
||||
.setSmallIcon(R.drawable.ic_stat_background)
|
||||
.setContentIntent(pending)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "presence"
|
||||
private const val NOTIFICATION_ID = 42
|
||||
|
||||
fun start(context: Context) {
|
||||
runCatching { context.startForegroundService(Intent(context, PresenceService::class.java)) }
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
runCatching { context.stopService(Intent(context, PresenceService::class.java)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cc.skysparkle.matewave.presence
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class PresenceSettings(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
var announceEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_ANNOUNCE, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_ANNOUNCE, value).apply() }
|
||||
|
||||
var backgroundEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_BACKGROUND, false)
|
||||
set(value) { prefs.edit().putBoolean(KEY_BACKGROUND, value).apply() }
|
||||
|
||||
private companion object {
|
||||
const val PREFS = "presence_settings"
|
||||
const val KEY_ANNOUNCE = "announce"
|
||||
const val KEY_BACKGROUND = "background"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cc.skysparkle.matewave.puzzles
|
||||
|
||||
import android.content.Context
|
||||
import cc.skysparkle.matewave.R
|
||||
import org.json.JSONArray
|
||||
|
||||
data class Puzzle(
|
||||
val id: Int,
|
||||
val theme: String,
|
||||
val fen: String,
|
||||
val solutionUci: List<String>,
|
||||
val rating: Int,
|
||||
val themes: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/** Localized name of the puzzle theme, shown as the screen title. */
|
||||
fun Puzzle.themeTitleRes(): Int = when (theme) {
|
||||
"tactic" -> R.string.puzzle_theme_tactic
|
||||
"mate_in_1" -> R.string.puzzle_theme_mate_in_1
|
||||
"mate_in_2" -> R.string.puzzle_theme_mate_in_2
|
||||
"mate_in_3" -> R.string.puzzle_theme_mate_in_3
|
||||
"fork" -> R.string.puzzle_theme_fork
|
||||
"pin" -> R.string.puzzle_theme_pin
|
||||
"discovered_attack" -> R.string.puzzle_theme_discovered_attack
|
||||
"rook_endgame" -> R.string.puzzle_theme_rook_endgame
|
||||
"pawn_endgame" -> R.string.puzzle_theme_pawn_endgame
|
||||
"sacrifice" -> R.string.puzzle_theme_sacrifice
|
||||
"deflection" -> R.string.puzzle_theme_deflection
|
||||
"hanging_piece" -> R.string.puzzle_theme_hanging_piece
|
||||
"skewer" -> R.string.puzzle_theme_skewer
|
||||
"bishop_endgame" -> R.string.puzzle_theme_bishop_endgame
|
||||
"zugzwang" -> R.string.puzzle_theme_zugzwang
|
||||
"trapped_piece" -> R.string.puzzle_theme_trapped_piece
|
||||
"knight_endgame" -> R.string.puzzle_theme_knight_endgame
|
||||
"promotion" -> R.string.puzzle_theme_promotion
|
||||
"attraction" -> R.string.puzzle_theme_attraction
|
||||
"double_check" -> R.string.puzzle_theme_double_check
|
||||
"clearance" -> R.string.puzzle_theme_clearance
|
||||
"queen_endgame" -> R.string.puzzle_theme_queen_endgame
|
||||
"en_passant" -> R.string.puzzle_theme_en_passant
|
||||
"interference" -> R.string.puzzle_theme_interference
|
||||
"xray" -> R.string.puzzle_theme_xray
|
||||
else -> R.string.puzzle_title_fallback
|
||||
}
|
||||
|
||||
/** Loads the bundled Lichess puzzle set (CC0) from assets and caches it in memory. */
|
||||
object PuzzleRepository {
|
||||
@Volatile
|
||||
private var cached: List<Puzzle>? = null
|
||||
|
||||
fun load(context: Context): List<Puzzle> {
|
||||
cached?.let { return it }
|
||||
synchronized(this) {
|
||||
cached?.let { return it }
|
||||
val json = context.assets.open("puzzles.json").bufferedReader(Charsets.UTF_8).use { it.readText() }
|
||||
val arr = JSONArray(json)
|
||||
val result = ArrayList<Puzzle>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val solutionArr = o.getJSONArray("solution")
|
||||
val solution = (0 until solutionArr.length()).map { solutionArr.getString(it) }
|
||||
val themesArr = o.optJSONArray("themes")
|
||||
val themes = themesArr?.let { t -> (0 until t.length()).map { t.getString(it) } } ?: emptyList()
|
||||
result.add(
|
||||
Puzzle(
|
||||
id = o.getInt("id"),
|
||||
theme = o.optString("theme", "tactic"),
|
||||
fen = o.getString("fen"),
|
||||
solutionUci = solution,
|
||||
rating = o.getInt("rating"),
|
||||
themes = themes
|
||||
)
|
||||
)
|
||||
}
|
||||
cached = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A puzzle close to [rating]: a random unsolved one within ±100, widening the window if
|
||||
* needed. Once every nearby puzzle is solved, solved ones are offered again.
|
||||
*/
|
||||
fun pickForRating(context: Context, rating: Int, solved: Set<Int>, exclude: Int? = null): Puzzle? {
|
||||
val all = load(context).filter { it.id != exclude }
|
||||
if (all.isEmpty()) return null
|
||||
val unsolved = all.filter { it.id !in solved }
|
||||
for (pool in listOf(unsolved, all)) {
|
||||
for (window in intArrayOf(100, 200, 400)) {
|
||||
val near = pool.filter { kotlin.math.abs(it.rating - rating) <= window }
|
||||
if (near.isNotEmpty()) return near.random()
|
||||
}
|
||||
}
|
||||
return (unsolved.ifEmpty { all }).minByOrNull { kotlin.math.abs(it.rating - rating) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package cc.skysparkle.matewave.security
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyPair
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.MessageDigest
|
||||
import java.security.PrivateKey
|
||||
import java.security.PublicKey
|
||||
import java.security.Signature
|
||||
import java.security.spec.ECGenParameterSpec
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
|
||||
/**
|
||||
* Long-term ECDSA identity key (kept in Android Keystore) plus per-game ephemeral ECDH
|
||||
* keys, so a leaked session secret does not expose other games.
|
||||
*/
|
||||
object DeviceKeys {
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
// Kept from before the rename: a new alias would create a new identity and break pinned keys.
|
||||
private const val KEY_ALIAS = "chessapp_identity"
|
||||
private const val PREFS = "device_keys"
|
||||
private const val KEY_PRIVATE = "private"
|
||||
private const val KEY_PUBLIC = "public"
|
||||
private const val ALGORITHM = "EC"
|
||||
private const val CURVE = "secp256r1"
|
||||
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
|
||||
|
||||
@Volatile private var cached: KeyPair? = null
|
||||
|
||||
private fun keystoreKeyPair(): KeyPair? = try {
|
||||
val store = java.security.KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||
val existing = store.getEntry(KEY_ALIAS, null) as? java.security.KeyStore.PrivateKeyEntry
|
||||
if (existing != null) {
|
||||
KeyPair(existing.certificate.publicKey, existing.privateKey)
|
||||
} else {
|
||||
val generator = KeyPairGenerator.getInstance(ALGORITHM, KEYSTORE)
|
||||
generator.initialize(
|
||||
android.security.keystore.KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
android.security.keystore.KeyProperties.PURPOSE_SIGN or
|
||||
android.security.keystore.KeyProperties.PURPOSE_VERIFY
|
||||
)
|
||||
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
|
||||
.setDigests(android.security.keystore.KeyProperties.DIGEST_SHA256)
|
||||
.build()
|
||||
)
|
||||
generator.generateKeyPair()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun keyPair(context: Context): KeyPair? {
|
||||
cached?.let { return it }
|
||||
synchronized(this) {
|
||||
cached?.let { return it }
|
||||
|
||||
keystoreKeyPair()?.let {
|
||||
cached = it
|
||||
return it
|
||||
}
|
||||
|
||||
val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val storedPrivate = prefs.getString(KEY_PRIVATE, null)
|
||||
val storedPublic = prefs.getString(KEY_PUBLIC, null)
|
||||
|
||||
if (storedPrivate != null && storedPublic != null) {
|
||||
val restored = try {
|
||||
val factory = KeyFactory.getInstance(ALGORITHM)
|
||||
KeyPair(
|
||||
factory.generatePublic(X509EncodedKeySpec(decode(storedPublic))),
|
||||
factory.generatePrivate(PKCS8EncodedKeySpec(decode(storedPrivate)))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
if (restored != null) {
|
||||
cached = restored
|
||||
return restored
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val generator = KeyPairGenerator.getInstance(ALGORITHM)
|
||||
generator.initialize(ECGenParameterSpec(CURVE))
|
||||
val fresh = generator.generateKeyPair()
|
||||
prefs.edit()
|
||||
.putString(KEY_PRIVATE, encode(fresh.private.encoded))
|
||||
.putString(KEY_PUBLIC, encode(fresh.public.encoded))
|
||||
.apply()
|
||||
cached = fresh
|
||||
fresh
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publicKey(context: Context): String? =
|
||||
keyPair(context)?.public?.encoded?.let { encode(it) }
|
||||
|
||||
fun publicKeyFingerprint(context: Context): String? =
|
||||
publicKey(context)?.let { fingerprintOf(it) }
|
||||
|
||||
fun fingerprintOf(publicKey: String): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(publicKey.toByteArray(Charsets.UTF_8))
|
||||
.take(16).joinToString("") { "%02x".format(it) }
|
||||
|
||||
fun sign(context: Context, data: String): String? {
|
||||
val privateKey: PrivateKey = keyPair(context)?.private ?: return null
|
||||
return try {
|
||||
val signature = Signature.getInstance(SIGNATURE_ALGORITHM)
|
||||
signature.initSign(privateKey)
|
||||
signature.update(data.toByteArray(Charsets.UTF_8))
|
||||
encode(signature.sign())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun verify(publicKeyEncoded: String, data: String, signatureEncoded: String): Boolean {
|
||||
return try {
|
||||
val key: PublicKey = KeyFactory.getInstance(ALGORITHM)
|
||||
.generatePublic(X509EncodedKeySpec(decode(publicKeyEncoded)))
|
||||
val signature = Signature.getInstance(SIGNATURE_ALGORITHM)
|
||||
signature.initVerify(key)
|
||||
signature.update(data.toByteArray(Charsets.UTF_8))
|
||||
signature.verify(decode(signatureEncoded))
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun generateEphemeral(): KeyPair? = try {
|
||||
val generator = KeyPairGenerator.getInstance(ALGORITHM)
|
||||
generator.initialize(ECGenParameterSpec(CURVE))
|
||||
generator.generateKeyPair()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun encodePublic(keyPair: KeyPair): String = encode(keyPair.public.encoded)
|
||||
|
||||
fun sharedSecretWith(ephemeral: KeyPair, peerEphemeralPublic: String): ByteArray? = try {
|
||||
val peer = KeyFactory.getInstance(ALGORITHM)
|
||||
.generatePublic(X509EncodedKeySpec(decode(peerEphemeralPublic)))
|
||||
val agreement = javax.crypto.KeyAgreement.getInstance("ECDH")
|
||||
agreement.init(ephemeral.private)
|
||||
agreement.doPhase(peer, true)
|
||||
MessageDigest.getInstance("SHA-256").digest(agreement.generateSecret())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun encode(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
private fun decode(value: String): ByteArray = Base64.decode(value, Base64.NO_WRAP)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package cc.skysparkle.matewave.security
|
||||
|
||||
import android.content.Context
|
||||
import cc.skysparkle.matewave.network.NetworkLog
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class TrustResult {
|
||||
TRUSTED,
|
||||
|
||||
FIRST_SEEN,
|
||||
|
||||
IMPERSONATION,
|
||||
|
||||
BAD_SIGNATURE,
|
||||
|
||||
UNSIGNED
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-on-first-use key pinning: the first public key seen for a player ID is stored,
|
||||
* and later profiles for that ID must be signed with the same key.
|
||||
*/
|
||||
class KnownPeers(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun publicKeyOf(userId: String): String? = prefs.getString(key(userId), null)
|
||||
|
||||
fun verifyAndRemember(
|
||||
userId: String,
|
||||
publicKey: String?,
|
||||
signedData: String,
|
||||
signature: String?
|
||||
): TrustResult {
|
||||
if (publicKey.isNullOrBlank() || signature.isNullOrBlank()) {
|
||||
if (publicKeyOf(userId) != null) {
|
||||
NetworkLog.log("TRUST", "Unsigned profile for known $userId rejected as impersonation")
|
||||
return TrustResult.IMPERSONATION
|
||||
}
|
||||
return TrustResult.UNSIGNED
|
||||
}
|
||||
if (!DeviceKeys.verify(publicKey, signedData, signature)) {
|
||||
NetworkLog.log("TRUST", "Signature mismatch for $userId")
|
||||
return TrustResult.BAD_SIGNATURE
|
||||
}
|
||||
|
||||
val known = publicKeyOf(userId)
|
||||
return when {
|
||||
known == null -> {
|
||||
prefs.edit().putString(key(userId), publicKey).apply()
|
||||
TrustResult.FIRST_SEEN
|
||||
}
|
||||
known == publicKey -> TrustResult.TRUSTED
|
||||
else -> {
|
||||
NetworkLog.log("TRUST", "Impersonation attempt for $userId rejected")
|
||||
TrustResult.IMPERSONATION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun profilePayload(
|
||||
userId: String,
|
||||
name: String,
|
||||
elo: Int,
|
||||
avatarBase64: String? = null,
|
||||
backgroundBase64: String? = null,
|
||||
|
||||
ephemeralKey: String = ""
|
||||
): String =
|
||||
JSONObject().apply {
|
||||
put("userId", userId)
|
||||
put("name", name)
|
||||
put("elo", elo)
|
||||
put("avatar", digestOf(avatarBase64))
|
||||
put("background", digestOf(backgroundBase64))
|
||||
put("ephemeral", ephemeralKey)
|
||||
}.toString()
|
||||
|
||||
private fun digestOf(value: String?): String {
|
||||
if (value.isNullOrEmpty()) return ""
|
||||
return java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray(Charsets.UTF_8))
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** Forgets every pinned key, e.g. after opponents reinstalled the app. */
|
||||
fun forgetAll() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private fun key(userId: String) = "pk:$userId"
|
||||
|
||||
private companion object {
|
||||
const val PREFS = "known_peers"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package cc.skysparkle.matewave.security
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Per-session message protection: AES-256-GCM for confidentiality and a truncated
|
||||
* HMAC-SHA256 tag bound to the sender's direction. A sliding window of sequence numbers
|
||||
* rejects replays while tolerating out-of-order delivery.
|
||||
*/
|
||||
class SessionAuth(private val secret: ByteArray) {
|
||||
private var lastAcceptedSeq = 0
|
||||
private var outgoingSeq = 0
|
||||
|
||||
private val seenInWindow = java.util.TreeSet<Int>()
|
||||
|
||||
fun nextSeq(): Int {
|
||||
outgoingSeq += 1
|
||||
return outgoingSeq
|
||||
}
|
||||
|
||||
fun acceptSeq(seq: Int): Boolean {
|
||||
if (seq <= 0) return false
|
||||
if (seq > lastAcceptedSeq) {
|
||||
lastAcceptedSeq = seq
|
||||
seenInWindow.add(seq)
|
||||
|
||||
while (seenInWindow.isNotEmpty() && seenInWindow.first() < lastAcceptedSeq - REPLAY_WINDOW) {
|
||||
seenInWindow.pollFirst()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (seq < lastAcceptedSeq - REPLAY_WINDOW) return false
|
||||
|
||||
return seenInWindow.add(seq)
|
||||
}
|
||||
|
||||
fun tag(message: String): String? = try {
|
||||
val mac = Mac.getInstance(MAC_ALGORITHM)
|
||||
mac.init(SecretKeySpec(secret, MAC_ALGORITHM))
|
||||
val full = mac.doFinal(message.toByteArray(Charsets.UTF_8))
|
||||
Base64.encodeToString(full.copyOfRange(0, TAG_BYTES), Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun verify(message: String, providedTag: String?): Boolean {
|
||||
if (providedTag == null) return false
|
||||
val expected = tag(message) ?: return false
|
||||
if (expected.length != providedTag.length) return false
|
||||
var diff = 0
|
||||
for (i in expected.indices) {
|
||||
diff = diff or (expected[i].code xor providedTag[i].code)
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
private val encryptionKey: ByteArray by lazy {
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(secret + KDF_LABEL)
|
||||
}
|
||||
|
||||
fun encrypt(plaintext: String): String? = try {
|
||||
val nonce = ByteArray(GCM_NONCE_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
val cipher = Cipher.getInstance(CIPHER_ALGORITHM)
|
||||
cipher.init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
SecretKeySpec(encryptionKey, "AES"),
|
||||
GCMParameterSpec(GCM_TAG_BITS, nonce)
|
||||
)
|
||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
Base64.encodeToString(nonce + ciphertext, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun decrypt(payload: String): String? = try {
|
||||
val raw = Base64.decode(payload, Base64.NO_WRAP)
|
||||
if (raw.size <= GCM_NONCE_BYTES) null else {
|
||||
val nonce = raw.copyOfRange(0, GCM_NONCE_BYTES)
|
||||
val ciphertext = raw.copyOfRange(GCM_NONCE_BYTES, raw.size)
|
||||
val cipher = Cipher.getInstance(CIPHER_ALGORITHM)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(encryptionKey, "AES"),
|
||||
GCMParameterSpec(GCM_TAG_BITS, nonce)
|
||||
)
|
||||
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAC_ALGORITHM = "HmacSHA256"
|
||||
const val TAG_BYTES = 12
|
||||
const val CIPHER_ALGORITHM = "AES/GCM/NoPadding"
|
||||
const val GCM_NONCE_BYTES = 12
|
||||
const val GCM_TAG_BITS = 128
|
||||
const val REPLAY_WINDOW = 256
|
||||
val KDF_LABEL = "chessapp-session-aes-gcm-v1".toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cc.skysparkle.matewave.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
/** External links of the app, kept in one place. */
|
||||
object AppLinks {
|
||||
const val REPOSITORY = "https://git.skysparkle.cc/kalarina/Matewave"
|
||||
const val REPOSITORY_LABEL = "git.skysparkle.cc/kalarina/Matewave"
|
||||
const val PRIVACY_POLICY = "https://skysparkle.cc/privacy?lang=en"
|
||||
|
||||
/** Opens a link in the browser; does nothing if no app can open it. */
|
||||
fun open(context: Context, url: String) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cc.skysparkle.matewave.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
enum class BoardTheme(val titleRes: Int, val light: Color, val dark: Color) {
|
||||
CLASSIC(cc.skysparkle.matewave.R.string.board_theme_classic, Color(0xFFEDD6B0), Color(0xFFB88762)),
|
||||
GREEN(cc.skysparkle.matewave.R.string.board_theme_green, Color(0xFFEEEED2), Color(0xFF769656)),
|
||||
BLUE(cc.skysparkle.matewave.R.string.board_theme_blue, Color(0xFFDEE3E6), Color(0xFF8CA2AD)),
|
||||
GREY(cc.skysparkle.matewave.R.string.board_theme_grey, Color(0xFFDCDCDC), Color(0xFF8F8F8F))
|
||||
}
|
||||
|
||||
class BoardAppearance(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
var theme: BoardTheme
|
||||
get() = runCatching { BoardTheme.valueOf(prefs.getString(KEY_THEME, null) ?: "") }
|
||||
.getOrDefault(BoardTheme.CLASSIC)
|
||||
set(value) { prefs.edit().putString(KEY_THEME, value.name).apply() }
|
||||
|
||||
var showCoordinates: Boolean
|
||||
get() = prefs.getBoolean(KEY_COORDS, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_COORDS, value).apply() }
|
||||
|
||||
var useImagePieces: Boolean
|
||||
get() = prefs.getBoolean(KEY_IMAGES, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_IMAGES, value).apply() }
|
||||
|
||||
var showLastMove: Boolean
|
||||
get() = prefs.getBoolean(KEY_LAST_MOVE, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_LAST_MOVE, value).apply() }
|
||||
|
||||
var showCheckHighlight: Boolean
|
||||
get() = prefs.getBoolean(KEY_CHECK, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_CHECK, value).apply() }
|
||||
|
||||
var showOpening: Boolean
|
||||
get() = prefs.getBoolean(KEY_OPENING, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_OPENING, value).apply() }
|
||||
|
||||
private companion object {
|
||||
const val KEY_OPENING = "opening_in_game"
|
||||
const val KEY_LAST_MOVE = "last_move"
|
||||
const val KEY_CHECK = "check_highlight"
|
||||
const val PREFS = "board_appearance"
|
||||
const val KEY_THEME = "theme"
|
||||
const val KEY_COORDS = "coords"
|
||||
const val KEY_IMAGES = "images"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cc.skysparkle.matewave.settings
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class FeedbackSettings(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
var vibrationEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_VIBRATION, true)
|
||||
set(value) { prefs.edit().putBoolean(KEY_VIBRATION, value).apply() }
|
||||
|
||||
private companion object {
|
||||
const val PREFS = "feedback_settings"
|
||||
const val KEY_VIBRATION = "vibration"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package cc.skysparkle.matewave.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
|
||||
/**
|
||||
* Vibration feedback through the Vibrator service directly, so it works even when
|
||||
* system touch feedback is disabled. Controlled by the in-app vibration switch.
|
||||
*/
|
||||
object Haptics {
|
||||
@Volatile private var appContext: Context? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
(app.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
app.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
}
|
||||
|
||||
fun select() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_TICK, fallbackMs = 8)
|
||||
|
||||
fun move() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_CLICK, fallbackMs = 15)
|
||||
|
||||
fun capture() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_HEAVY_CLICK, fallbackMs = 25)
|
||||
|
||||
fun alert() = play(Build.VERSION_CODES.Q, predefined = VibrationEffect.EFFECT_DOUBLE_CLICK, fallbackMs = 40)
|
||||
|
||||
private fun play(minApiForPredefined: Int, predefined: Int, fallbackMs: Long) {
|
||||
val context = appContext ?: return
|
||||
val v = vibrator ?: return
|
||||
if (!v.hasVibrator()) return
|
||||
if (!FeedbackSettings(context).vibrationEnabled) return
|
||||
runCatching {
|
||||
val effect = if (Build.VERSION.SDK_INT >= minApiForPredefined) {
|
||||
VibrationEffect.createPredefined(predefined)
|
||||
} else {
|
||||
VibrationEffect.createOneShot(fallbackMs, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||
}
|
||||
v.vibrate(effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cc.skysparkle.matewave.settings
|
||||
|
||||
import android.content.Context
|
||||
import cc.skysparkle.matewave.ai.AiDifficulty
|
||||
import cc.skysparkle.matewave.clock.TimeControl
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
|
||||
/** Remembers the last choices on setup screens, so a repeat game is one tap away. */
|
||||
class LastGameSetup(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("last_game_setup", Context.MODE_PRIVATE)
|
||||
|
||||
var aiDifficulty: AiDifficulty
|
||||
get() = AiDifficulty.entries.firstOrNull { it.name == prefs.getString("ai_difficulty", null) } ?: AiDifficulty.MEDIUM
|
||||
set(value) { prefs.edit().putString("ai_difficulty", value.name).apply() }
|
||||
|
||||
/** null means a random colour. */
|
||||
var aiColor: PieceColor?
|
||||
get() = when (prefs.getString("ai_color", "W")) { "W" -> PieceColor.WHITE; "B" -> PieceColor.BLACK; else -> null }
|
||||
set(value) { prefs.edit().putString("ai_color", when (value) { PieceColor.WHITE -> "W"; PieceColor.BLACK -> "B"; null -> "R" }).apply() }
|
||||
|
||||
var aiTime: TimeControl
|
||||
get() = timeFor("ai_time", TimeControl.RAPID_10_0)
|
||||
set(value) { prefs.edit().putString("ai_time", value.label).apply() }
|
||||
|
||||
var nearbyTime: TimeControl
|
||||
get() = timeFor("nearby_time", TimeControl.BLITZ_5_0)
|
||||
set(value) { prefs.edit().putString("nearby_time", value.label).apply() }
|
||||
|
||||
private fun timeFor(key: String, default: TimeControl): TimeControl =
|
||||
TimeControl.presets.firstOrNull { it.label == prefs.getString(key, null) } ?: default
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package cc.skysparkle.matewave.share
|
||||
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.util.Locale
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class ApkShareServer(private val apk: File, private val appName: String) {
|
||||
private val fileName = "$appName.apk"
|
||||
|
||||
@Volatile
|
||||
private var server: ServerSocket? = null
|
||||
|
||||
fun start(): Int {
|
||||
val socket = ServerSocket(0)
|
||||
server = socket
|
||||
thread(name = "apk-share-accept", isDaemon = true) {
|
||||
while (!socket.isClosed) {
|
||||
val client = try {
|
||||
socket.accept()
|
||||
} catch (e: IOException) {
|
||||
break
|
||||
}
|
||||
thread(name = "apk-share-client", isDaemon = true) { serve(client) }
|
||||
}
|
||||
}
|
||||
return socket.localPort
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
runCatching { server?.close() }
|
||||
server = null
|
||||
}
|
||||
|
||||
private fun serve(client: Socket) {
|
||||
try {
|
||||
client.use { s ->
|
||||
s.soTimeout = 15_000
|
||||
val input = s.getInputStream().bufferedReader(Charsets.ISO_8859_1)
|
||||
val requestLine = input.readLine() ?: return
|
||||
|
||||
while (true) {
|
||||
val line = input.readLine() ?: break
|
||||
if (line.isEmpty()) break
|
||||
}
|
||||
val parts = requestLine.split(' ')
|
||||
val method = parts.getOrNull(0).orEmpty()
|
||||
val path = parts.getOrNull(1).orEmpty().substringBefore('?')
|
||||
val out = BufferedOutputStream(s.getOutputStream())
|
||||
val withBody = method == "GET"
|
||||
|
||||
when {
|
||||
method != "GET" && method != "HEAD" ->
|
||||
sendText(out, 405, "Method Not Allowed", "Method Not Allowed", withBody)
|
||||
path == "/" || path == "/index.html" -> {
|
||||
val page = landingPage()
|
||||
writeHeader(out, 200, "OK", "text/html; charset=utf-8", page.size.toLong())
|
||||
if (withBody) out.write(page)
|
||||
}
|
||||
path == "/$fileName" -> {
|
||||
writeHeader(
|
||||
out, 200, "OK", "application/vnd.android.package-archive", apk.length(),
|
||||
"Content-Disposition: attachment; filename=\"$fileName\"\r\n"
|
||||
)
|
||||
if (withBody) apk.inputStream().use { it.copyTo(out, 64 * 1024) }
|
||||
}
|
||||
else -> sendText(out, 404, "Not Found", "Not Found", withBody)
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendText(out: OutputStream, code: Int, reason: String, text: String, withBody: Boolean) {
|
||||
val body = text.toByteArray(Charsets.UTF_8)
|
||||
writeHeader(out, code, reason, "text/plain; charset=utf-8", body.size.toLong())
|
||||
if (withBody) out.write(body)
|
||||
}
|
||||
|
||||
private fun writeHeader(
|
||||
out: OutputStream, code: Int, reason: String, type: String, length: Long, extra: String = ""
|
||||
) {
|
||||
val header = "HTTP/1.1 $code $reason\r\n" +
|
||||
"Content-Type: $type\r\n" +
|
||||
"Content-Length: $length\r\n" +
|
||||
"Cache-Control: no-store\r\n" +
|
||||
"Connection: close\r\n" +
|
||||
extra +
|
||||
"\r\n"
|
||||
out.write(header.toByteArray(Charsets.ISO_8859_1))
|
||||
}
|
||||
|
||||
private fun landingPage(): ByteArray {
|
||||
val size = String.format(Locale.US, "%.1f MB", apk.length() / 1_048_576.0)
|
||||
val html = "<!doctype html><html><head><meta charset=\"utf-8\">" +
|
||||
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
||||
"<title>$appName</title><style>" +
|
||||
"body{font-family:sans-serif;background:#12181A;color:#fff;text-align:center;padding:48px 20px}" +
|
||||
"a.b{display:inline-block;margin-top:28px;padding:16px 28px;border-radius:12px;" +
|
||||
"background:#3E7C4A;color:#fff;font-size:18px;text-decoration:none}" +
|
||||
"p{color:#b8c2c0}</style></head><body><h1>$appName</h1>" +
|
||||
"<p>Android app \u00B7 APK $size</p>" +
|
||||
"<a class=\"b\" href=\"/$fileName\">Download APK</a>" +
|
||||
"<p>After downloading, open the file and allow installation from this source if asked.</p>" +
|
||||
"</body></html>"
|
||||
return html.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
object LocalNetwork {
|
||||
private val WIFI_PREFIXES = listOf("wlan", "swlan", "ap", "softap", "eth")
|
||||
|
||||
fun wifiIpv4(): String? = runCatching {
|
||||
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
|
||||
.filter { nif -> nif.isUp && !nif.isLoopback && WIFI_PREFIXES.any { nif.name.startsWith(it) } }
|
||||
.flatMap { nif -> nif.inetAddresses.toList() }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { it.isSiteLocalAddress }
|
||||
?.hostAddress
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cc.skysparkle.matewave.share
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
object ApkSharing {
|
||||
fun apkFile(context: Context): File = File(context.applicationInfo.sourceDir)
|
||||
|
||||
fun isSplitInstall(context: Context): Boolean =
|
||||
!context.applicationInfo.splitSourceDirs.isNullOrEmpty()
|
||||
|
||||
suspend fun copyForSharing(context: Context, appName: String): Uri = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val target = File(dir, "$appName.apk")
|
||||
apkFile(context).copyTo(target, overwrite = true)
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package cc.skysparkle.matewave.stats
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object Stats {
|
||||
private const val PREFS = "chess_stats_prefs"
|
||||
private const val KEY_WINS = "wins_vs_ai"
|
||||
private const val KEY_GAMES = "games_played_vs_ai"
|
||||
private const val KEY_STREAK = "current_streak"
|
||||
private const val KEY_RATINGS = "rating_history"
|
||||
private const val KEY_SOLVED = "solved_puzzles"
|
||||
private const val RATING_LIMIT = 40
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
private fun prefs() = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun wins(): Int = prefs()?.getInt(KEY_WINS, 0) ?: 0
|
||||
|
||||
fun gamesPlayed(): Int = prefs()?.getInt(KEY_GAMES, 0) ?: 0
|
||||
|
||||
fun winPercent(): Int {
|
||||
val played = gamesPlayed()
|
||||
return if (played == 0) 0 else ((wins() * 100f) / played).let { Math.round(it) }
|
||||
}
|
||||
|
||||
fun streak(): Int = prefs()?.getInt(KEY_STREAK, 0) ?: 0
|
||||
|
||||
/** Result of a rated game against the AI or another player: 1.0 win, 0.5 draw, 0.0 loss. */
|
||||
fun recordResult(result: Double) {
|
||||
val p = prefs() ?: return
|
||||
val win = result >= 1.0
|
||||
p.edit()
|
||||
.putInt(KEY_WINS, p.getInt(KEY_WINS, 0) + if (win) 1 else 0)
|
||||
.putInt(KEY_GAMES, p.getInt(KEY_GAMES, 0) + 1)
|
||||
.putInt(KEY_STREAK, if (win) p.getInt(KEY_STREAK, 0) + 1 else 0)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Rating change after a rated game; the statistics graph shows this history. */
|
||||
fun recordRating(before: Int, after: Int) {
|
||||
val p = prefs() ?: return
|
||||
val history = readRatings().ifEmpty { listOf(before) }
|
||||
val ratings = (history + after).takeLast(RATING_LIMIT)
|
||||
p.edit().putString(KEY_RATINGS, ratings.joinToString(",")).apply()
|
||||
}
|
||||
|
||||
private fun readRatings(): List<Int> {
|
||||
val raw = prefs()?.getString(KEY_RATINGS, "") ?: ""
|
||||
return if (raw.isBlank()) emptyList() else raw.split(",").mapNotNull { it.toIntOrNull() }
|
||||
}
|
||||
|
||||
fun solvedPuzzles(): Set<Int> =
|
||||
prefs()?.getStringSet(KEY_SOLVED, emptySet())?.mapNotNull { it.toIntOrNull() }?.toSet() ?: emptySet()
|
||||
|
||||
fun markPuzzleSolved(id: Int) {
|
||||
val p = prefs() ?: return
|
||||
val set = (p.getStringSet(KEY_SOLVED, emptySet()) ?: emptySet()).toMutableSet()
|
||||
set.add(id.toString())
|
||||
p.edit().putStringSet(KEY_SOLVED, set).apply()
|
||||
}
|
||||
|
||||
/** Rating history for the graph; with fewer than two games it starts from the current rating. */
|
||||
fun ratingCurve(currentRating: Int): List<Int> {
|
||||
val ratings = readRatings()
|
||||
return when {
|
||||
ratings.isEmpty() -> listOf(currentRating, currentRating)
|
||||
ratings.size == 1 -> listOf(ratings.first(), ratings.first())
|
||||
else -> ratings
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import cc.skysparkle.matewave.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private object BackgroundAssetCache {
|
||||
@Volatile private var bitmap: ImageBitmap? = null
|
||||
|
||||
fun cached(): ImageBitmap? = bitmap
|
||||
|
||||
fun load(context: Context): ImageBitmap? {
|
||||
bitmap?.let { return it }
|
||||
synchronized(this) {
|
||||
bitmap?.let { return it }
|
||||
val metrics: DisplayMetrics = context.resources.displayMetrics
|
||||
val target = maxOf(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1)
|
||||
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.bg_forest, bounds)
|
||||
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > target * 2) sample *= 2
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sample
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
|
||||
}
|
||||
val decoded = runCatching {
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.bg_forest, options)
|
||||
?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
bitmap = decoded
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBackgroundImage(modifier: Modifier = Modifier.fillMaxSize()) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var bitmap by remember { mutableStateOf(BackgroundAssetCache.cached()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (bitmap == null) {
|
||||
bitmap = withContext(Dispatchers.IO) { BackgroundAssetCache.load(context) }
|
||||
}
|
||||
}
|
||||
|
||||
val image = bitmap
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(modifier = modifier.background(Color(0xFF101413)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.background
|
||||
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.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
|
||||
|
||||
enum class BottomTab { HOME, FRIENDS, SETTINGS, ACCOUNT }
|
||||
|
||||
@Composable
|
||||
fun AppBottomBar(
|
||||
current: BottomTab,
|
||||
onHomeClick: () -> Unit,
|
||||
onFriendsClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
|
||||
accountAvatarBase64: String? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.background(cc.skysparkle.matewave.ui.theme.FloatingBarFill, RoundedCornerShape(UiCornerRadius))
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
NavItem(iconRes = R.drawable.ic_home, label = stringResource(R.string.nav_home), active = current == BottomTab.HOME, onClick = onHomeClick)
|
||||
|
||||
NavItem(iconRes = R.drawable.ic_friends, label = stringResource(R.string.nav_friends), active = current == BottomTab.FRIENDS, onClick = onFriendsClick)
|
||||
NavItem(iconRes = R.drawable.ic_settings, label = stringResource(R.string.nav_settings), active = current == BottomTab.SETTINGS, onClick = onSettingsClick)
|
||||
|
||||
AccountNavItem(
|
||||
avatarBase64 = accountAvatarBase64,
|
||||
active = current == BottomTab.ACCOUNT,
|
||||
onClick = onAccountClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.NavItem(iconRes: Int, label: String, active: Boolean, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(UiCornerRadius)
|
||||
|
||||
val highlight by animateColorAsState(
|
||||
if (active) ink.copy(alpha = 0.14f) else ink.copy(alpha = 0f),
|
||||
animationSpec = tween(220),
|
||||
label = "navHighlight"
|
||||
)
|
||||
val itemColor by animateColorAsState(
|
||||
if (active) ink else ink.copy(alpha = 0.7f),
|
||||
animationSpec = tween(220),
|
||||
label = "navColor"
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(shape)
|
||||
.background(highlight, shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(itemColor),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
label,
|
||||
color = if (active) itemColor else Color.Transparent,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.AccountNavItem(avatarBase64: String?, active: Boolean, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(UiCornerRadius)
|
||||
val bitmap = rememberChatImage(avatarBase64, AVATAR_MAX_DIMENSION_PX)
|
||||
val highlight by animateColorAsState(
|
||||
if (active) ink.copy(alpha = 0.14f) else ink.copy(alpha = 0f),
|
||||
animationSpec = tween(220),
|
||||
label = "navHighlight"
|
||||
)
|
||||
val itemColor by animateColorAsState(
|
||||
if (active) ink else ink.copy(alpha = 0.7f),
|
||||
animationSpec = tween(220),
|
||||
label = "navColor"
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(shape)
|
||||
.background(highlight, shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(24.dp).clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_default_avatar),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(itemColor),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.account_title),
|
||||
color = if (active) itemColor else Color.Transparent,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import android.os.Build
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import cc.skysparkle.matewave.ui.theme.DialogScrimColor
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
val DialogSurface = Color(0xFF262B29)
|
||||
val DialogAccent = Color(0xFF93DBA3)
|
||||
val DialogShape = RoundedCornerShape(28.dp)
|
||||
|
||||
@Composable
|
||||
fun AppDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
title: String? = null,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit = {}
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)
|
||||
) {
|
||||
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
SideEffect {
|
||||
window?.let { w ->
|
||||
w.setDimAmount(0f)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
runCatching {
|
||||
if (w.windowManager.isCrossWindowBlurEnabled) {
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
||||
w.attributes = w.attributes.apply { blurBehindRadius = 24 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var shown by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { shown = true }
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (shown) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing),
|
||||
label = "dialogAppear"
|
||||
)
|
||||
val scrim = DialogScrimColor
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawBehind { drawRect(scrim.copy(alpha = scrim.alpha * progress)) }
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismissRequest
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.graphicsLayer {
|
||||
alpha = progress
|
||||
val s = 0.92f + 0.08f * progress
|
||||
scaleX = s
|
||||
scaleY = s
|
||||
translationY = (1f - progress) * 24.dp.toPx()
|
||||
}
|
||||
.clip(DialogShape)
|
||||
.background(DialogSurface)
|
||||
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { }
|
||||
.padding(start = 24.dp, end = 16.dp, top = 24.dp, bottom = 16.dp)
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
title,
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
// Long content scrolls between the fixed title and buttons.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(end = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
content = content
|
||||
)
|
||||
if (actions != null) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogText(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text,
|
||||
color = ink.copy(alpha = 0.78f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DialogAction(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
color: Color = DialogAccent
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
color = if (enabled) color else color.copy(alpha = 0.4f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AppIcon(
|
||||
resId: Int,
|
||||
size: Dp = 20.dp,
|
||||
tint: Color = ink,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(tint),
|
||||
modifier = modifier.size(size)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ButtonIcon(resId: Int, tint: Color = ink, size: Dp = 18.dp) {
|
||||
AppIcon(resId, size = size, tint = tint, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.engine.Piece
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.engine.PieceType
|
||||
|
||||
@Composable
|
||||
fun CapturedPiecesRow(
|
||||
captured: List<PieceType>,
|
||||
capturedColor: PieceColor,
|
||||
advantage: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().height(22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
captured.groupBy { it }.forEach { (type, group) ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy((-10).dp)) {
|
||||
repeat(group.size) {
|
||||
Image(
|
||||
painter = painterResource(Piece(type, capturedColor).drawableRes()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (advantage > 0) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"+$advantage",
|
||||
color = ink.copy(alpha = 0.85f),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.whiteOutlinedTextFieldColors
|
||||
import cc.skysparkle.matewave.viewmodel.ChatEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ChatDialog(
|
||||
messages: List<ChatEntry>,
|
||||
onSendText: (String) -> Unit,
|
||||
onSendImage: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var text by remember { mutableStateOf("") }
|
||||
var sendingImage by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
sendingImage = true
|
||||
scope.launch {
|
||||
val encoded = withContext(Dispatchers.IO) { encodeImageForChat(context, uri) }
|
||||
sendingImage = false
|
||||
if (encoded != null) onSendImage(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.chat_title),
|
||||
actions = { DialogAction(stringResource(R.string.chat_close), onDismiss) }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 320.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (messages.isEmpty()) {
|
||||
DialogText(stringResource(R.string.chat_empty))
|
||||
}
|
||||
messages.forEach { entry -> ChatBubble(entry) }
|
||||
}
|
||||
|
||||
if (sendingImage) {
|
||||
GlassProgress(stringResource(R.string.chat_sending_image))
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
GlassOutlinedButton(onClick = { imagePicker.launch("image/*") }) {
|
||||
AppIcon(R.drawable.ic_add_photo, size = 22.dp)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text(stringResource(R.string.chat_input_hint), color = ink.copy(alpha = 0.5f)) },
|
||||
colors = whiteOutlinedTextFieldColors()
|
||||
)
|
||||
GlassButton(
|
||||
onClick = {
|
||||
if (text.isNotBlank()) {
|
||||
onSendText(text.trim())
|
||||
text = ""
|
||||
}
|
||||
},
|
||||
enabled = text.isNotBlank()
|
||||
) {
|
||||
AppIcon(R.drawable.ic_arrow_forward, size = 20.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatBubble(entry: ChatEntry) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (entry.fromMe) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 220.dp)
|
||||
.background(if (entry.fromMe) ChessGreen.copy(alpha = 0.55f) else ink.copy(alpha = 0.15f), shape)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
entry.text?.let { Text(it, color = ink) }
|
||||
entry.imageBase64?.let { b64 ->
|
||||
val image = rememberChatImage(b64)
|
||||
if (image != null) {
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.size(180.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
private const val MAX_DIMENSION_PX = 640
|
||||
private const val JPEG_QUALITY = 60
|
||||
|
||||
const val AVATAR_MAX_DIMENSION_PX = 320
|
||||
const val AVATAR_JPEG_QUALITY = 75
|
||||
|
||||
fun encodeImageForChat(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
maxDimension: Int = MAX_DIMENSION_PX,
|
||||
quality: Int = JPEG_QUALITY
|
||||
): String? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
// Read the size first and decode a reduced copy: a full camera photo would not fit in memory.
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
val boundsStream = resolver.openInputStream(uri) ?: return null
|
||||
boundsStream.use { BitmapFactory.decodeStream(it, null, bounds) }
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxDimension) sample *= 2
|
||||
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
val decoded = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
|
||||
|
||||
val rotation = runCatching {
|
||||
resolver.openInputStream(uri)?.use { input ->
|
||||
when (ExifInterface(input).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}.getOrNull() ?: 0f
|
||||
|
||||
val scale = minOf(1f, maxDimension.toFloat() / maxOf(decoded.width, decoded.height))
|
||||
val matrix = Matrix().apply {
|
||||
if (scale < 1f) postScale(scale, scale)
|
||||
if (rotation != 0f) postRotate(rotation)
|
||||
}
|
||||
val prepared = if (scale < 1f || rotation != 0f) {
|
||||
Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
prepared.compress(Bitmap.CompressFormat.JPEG, quality, output)
|
||||
Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} catch (e: OutOfMemoryError) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private object DecodedImageCache {
|
||||
private val cache = object : android.util.LruCache<String, Bitmap>(budgetBytes()) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
private fun budgetBytes(): Int =
|
||||
(Runtime.getRuntime().maxMemory() / 16).toInt().coerceIn(4 shl 20, 32 shl 20)
|
||||
|
||||
fun key(base64: String, maxDimension: Int) = "$maxDimension:${base64.length}:${base64.hashCode()}"
|
||||
|
||||
fun get(key: String): Bitmap? = cache.get(key)
|
||||
fun put(key: String, bitmap: Bitmap) { cache.put(key, bitmap) }
|
||||
}
|
||||
|
||||
fun decodeChatImage(base64: String, maxDimension: Int = 0): Bitmap? {
|
||||
return try {
|
||||
val bytes = Base64.decode(base64, Base64.NO_WRAP)
|
||||
if (maxDimension <= 0) {
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} else {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxDimension) sample *= 2
|
||||
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberChatImage(base64: String?, maxDimension: Int = 0): ImageBitmap? {
|
||||
if (base64 == null) return null
|
||||
val key = remember(base64, maxDimension) { DecodedImageCache.key(base64, maxDimension) }
|
||||
val state = produceState<ImageBitmap?>(
|
||||
initialValue = DecodedImageCache.get(key)?.asImageBitmap(),
|
||||
key1 = key
|
||||
) {
|
||||
if (value == null) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
val bitmap = decodeChatImage(base64, maxDimension)
|
||||
if (bitmap != null) DecodedImageCache.put(key, bitmap)
|
||||
bitmap?.asImageBitmap()
|
||||
}
|
||||
}
|
||||
}
|
||||
return state.value
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import cc.skysparkle.matewave.engine.Board
|
||||
import cc.skysparkle.matewave.engine.Move
|
||||
import cc.skysparkle.matewave.engine.Piece
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.engine.PieceType
|
||||
import cc.skysparkle.matewave.settings.BoardAppearance
|
||||
import cc.skysparkle.matewave.ui.theme.HighlightCheck
|
||||
import cc.skysparkle.matewave.ui.theme.HighlightHint
|
||||
import cc.skysparkle.matewave.ui.theme.HighlightLastMove
|
||||
import cc.skysparkle.matewave.ui.theme.HighlightMove
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private object PieceImages {
|
||||
@Volatile private var cache: Array<ImageBitmap>? = null
|
||||
|
||||
fun get(context: Context): Array<ImageBitmap> {
|
||||
cache?.let { return it }
|
||||
synchronized(this) {
|
||||
cache?.let { return it }
|
||||
val options = BitmapFactory.Options().apply { inScaled = false }
|
||||
val types = PieceType.values()
|
||||
val loaded = Array(PieceColor.values().size * types.size) { i ->
|
||||
val piece = Piece(types[i % types.size], PieceColor.values()[i / types.size])
|
||||
BitmapFactory.decodeResource(context.resources, piece.drawableRes(), options).asImageBitmap()
|
||||
}
|
||||
cache = loaded
|
||||
return loaded
|
||||
}
|
||||
}
|
||||
|
||||
fun indexOf(piece: Piece): Int = piece.color.ordinal * PieceType.values().size + piece.type.ordinal
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChessBoardView(
|
||||
board: Board,
|
||||
selectedSquare: Int?,
|
||||
legalTargets: List<Move>,
|
||||
kingInCheckSquare: Int?,
|
||||
flipped: Boolean,
|
||||
onSquareClick: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
lastMove: Move? = null,
|
||||
hintSquare: Int? = null,
|
||||
interactionEnabled: Boolean = true,
|
||||
/** A square briefly tinted to confirm or reject a move (puzzles). */
|
||||
flashSquare: Int? = null,
|
||||
flashColor: Color = Color.Transparent
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val appearance = remember { BoardAppearance(context) }
|
||||
val theme = appearance.theme
|
||||
val showCoords = appearance.showCoordinates
|
||||
val imagePieces = appearance.useImagePieces
|
||||
val showLastMove = appearance.showLastMove
|
||||
val showCheck = appearance.showCheckHighlight
|
||||
|
||||
val pieceImages = if (imagePieces) remember(context) { PieceImages.get(context) } else null
|
||||
|
||||
val targetMask = remember(legalTargets) {
|
||||
var mask = 0L
|
||||
for (m in legalTargets) mask = mask or (1L shl m.to)
|
||||
mask
|
||||
}
|
||||
|
||||
val coordPaint = remember {
|
||||
Paint().apply { isAntiAlias = true; typeface = Typeface.DEFAULT_BOLD }
|
||||
}
|
||||
val glyphPaint = remember {
|
||||
Paint().apply { isAntiAlias = true; textAlign = Paint.Align.CENTER }
|
||||
}
|
||||
|
||||
val currentOnSquareClick by rememberUpdatedState(onSquareClick)
|
||||
val currentBoard by rememberUpdatedState(board)
|
||||
val currentSelected by rememberUpdatedState(selectedSquare)
|
||||
val currentTargetMask by rememberUpdatedState(targetMask)
|
||||
|
||||
var dragSquare by remember { mutableStateOf<Int?>(null) }
|
||||
var dragPos by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.pointerInput(flipped, interactionEnabled) {
|
||||
if (interactionEnabled) {
|
||||
detectTapGestures { tap ->
|
||||
val cellPx = size.width / 8f
|
||||
val col = floor(tap.x / cellPx).toInt().coerceIn(0, 7)
|
||||
val row = floor(tap.y / cellPx).toInt().coerceIn(0, 7)
|
||||
val displayRank = if (flipped) row else 7 - row
|
||||
val displayFile = if (flipped) 7 - col else col
|
||||
currentOnSquareClick(displayRank * 8 + displayFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(flipped, interactionEnabled) {
|
||||
if (interactionEnabled) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { start ->
|
||||
val src = squareAt(start.x, start.y, size.width.toFloat(), flipped)
|
||||
val piece = if (src >= 0) currentBoard.pieceAt(src) else null
|
||||
|
||||
if (piece != null && piece.color == currentBoard.sideToMove) {
|
||||
dragSquare = src
|
||||
dragPos = start
|
||||
|
||||
if (currentSelected == src) cc.skysparkle.matewave.settings.Haptics.select()
|
||||
|
||||
if (currentSelected != src) currentOnSquareClick(src)
|
||||
}
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
if (dragSquare != null) dragPos = change.position
|
||||
},
|
||||
onDragEnd = {
|
||||
val src = dragSquare
|
||||
val pos = dragPos
|
||||
dragSquare = null
|
||||
dragPos = null
|
||||
if (src != null && pos != null && currentSelected == src) {
|
||||
val target = squareAt(pos.x, pos.y, size.width.toFloat(), flipped)
|
||||
if (target >= 0 && target != src &&
|
||||
((currentTargetMask shr target) and 1L) != 0L
|
||||
) {
|
||||
currentOnSquareClick(target)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
dragSquare = null
|
||||
dragPos = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
val cell = size.width / 8f
|
||||
val lastFrom = if (showLastMove) (lastMove?.from ?: -1) else -1
|
||||
val lastTo = if (showLastMove) (lastMove?.to ?: -1) else -1
|
||||
val selected = selectedSquare ?: -1
|
||||
val checkSquare = if (showCheck) (kingInCheckSquare ?: -1) else -1
|
||||
val hint = hintSquare ?: -1
|
||||
val flash = flashSquare ?: -1
|
||||
|
||||
val dSq = dragSquare
|
||||
val dPos = dragPos
|
||||
val liftedSq = if (dSq != null && dPos != null && selected == dSq) dSq else -1
|
||||
val liftedAt: Offset? = if (liftedSq >= 0) dPos else null
|
||||
|
||||
for (row in 0 until 8) {
|
||||
for (col in 0 until 8) {
|
||||
val displayRank = if (flipped) row else 7 - row
|
||||
val displayFile = if (flipped) 7 - col else col
|
||||
val sqIndex = displayRank * 8 + displayFile
|
||||
val isLight = (displayRank + displayFile) % 2 == 1
|
||||
var color = if (isLight) theme.light else theme.dark
|
||||
if (sqIndex == lastFrom || sqIndex == lastTo) color = HighlightLastMove
|
||||
if (sqIndex == hint) color = HighlightHint
|
||||
if (sqIndex == selected) color = HighlightMove
|
||||
if (sqIndex == checkSquare) color = HighlightCheck
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(col * cell, row * cell),
|
||||
size = Size(cell, cell)
|
||||
)
|
||||
if (sqIndex == flash) {
|
||||
drawRect(color = flashColor, topLeft = Offset(col * cell, row * cell), size = Size(cell, cell))
|
||||
}
|
||||
|
||||
if (showCoords) {
|
||||
val labelColor = if (isLight) theme.dark else theme.light
|
||||
if (row == 7) {
|
||||
drawCoordinate(
|
||||
('a' + displayFile).toString(),
|
||||
Offset(col * cell + cell * 0.78f, row * cell + cell * 0.80f),
|
||||
cell, labelColor, coordPaint
|
||||
)
|
||||
}
|
||||
if (col == 0) {
|
||||
drawCoordinate(
|
||||
(displayRank + 1).toString(),
|
||||
Offset(col * cell + cell * 0.10f, row * cell + cell * 0.22f),
|
||||
cell, labelColor, coordPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
if ((targetMask shr sqIndex) and 1L != 0L) {
|
||||
drawCircle(
|
||||
color = HighlightMove,
|
||||
radius = cell * 0.14f,
|
||||
center = Offset(col * cell + cell / 2, row * cell + cell / 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pieceSize = cell * 0.86f
|
||||
val inset = (cell - pieceSize) / 2f
|
||||
val pieceDst = IntSize(pieceSize.roundToInt(), pieceSize.roundToInt())
|
||||
if (pieceImages == null) {
|
||||
glyphPaint.textSize = cell * 0.62f
|
||||
glyphPaint.color = GLYPH_COLOR
|
||||
}
|
||||
for (row in 0 until 8) {
|
||||
for (col in 0 until 8) {
|
||||
val displayRank = if (flipped) row else 7 - row
|
||||
val displayFile = if (flipped) 7 - col else col
|
||||
val pieceSq = displayRank * 8 + displayFile
|
||||
if (pieceSq == liftedSq) continue
|
||||
val piece = board.pieceAt(pieceSq) ?: continue
|
||||
if (pieceImages != null) {
|
||||
drawImage(
|
||||
image = pieceImages[PieceImages.indexOf(piece)],
|
||||
dstOffset = IntOffset(
|
||||
(col * cell + inset).roundToInt(),
|
||||
(row * cell + inset).roundToInt()
|
||||
),
|
||||
dstSize = pieceDst,
|
||||
filterQuality = FilterQuality.Medium
|
||||
)
|
||||
} else {
|
||||
val metrics = glyphPaint.fontMetrics
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
piece.unicode(),
|
||||
col * cell + cell / 2f,
|
||||
row * cell + cell / 2f - (metrics.ascent + metrics.descent) / 2f,
|
||||
glyphPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (liftedAt != null) {
|
||||
val liftedPiece = board.pieceAt(liftedSq)
|
||||
if (liftedPiece != null) {
|
||||
val hover = squareAt(liftedAt.x, liftedAt.y, size.width, flipped)
|
||||
if (hover >= 0 && ((targetMask shr hover) and 1L) != 0L) {
|
||||
val hRank = hover / 8
|
||||
val hFile = hover % 8
|
||||
val hRow = if (flipped) hRank else 7 - hRank
|
||||
val hCol = if (flipped) 7 - hFile else hFile
|
||||
drawRect(
|
||||
color = Color.White.copy(alpha = 0.75f),
|
||||
topLeft = Offset(hCol * cell, hRow * cell),
|
||||
size = Size(cell, cell),
|
||||
style = Stroke(width = cell * 0.07f)
|
||||
)
|
||||
}
|
||||
val liftedSize = pieceSize * LIFT_SCALE
|
||||
val cx = liftedAt.x
|
||||
val cy = liftedAt.y - cell * 0.55f
|
||||
drawOval(
|
||||
color = Color.Black.copy(alpha = 0.30f),
|
||||
topLeft = Offset(liftedAt.x - liftedSize * 0.36f, liftedAt.y + cell * 0.05f),
|
||||
size = Size(liftedSize * 0.72f, liftedSize * 0.28f)
|
||||
)
|
||||
if (pieceImages != null) {
|
||||
val liftedDst = IntSize(liftedSize.roundToInt(), liftedSize.roundToInt())
|
||||
drawImage(
|
||||
image = pieceImages[PieceImages.indexOf(liftedPiece)],
|
||||
dstOffset = IntOffset(
|
||||
(cx - liftedSize / 2f).roundToInt(),
|
||||
(cy - liftedSize / 2f).roundToInt()
|
||||
),
|
||||
dstSize = liftedDst,
|
||||
filterQuality = FilterQuality.Medium
|
||||
)
|
||||
} else {
|
||||
glyphPaint.textSize = cell * 0.62f * LIFT_SCALE
|
||||
val metrics = glyphPaint.fontMetrics
|
||||
drawContext.canvas.nativeCanvas.drawText(
|
||||
liftedPiece.unicode(),
|
||||
cx,
|
||||
cy - (metrics.ascent + metrics.descent) / 2f,
|
||||
glyphPaint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val LIFT_SCALE = 1.6f
|
||||
|
||||
private fun squareAt(x: Float, y: Float, boardPx: Float, flipped: Boolean): Int {
|
||||
if (x < 0f || y < 0f || x >= boardPx || y >= boardPx) return -1
|
||||
val cellPx = boardPx / 8f
|
||||
val col = floor(x / cellPx).toInt().coerceIn(0, 7)
|
||||
val row = floor(y / cellPx).toInt().coerceIn(0, 7)
|
||||
val displayRank = if (flipped) row else 7 - row
|
||||
val displayFile = if (flipped) 7 - col else col
|
||||
return displayRank * 8 + displayFile
|
||||
}
|
||||
|
||||
private val GLYPH_COLOR = Color(0xFF1B1B1B).toArgb()
|
||||
|
||||
private fun DrawScope.drawCoordinate(
|
||||
text: String,
|
||||
position: Offset,
|
||||
cell: Float,
|
||||
color: Color,
|
||||
paint: Paint
|
||||
) {
|
||||
paint.color = color.toArgb()
|
||||
paint.textSize = cell * 0.20f
|
||||
drawContext.canvas.nativeCanvas.drawText(text, position.x, position.y, paint)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import cc.skysparkle.matewave.ui.theme.LocalInk
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.PanelBorder
|
||||
import cc.skysparkle.matewave.ui.theme.PanelFill
|
||||
import cc.skysparkle.matewave.ui.theme.TabletMaxContentWidth
|
||||
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
|
||||
import cc.skysparkle.matewave.ui.theme.glass
|
||||
|
||||
private val UiShape: Shape = RoundedCornerShape(UiCornerRadius)
|
||||
|
||||
@Composable
|
||||
fun GlassScaffold(
|
||||
title: String,
|
||||
onBack: (() -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
scrollable: Boolean = true,
|
||||
|
||||
bottomBar: (@Composable () -> Unit)? = null,
|
||||
/** Replaces the plain title, e.g. with the opening name and its status badge. */
|
||||
titleContent: (@Composable RowScope.() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Column(modifier = Modifier.fillMaxSize().widthIn(max = TabletMaxContentWidth)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack) {
|
||||
AppIcon(
|
||||
R.drawable.ic_arrow_back,
|
||||
size = 24.dp,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.width(48.dp))
|
||||
}
|
||||
if (titleContent != null) {
|
||||
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, content = titleContent)
|
||||
} else {
|
||||
Text(
|
||||
title,
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
actions()
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val contentModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(if (scrollable) Modifier.verticalScroll(scrollState) else Modifier)
|
||||
.then(if (bottomBar == null) Modifier.navigationBarsPadding() else Modifier)
|
||||
.padding(bottom = 16.dp)
|
||||
|
||||
Column(
|
||||
modifier = contentModifier,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
content = content
|
||||
)
|
||||
|
||||
if (bottomBar != null) {
|
||||
Box(modifier = Modifier.navigationBarsPadding()) { bottomBar() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.pressScale(interactionSource: MutableInteractionSource): Modifier {
|
||||
val pressed by interactionSource.collectIsPressedAsState()
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (pressed) 0.97f else 1f,
|
||||
animationSpec = spring(dampingRatio = 0.6f, stiffness = 900f),
|
||||
label = "pressScale"
|
||||
)
|
||||
return this.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
|
||||
CompositionLocalProvider(LocalInk provides Color.White) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.pressScale(interaction)
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.clip(UiShape)
|
||||
.background(ChessGreen)
|
||||
.then(
|
||||
if (enabled) Modifier.clickable(
|
||||
interactionSource = interaction,
|
||||
indication = LocalIndication.current,
|
||||
onClick = onClick
|
||||
) else Modifier
|
||||
)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassOutlinedButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
Row(
|
||||
modifier = modifier
|
||||
.pressScale(interaction)
|
||||
.alpha(if (enabled) 1f else 0.4f)
|
||||
.clip(UiShape)
|
||||
.background(PanelFill)
|
||||
.border(1.dp, PanelBorder, UiShape)
|
||||
.then(
|
||||
if (enabled) Modifier.clickable(
|
||||
interactionSource = interaction,
|
||||
indication = LocalIndication.current,
|
||||
onClick = onClick
|
||||
) else Modifier
|
||||
)
|
||||
.padding(horizontal = 20.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassCard(modifier: Modifier = Modifier, shape: Shape = UiShape, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.glass(shape)
|
||||
.border(1.dp, PanelBorder, shape)
|
||||
.padding(14.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GlassProgress(text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
CircularProgressIndicator(color = ink, modifier = Modifier.padding(2.dp))
|
||||
Text(text, color = ink, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
|
||||
import cc.skysparkle.matewave.ui.theme.glass
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
/** Small uppercase caption above a group. */
|
||||
@Composable
|
||||
fun SectionCaption(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text.uppercase(),
|
||||
color = ink.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = modifier.padding(start = 4.dp, top = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/** Rows of one kind in a single block, instead of a card per row. Separate rows with [ListDivider]. */
|
||||
@Composable
|
||||
fun ListGroup(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.glass(RoundedCornerShape(UiCornerRadius))
|
||||
.padding(vertical = 2.dp),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListDivider(inset: Int = 52) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = inset.dp)
|
||||
.height(1.dp)
|
||||
.background(ink.copy(alpha = 0.07f))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One row: optional icon or custom leading content, title with an optional subtitle, an
|
||||
* optional grey value on the right, custom trailing content and a chevron when it navigates.
|
||||
*/
|
||||
@Composable
|
||||
fun ListRow(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
icon: Int? = null,
|
||||
iconTint: Color = ink,
|
||||
titleColor: Color = ink,
|
||||
subtitleColor: Color = ink.copy(alpha = 0.6f),
|
||||
value: String? = null,
|
||||
valueColor: Color = ink.copy(alpha = 0.55f),
|
||||
chevron: Boolean = false,
|
||||
leading: (@Composable () -> Unit)? = null,
|
||||
trailing: (@Composable RowScope.() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
when {
|
||||
leading != null -> { leading(); Spacer(Modifier.width(12.dp)) }
|
||||
icon != null -> { AppIcon(icon, size = 22.dp, tint = iconTint); Spacer(Modifier.width(14.dp)) }
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, color = titleColor, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, color = subtitleColor, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (value != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(value, color = valueColor, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
}
|
||||
if (trailing != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
trailing()
|
||||
}
|
||||
if (chevron) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
AppIcon(R.drawable.ic_arrow_forward, size = 16.dp, tint = ink.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.engine.Board
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.openings.OpeningName
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
private data class MoveToken(val text: String, val moveIndex: Int?)
|
||||
|
||||
private val BookTint = androidx.compose.ui.graphics.Color(0xFF93DBA3)
|
||||
private val OutOfBookTint = androidx.compose.ui.graphics.Color(0xFFFFB86B)
|
||||
|
||||
/**
|
||||
* Compact move list that scrolls to the current move, with the opening name underneath.
|
||||
* [currentIndex] is the number of moves played to reach the shown position.
|
||||
*/
|
||||
@Composable
|
||||
fun MovesBar(
|
||||
sans: List<String>,
|
||||
startBoard: Board,
|
||||
currentIndex: Int,
|
||||
opening: OpeningName?,
|
||||
modifier: Modifier = Modifier,
|
||||
/** Leading moves that are opening theory; they are tinted in the list. */
|
||||
bookPlies: Int = 0,
|
||||
/** "Theory" or "Out of book from move N"; null hides the status. */
|
||||
bookStatus: String? = null,
|
||||
inBook: Boolean = true,
|
||||
onMoveClick: ((Int) -> Unit)? = null
|
||||
) {
|
||||
if (sans.isEmpty() && opening == null) return
|
||||
val tokens = remember(sans, startBoard) {
|
||||
buildList {
|
||||
sans.forEachIndexed { i, san ->
|
||||
val whiteMove = (startBoard.sideToMove == PieceColor.WHITE) == (i % 2 == 0)
|
||||
val number = startBoard.fullmoveNumber + (i + if (startBoard.sideToMove == PieceColor.BLACK) 1 else 0) / 2
|
||||
if (whiteMove) add(MoveToken("$number.", null))
|
||||
else if (i == 0) add(MoveToken("$number…", null))
|
||||
add(MoveToken(san, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(currentIndex, tokens.size) {
|
||||
val target = tokens.indexOfFirst { it.moveIndex == currentIndex - 1 }
|
||||
if (target >= 0) listState.animateScrollToItem(maxOf(0, target - 3))
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (sans.isNotEmpty()) {
|
||||
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
itemsIndexed(tokens) { _, token ->
|
||||
val index = token.moveIndex
|
||||
if (index == null) {
|
||||
Text(
|
||||
token.text,
|
||||
color = ink.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 6.dp, top = 4.dp, bottom = 4.dp)
|
||||
)
|
||||
} else {
|
||||
val current = index == currentIndex - 1
|
||||
Text(
|
||||
token.text,
|
||||
color = if (index < bookPlies) BookTint else ink,
|
||||
fontWeight = if (current) FontWeight.Bold else FontWeight.Medium,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (current) Modifier.background(ChessGreen.copy(alpha = 0.45f)) else Modifier)
|
||||
.then(if (onMoveClick != null) Modifier.clickable { onMoveClick(index + 1) } else Modifier)
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opening != null || bookStatus != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppIcon(R.drawable.ic_book, size = 14.dp, tint = if (inBook) BookTint else ink.copy(alpha = 0.5f))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
opening?.let { "${it.eco} · ${it.name}" } ?: "",
|
||||
color = ink.copy(alpha = 0.85f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
if (bookStatus != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
bookStatus,
|
||||
color = if (inBook) BookTint else OutOfBookTint,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background((if (inBook) BookTint else OutOfBookTint).copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import cc.skysparkle.matewave.R
|
||||
|
||||
/**
|
||||
* Asks for [permissions] after a short explanation. Once Android stops showing its own prompt
|
||||
* (the user declined twice), the explanation leads to the app's system settings instead, so
|
||||
* the button never silently does nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberPermissionRequester(
|
||||
permissions: List<String>,
|
||||
rationaleTitle: String,
|
||||
rationaleText: String,
|
||||
onResult: (granted: Boolean) -> Unit
|
||||
): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
var showRationale by remember { mutableStateOf(false) }
|
||||
var openSettings by remember { mutableStateOf(false) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
|
||||
onResult(results.values.all { it })
|
||||
}
|
||||
|
||||
if (showRationale) {
|
||||
PermissionRationaleDialog(
|
||||
title = rationaleTitle,
|
||||
text = rationaleText,
|
||||
confirmText = stringResource(if (openSettings) R.string.permission_open_settings else R.string.permission_allow),
|
||||
onConfirm = {
|
||||
showRationale = false
|
||||
if (openSettings) {
|
||||
openAppSettings(context)
|
||||
onResult(false)
|
||||
} else {
|
||||
markRequested(context, permissions)
|
||||
launcher.launch(permissions.toTypedArray())
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
showRationale = false
|
||||
onResult(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
val allGranted = permissions.all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (allGranted) {
|
||||
onResult(true)
|
||||
} else {
|
||||
openSettings = permanentlyDenied(context, permissions)
|
||||
showRationale = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val REQUESTS_PREFS = "permission_requests"
|
||||
|
||||
private fun markRequested(context: Context, permissions: List<String>) {
|
||||
val editor = context.getSharedPreferences(REQUESTS_PREFS, Context.MODE_PRIVATE).edit()
|
||||
permissions.forEach { editor.putBoolean(it, true) }
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
/** Asked before, still denied, and Android would not show its prompt again. */
|
||||
private fun permanentlyDenied(context: Context, permissions: List<String>): Boolean {
|
||||
val activity = context.findActivity() ?: return false
|
||||
val prefs = context.getSharedPreferences(REQUESTS_PREFS, Context.MODE_PRIVATE)
|
||||
return permissions.any { permission ->
|
||||
prefs.getBoolean(permission, false) &&
|
||||
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED &&
|
||||
!ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(context: Context) {
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}"))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionRationaleDialog(
|
||||
title: String,
|
||||
text: String,
|
||||
confirmText: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = title,
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.permission_not_now), onDismiss)
|
||||
DialogAction(confirmText, onConfirm)
|
||||
}
|
||||
) {
|
||||
DialogText(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.engine.Piece
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.engine.PieceType
|
||||
import cc.skysparkle.matewave.ui.theme.PanelBorder
|
||||
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
/** The four promotion pieces, drawn with the same images as the board. */
|
||||
@Composable
|
||||
fun PromotionDialog(color: PieceColor, onChoose: (PieceType) -> Unit, onDismiss: () -> Unit) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.promotion_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
|
||||
}
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(PieceType.QUEEN, PieceType.ROOK, PieceType.BISHOP, PieceType.KNIGHT).forEach { type ->
|
||||
val optionShape = RoundedCornerShape(UiCornerRadius)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.clip(optionShape)
|
||||
.background(ink.copy(alpha = 0.10f))
|
||||
.border(1.dp, PanelBorder, optionShape)
|
||||
.clickable { onChoose(type) }
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(Piece(type, color).drawableRes()),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
|
||||
@Composable
|
||||
fun SetupSectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text.uppercase(),
|
||||
color = ink.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SetupSegmented(options: List<String>, selectedIndex: Int, onSelect: (Int) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(ink.copy(alpha = 0.08f))
|
||||
.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
options.forEachIndexed { index, label ->
|
||||
val selected = index == selectedIndex
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (selected) ChessGreen.copy(alpha = 0.35f) else Color.Transparent)
|
||||
.clickable { onSelect(index) }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(label, color = ink, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package cc.skysparkle.matewave.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.clock.TimeControl
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
/** "10+0" from "Rapid 10+0"; the infinity sign for no limit. */
|
||||
fun TimeControl.shortLabel(): String = if (this == TimeControl.NO_LIMIT) "∞" else label.substringAfterLast(' ')
|
||||
|
||||
@Composable
|
||||
fun TimeControl.displayName(): String =
|
||||
if (this == TimeControl.NO_LIMIT) stringResource(R.string.time_no_limit) else shortLabel()
|
||||
|
||||
/** Every time control visible at once, grouped by category, instead of a clipped scrolling row. */
|
||||
@Composable
|
||||
fun TimeControlGrid(selected: TimeControl, onSelect: (TimeControl) -> Unit) {
|
||||
val groups = listOf(
|
||||
R.string.time_cat_bullet to listOf(TimeControl.BULLET_1_0, TimeControl.BULLET_2_1),
|
||||
R.string.time_cat_blitz to listOf(TimeControl.BLITZ_3_0, TimeControl.BLITZ_5_0, TimeControl.BLITZ_5_3),
|
||||
R.string.time_cat_rapid to listOf(TimeControl.RAPID_10_0, TimeControl.RAPID_15_10),
|
||||
R.string.time_cat_classical to listOf(TimeControl.CLASSICAL_30_0, TimeControl.CLASSICAL_30_20),
|
||||
R.string.time_no_limit to listOf(TimeControl.NO_LIMIT)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
groups.forEach { (title, options) ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(title),
|
||||
color = ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.width(110.dp)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
options.forEach { tc ->
|
||||
val active = tc == selected
|
||||
val shape = RoundedCornerShape(50)
|
||||
Text(
|
||||
tc.shortLabel(),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(if (active) ChessGreen.copy(alpha = 0.35f) else ink.copy(alpha = 0.08f))
|
||||
.border(1.dp, if (active) ChessGreen else ink.copy(alpha = 0.12f), shape)
|
||||
.clickable { onSelect(tc) }
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.components.ListDivider
|
||||
import cc.skysparkle.matewave.ui.components.ListGroup
|
||||
import cc.skysparkle.matewave.ui.components.ListRow
|
||||
import cc.skysparkle.matewave.ui.components.SectionCaption
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.components.DialogText
|
||||
import cc.skysparkle.matewave.ui.components.DialogAction
|
||||
import cc.skysparkle.matewave.ui.components.AppDialog
|
||||
import cc.skysparkle.matewave.ui.components.AppBottomBar
|
||||
import cc.skysparkle.matewave.ui.components.BottomTab
|
||||
import cc.skysparkle.matewave.data.ProfileStore
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.rememberChatImage
|
||||
import cc.skysparkle.matewave.ui.components.encodeImageForChat
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.PanelBorder
|
||||
import cc.skysparkle.matewave.ui.theme.whiteOutlinedTextFieldColors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel,
|
||||
onHomeClick: () -> Unit,
|
||||
onFriendsClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onStudyClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var profile by remember { mutableStateOf(ProfileStore.get()) }
|
||||
var showLevelPicker by remember { mutableStateOf(false) }
|
||||
var showForgetConfirm by remember { mutableStateOf(false) }
|
||||
var editingName by remember { mutableStateOf(false) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
val encoded = withContext(Dispatchers.IO) { encodeImageForChat(context, uri) }
|
||||
if (encoded != null) {
|
||||
ProfileStore.setBackground(encoded)
|
||||
profile = ProfileStore.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
val avatarPicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
scope.launch {
|
||||
val encoded = withContext(Dispatchers.IO) {
|
||||
encodeImageForChat(
|
||||
context, uri,
|
||||
cc.skysparkle.matewave.ui.components.AVATAR_MAX_DIMENSION_PX,
|
||||
cc.skysparkle.matewave.ui.components.AVATAR_JPEG_QUALITY
|
||||
)
|
||||
}
|
||||
if (encoded != null) {
|
||||
ProfileStore.setAvatar(encoded)
|
||||
profile = ProfileStore.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val myFingerprint = remember {
|
||||
cc.skysparkle.matewave.security.DeviceKeys.publicKeyFingerprint(context)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { studyViewModel.init(context) }
|
||||
val study by studyViewModel.overview.collectAsState()
|
||||
var showStats by remember { mutableStateOf(false) }
|
||||
var showSecurity by remember { mutableStateOf(false) }
|
||||
|
||||
GlassScaffold(
|
||||
title = stringResource(R.string.account_title),
|
||||
onBack = null,
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
current = BottomTab.ACCOUNT,
|
||||
onHomeClick = onHomeClick,
|
||||
onFriendsClick = onFriendsClick,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onAccountClick = { },
|
||||
accountAvatarBase64 = profile.avatarBase64
|
||||
)
|
||||
}
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(cc.skysparkle.matewave.ui.theme.LocalInk provides Color.White) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(168.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.clickable { backgroundPicker.launch("image/*") }
|
||||
) {
|
||||
val bgBitmap = rememberChatImage(profile.backgroundBase64)
|
||||
if (bgBitmap != null) {
|
||||
Image(
|
||||
bitmap = bgBitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().height(168.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.bg_profile_default),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().height(168.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(168.dp).background(
|
||||
Brush.verticalGradient(
|
||||
0.0f to Color.Black.copy(alpha = 0.30f),
|
||||
0.55f to Color.Black.copy(alpha = 0.45f),
|
||||
1.0f to Color.Black.copy(alpha = 0.72f)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(10.dp)
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable { backgroundPicker.launch("image/*") },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AppIcon(R.drawable.ic_add_photo, size = 20.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Box {
|
||||
AvatarCircle(
|
||||
base64 = profile.avatarBase64,
|
||||
size = 76.dp,
|
||||
modifier = Modifier.clickable { avatarPicker.launch("image/*") }
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ChessGreen)
|
||||
.clickable { avatarPicker.launch("image/*") },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AppIcon(R.drawable.ic_edit, size = 14.dp)
|
||||
}
|
||||
}
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
profile.name,
|
||||
color = ink,
|
||||
fontSize = 23.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
AppIcon(
|
||||
R.drawable.ic_edit,
|
||||
size = 18.dp,
|
||||
tint = ink.copy(alpha = 0.75f),
|
||||
modifier = Modifier.clickable { editingName = true }
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.account_header_summary, profile.elo, cc.skysparkle.matewave.stats.Stats.gamesPlayed()),
|
||||
color = ink.copy(alpha = 0.72f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profile.avatarBase64 != null || profile.backgroundBase64 != null) {
|
||||
Text(
|
||||
stringResource(R.string.account_reset_avatar),
|
||||
color = ink.copy(alpha = 0.55f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.clickable {
|
||||
ProfileStore.resetAvatarToDefault()
|
||||
ProfileStore.setBackground(null)
|
||||
profile = ProfileStore.get()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!profile.levelChosen) {
|
||||
GlassOutlinedButton(onClick = { showLevelPicker = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.account_choose_level), color = ink)
|
||||
}
|
||||
}
|
||||
|
||||
// Progress: the reason to open a profile. The rating is shown here once, with its trend.
|
||||
SectionCaption(stringResource(R.string.account_progress))
|
||||
ListGroup {
|
||||
val curve = remember(profile.elo) { cc.skysparkle.matewave.stats.Stats.ratingCurve(profile.elo) }
|
||||
ListRow(
|
||||
title = stringResource(R.string.account_rating_row),
|
||||
icon = R.drawable.ic_license,
|
||||
iconTint = ChessGreen,
|
||||
value = "${profile.elo}",
|
||||
valueColor = ink,
|
||||
trailing = { MiniRatingGraph(points = curve, modifier = Modifier.width(64.dp).height(24.dp)) },
|
||||
chevron = true,
|
||||
onClick = { showStats = true }
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.study_title),
|
||||
icon = R.drawable.ic_crown,
|
||||
value = "${study.learnedCount}/${study.courses.size} · ★${study.masteredCount}",
|
||||
chevron = true,
|
||||
onClick = onStudyClick
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.puzzles_title),
|
||||
icon = R.drawable.ic_puzzle,
|
||||
value = stringResource(R.string.puzzle_solved_count, cc.skysparkle.matewave.stats.Stats.solvedPuzzles().size)
|
||||
)
|
||||
}
|
||||
|
||||
// Security folded into one row; the details live in a dialog.
|
||||
if (myFingerprint != null) {
|
||||
SectionCaption(stringResource(R.string.account_section_security))
|
||||
ListGroup {
|
||||
ListRow(
|
||||
title = stringResource(R.string.account_device_key),
|
||||
icon = R.drawable.ic_key,
|
||||
value = myFingerprint.take(4).uppercase() + "…" + myFingerprint.takeLast(4).uppercase(),
|
||||
chevron = true,
|
||||
onClick = { showSecurity = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showStats) StatsDialog(onDismiss = { showStats = false })
|
||||
|
||||
if (showSecurity && myFingerprint != null) {
|
||||
AppDialog(
|
||||
onDismissRequest = { showSecurity = false; copied = false },
|
||||
title = stringResource(R.string.account_section_security),
|
||||
actions = { DialogAction(stringResource(R.string.chat_close), { showSecurity = false; copied = false }) }
|
||||
) {
|
||||
Text(stringResource(R.string.account_fingerprint), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.labelMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(groupFingerprint(myFingerprint), color = ink, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
AppIcon(
|
||||
R.drawable.ic_copy,
|
||||
size = 20.dp,
|
||||
modifier = Modifier.clickable {
|
||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("fingerprint", myFingerprint))
|
||||
copied = true
|
||||
}
|
||||
)
|
||||
AppIcon(
|
||||
R.drawable.ic_share,
|
||||
size = 20.dp,
|
||||
modifier = Modifier.clickable {
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(android.content.Intent.EXTRA_TEXT, groupFingerprint(myFingerprint))
|
||||
}
|
||||
context.startActivity(android.content.Intent.createChooser(send, null))
|
||||
}
|
||||
)
|
||||
}
|
||||
if (copied) {
|
||||
Text(stringResource(R.string.account_fingerprint_copied), color = ChessGreen, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
DialogText(stringResource(R.string.account_fingerprint_explain))
|
||||
DialogText(stringResource(R.string.account_trusted_keys_hint))
|
||||
Text(
|
||||
stringResource(R.string.account_forget_keys),
|
||||
color = Color(0xFFFF9B9B),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.clickable { showSecurity = false; showForgetConfirm = true }
|
||||
.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (editingName) {
|
||||
EditNameDialog(
|
||||
initial = profile.name,
|
||||
onSave = { newName ->
|
||||
ProfileStore.setName(newName)
|
||||
profile = ProfileStore.get()
|
||||
// Nearby players should see the new name right away.
|
||||
cc.skysparkle.matewave.network.Transports.lan(context).republishProfile()
|
||||
cc.skysparkle.matewave.network.Transports.ble(context).republishProfile()
|
||||
editingName = false
|
||||
},
|
||||
onDismiss = { editingName = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showForgetConfirm) {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.account_forget_keys),
|
||||
text = stringResource(R.string.account_forget_keys_confirm),
|
||||
onConfirm = {
|
||||
cc.skysparkle.matewave.security.KnownPeers(context).forgetAll()
|
||||
showForgetConfirm = false
|
||||
},
|
||||
onDismiss = { showForgetConfirm = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showLevelPicker) {
|
||||
LevelPickerDialog(
|
||||
onChoose = { elo ->
|
||||
ProfileStore.setStartingElo(elo)
|
||||
profile = ProfileStore.get()
|
||||
cc.skysparkle.matewave.network.Transports.lan(context).republishProfile()
|
||||
showLevelPicker = false
|
||||
},
|
||||
onDismiss = { showLevelPicker = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_NAME_LENGTH = 20
|
||||
|
||||
@Composable
|
||||
private fun EditNameDialog(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
||||
var value by remember { mutableStateOf(initial) }
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.account_name),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(
|
||||
stringResource(R.string.account_save_name),
|
||||
onClick = { if (value.isNotBlank()) onSave(value.trim()) },
|
||||
enabled = value.isNotBlank()
|
||||
)
|
||||
}
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= MAX_NAME_LENGTH) value = it },
|
||||
singleLine = true,
|
||||
colors = whiteOutlinedTextFieldColors(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
"${value.length} / $MAX_NAME_LENGTH",
|
||||
color = ink.copy(alpha = 0.45f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun groupFingerprint(fingerprint: String): String =
|
||||
fingerprint.uppercase().chunked(4).joinToString(" ")
|
||||
|
||||
@Composable
|
||||
private fun ConfirmDialog(title: String, text: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = title,
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(stringResource(R.string.account_confirm), onConfirm)
|
||||
}
|
||||
) {
|
||||
DialogText(text)
|
||||
}
|
||||
}
|
||||
|
||||
private data class SkillLevel(val labelRes: Int, val elo: Int)
|
||||
|
||||
private val SKILL_LEVELS = listOf(
|
||||
SkillLevel(R.string.skill_novice, 800),
|
||||
SkillLevel(R.string.skill_amateur, 1200),
|
||||
SkillLevel(R.string.skill_pro, 1800),
|
||||
SkillLevel(R.string.skill_master, 2200)
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LevelPickerDialog(onChoose: (Int) -> Unit, onDismiss: () -> Unit) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.account_level_dialog_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
|
||||
}
|
||||
) {
|
||||
SKILL_LEVELS.forEach { level ->
|
||||
val rowShape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(rowShape)
|
||||
.background(ink.copy(alpha = 0.07f))
|
||||
.clickable { onChoose(level.elo) }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(level.labelRes), color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text("~${level.elo}", color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AvatarCircle(base64: String?, size: Dp, modifier: Modifier = Modifier) {
|
||||
val bitmap = rememberChatImage(base64, cc.skysparkle.matewave.ui.components.AVATAR_MAX_DIMENSION_PX)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(ChessGreen.copy(alpha = 0.6f))
|
||||
.border(2.dp, PanelBorder, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size).clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_default_avatar),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(ink),
|
||||
modifier = Modifier.size(size * 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.settings.BoardAppearance
|
||||
import cc.skysparkle.matewave.settings.BoardTheme
|
||||
import cc.skysparkle.matewave.ui.components.GlassCard
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.theme.GlassBorder
|
||||
|
||||
@Composable
|
||||
fun AppearanceScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val appearance = remember { BoardAppearance(context) }
|
||||
|
||||
var theme by remember { mutableStateOf(appearance.theme) }
|
||||
var coords by remember { mutableStateOf(appearance.showCoordinates) }
|
||||
var images by remember { mutableStateOf(appearance.useImagePieces) }
|
||||
var lastMove by remember { mutableStateOf(appearance.showLastMove) }
|
||||
var checkGlow by remember { mutableStateOf(appearance.showCheckHighlight) }
|
||||
var openingBar by remember { mutableStateOf(appearance.showOpening) }
|
||||
|
||||
GlassScaffold(title = stringResource(R.string.appearance_title), onBack = onBack) {
|
||||
Text(
|
||||
stringResource(R.string.appearance_board_theme),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
BoardTheme.entries.forEach { candidate ->
|
||||
GlassCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable {
|
||||
theme = candidate
|
||||
appearance.theme = candidate
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
ThemePreview(candidate, showPieces = images)
|
||||
Text(
|
||||
stringResource(candidate.titleRes),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (candidate == theme) {
|
||||
cc.skysparkle.matewave.ui.components.AppIcon(
|
||||
R.drawable.ic_check_box, size = 20.dp, tint = Color(0xFF7BE495)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppearanceToggle(
|
||||
title = stringResource(R.string.appearance_coordinates),
|
||||
subtitle = stringResource(R.string.appearance_coordinates_hint),
|
||||
checked = coords
|
||||
) { coords = it; appearance.showCoordinates = it }
|
||||
|
||||
AppearanceToggle(
|
||||
title = stringResource(R.string.appearance_image_pieces),
|
||||
subtitle = stringResource(R.string.appearance_image_pieces_hint),
|
||||
checked = images
|
||||
) { images = it; appearance.useImagePieces = it }
|
||||
|
||||
AppearanceToggle(
|
||||
title = stringResource(R.string.appearance_last_move),
|
||||
subtitle = stringResource(R.string.appearance_last_move_hint),
|
||||
checked = lastMove
|
||||
) { lastMove = it; appearance.showLastMove = it }
|
||||
|
||||
AppearanceToggle(
|
||||
title = stringResource(R.string.appearance_check),
|
||||
subtitle = stringResource(R.string.appearance_check_hint),
|
||||
checked = checkGlow
|
||||
) { checkGlow = it; appearance.showCheckHighlight = it }
|
||||
|
||||
AppearanceToggle(
|
||||
title = stringResource(R.string.appearance_opening_in_game),
|
||||
subtitle = stringResource(R.string.appearance_opening_in_game_hint),
|
||||
checked = openingBar
|
||||
) { openingBar = it; appearance.showOpening = it }
|
||||
|
||||
Text(
|
||||
stringResource(R.string.appearance_restart_hint),
|
||||
color = ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePreview(theme: BoardTheme, showPieces: Boolean) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Column(modifier = Modifier.size(56.dp).clip(shape).border(1.dp, GlassBorder, shape)) {
|
||||
repeat(2) { row ->
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
repeat(2) { col ->
|
||||
val light = (row + col) % 2 == 0
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.background(if (light) theme.light else theme.dark),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (showPieces && row == 0 && col == 0) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.piece_white_king),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
if (showPieces && row == 1 && col == 1) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.piece_black_knight),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceToggle(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text(subtitle, color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = Color(0xFF4F8A5B)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import cc.skysparkle.matewave.ui.components.ListDivider
|
||||
import cc.skysparkle.matewave.ui.components.ListGroup
|
||||
import cc.skysparkle.matewave.ui.components.ListRow
|
||||
import cc.skysparkle.matewave.ui.components.SectionCaption
|
||||
import cc.skysparkle.matewave.ui.components.TimeControlGrid
|
||||
import cc.skysparkle.matewave.ui.components.shortLabel
|
||||
import cc.skysparkle.matewave.ui.components.AppDialog
|
||||
import cc.skysparkle.matewave.ui.components.DialogAction
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.components.ButtonIcon
|
||||
import cc.skysparkle.matewave.clock.TimeControl
|
||||
import cc.skysparkle.matewave.data.FriendsStore
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import cc.skysparkle.matewave.network.AppPermissions
|
||||
import cc.skysparkle.matewave.network.NetworkRequirements
|
||||
import kotlinx.coroutines.delay
|
||||
import cc.skysparkle.matewave.network.NearbyPlayer
|
||||
import cc.skysparkle.matewave.network.Transports
|
||||
import cc.skysparkle.matewave.network.TransportType
|
||||
import cc.skysparkle.matewave.presence.LocalPresence
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.GlassButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.rememberPermissionRequester
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
|
||||
private enum class Hint { BLUETOOTH_PERMISSION, BLUETOOTH_OFF, WIFI_OFF }
|
||||
|
||||
private val WarningAmber = Color(0xFFFFC46B)
|
||||
|
||||
/** A player found over either transport. */
|
||||
private data class NearbyEntry(
|
||||
val name: String,
|
||||
val elo: Int?,
|
||||
val type: TransportType,
|
||||
val address: String,
|
||||
val avatar: String?
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ConnectionScreen(
|
||||
presence: LocalPresence,
|
||||
onStartHost: (TimeControl) -> Unit,
|
||||
onJoin: (TransportType, TimeControl, targetId: String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val last = remember { cc.skysparkle.matewave.settings.LastGameSetup(context) }
|
||||
var timeControl by remember { mutableStateOf(last.nearbyTime) }
|
||||
var pickTime by remember { mutableStateOf(false) }
|
||||
var btPlayers by remember { mutableStateOf<List<NearbyPlayer>>(emptyList()) }
|
||||
var btAllowed by remember { mutableStateOf(AppPermissions.allGranted(context, AppPermissions.nearby)) }
|
||||
val profile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
|
||||
val visible = remember { cc.skysparkle.matewave.presence.PresenceSettings(context).announceEnabled }
|
||||
var network by remember { mutableStateOf(NetworkRequirements.check(context)) }
|
||||
|
||||
fun startBluetooth() {
|
||||
val ble = Transports.ble(context)
|
||||
runCatching {
|
||||
ble.startPresence()
|
||||
ble.discoverNearby { found -> btPlayers = found }
|
||||
}
|
||||
}
|
||||
|
||||
// Wi-Fi and Bluetooth can be switched on while the screen is open: notice it and search again.
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(3_000)
|
||||
val now = NetworkRequirements.check(context)
|
||||
if (btAllowed && now.bluetoothOn && !network.bluetoothOn) startBluetooth()
|
||||
if (!now.bluetoothOn) btPlayers = emptyList()
|
||||
if (now.wifiConnected && !network.wifiConnected) presence.refresh()
|
||||
network = now
|
||||
}
|
||||
}
|
||||
val enableBluetooth = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { }
|
||||
|
||||
val requestNearby = rememberPermissionRequester(
|
||||
permissions = AppPermissions.nearby,
|
||||
rationaleTitle = stringResource(R.string.mesh_permission_title),
|
||||
rationaleText = stringResource(R.string.mesh_permission_text),
|
||||
onResult = { granted -> btAllowed = granted }
|
||||
)
|
||||
|
||||
// One search over both transports: Wi-Fi presence runs app-wide, Bluetooth while this screen is open.
|
||||
DisposableEffect(btAllowed) {
|
||||
if (btAllowed) startBluetooth()
|
||||
onDispose {
|
||||
if (btAllowed) runCatching {
|
||||
val ble = Transports.ble(context)
|
||||
ble.stopDiscovery()
|
||||
ble.stopPresenceIfIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val peers by presence.peers.collectAsState()
|
||||
val friends = remember(peers) { FriendsStore.list() }
|
||||
val entries = remember(peers, btPlayers, friends) {
|
||||
val lan = peers.values.map { peer ->
|
||||
NearbyEntry(
|
||||
name = peer.name,
|
||||
elo = peer.elo,
|
||||
type = TransportType.LAN,
|
||||
address = peer.address,
|
||||
avatar = friends.firstOrNull { it.userId == peer.playerId }?.avatarBase64
|
||||
)
|
||||
}
|
||||
val lanFingerprints = peers.values.map { it.fingerprint }.filter { it.isNotEmpty() }
|
||||
// The same phone seen over both channels is listed once, over Wi-Fi. Bluetooth carries
|
||||
// a shorter fingerprint: the first bytes of the same key hash.
|
||||
val bt = btPlayers.filter { player ->
|
||||
val short = player.fingerprint
|
||||
short.isNullOrEmpty() || lanFingerprints.none { it.startsWith(short) }
|
||||
}.map {
|
||||
NearbyEntry(name = it.name, elo = null, type = TransportType.BLUETOOTH, address = it.address, avatar = null)
|
||||
}
|
||||
(lan + bt).sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
GlassScaffold(
|
||||
title = stringResource(R.string.vsplayer_nearby_title),
|
||||
onBack = onBack,
|
||||
bottomBar = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
GlassButton(
|
||||
onClick = { last.nearbyTime = timeControl; onStartHost(timeControl) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
ButtonIcon(R.drawable.ic_add, size = 20.dp)
|
||||
Text(stringResource(R.string.nearby_create), color = ink, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||
}
|
||||
GlassOutlinedButton(onClick = { pickTime = true }) {
|
||||
Text(timeControl.shortLabel() + " ▾", color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
SearchPulse()
|
||||
Text(
|
||||
if (visible) stringResource(R.string.nearby_visible_as, profile.name) else stringResource(R.string.nearby_hidden),
|
||||
color = ink.copy(alpha = 0.65f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// What keeps others from being found, each with the fix one tap away.
|
||||
val hints = listOfNotNull(
|
||||
Hint.BLUETOOTH_PERMISSION.takeIf { !btAllowed && network.bluetoothSupported },
|
||||
Hint.BLUETOOTH_OFF.takeIf { btAllowed && network.bluetoothSupported && !network.bluetoothOn },
|
||||
Hint.WIFI_OFF.takeIf { !network.wifiConnected }
|
||||
)
|
||||
if (hints.isNotEmpty()) {
|
||||
ListGroup {
|
||||
hints.forEachIndexed { index, hint ->
|
||||
if (index > 0) ListDivider()
|
||||
when (hint) {
|
||||
Hint.BLUETOOTH_PERMISSION -> ListRow(
|
||||
title = stringResource(R.string.nearby_enable_bt),
|
||||
icon = R.drawable.ic_bluetooth,
|
||||
chevron = true,
|
||||
onClick = { requestNearby() }
|
||||
)
|
||||
Hint.BLUETOOTH_OFF -> ListRow(
|
||||
title = stringResource(R.string.reason_bluetooth_off),
|
||||
subtitle = stringResource(R.string.nearby_bt_off_hint),
|
||||
icon = R.drawable.ic_bluetooth,
|
||||
iconTint = WarningAmber,
|
||||
chevron = true,
|
||||
onClick = {
|
||||
runCatching { enableBluetooth.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
|
||||
}
|
||||
)
|
||||
Hint.WIFI_OFF -> ListRow(
|
||||
title = stringResource(R.string.reason_wifi_unavailable),
|
||||
subtitle = stringResource(R.string.nearby_wifi_hint),
|
||||
icon = R.drawable.ic_wifi,
|
||||
iconTint = WarningAmber
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.nearby_empty),
|
||||
color = ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
SectionCaption(stringResource(R.string.nearby_players))
|
||||
ListGroup {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
if (index > 0) ListDivider(inset = 68)
|
||||
val transport = stringResource(if (entry.type == TransportType.LAN) R.string.transport_wifi else R.string.transport_bluetooth)
|
||||
ListRow(
|
||||
title = entry.name.ifBlank { stringResource(R.string.players_unknown_name) },
|
||||
subtitle = listOfNotNull(entry.elo?.toString(), transport).joinToString(" · "),
|
||||
leading = { AvatarCircle(base64 = entry.avatar, size = 40.dp) },
|
||||
trailing = {
|
||||
AppIcon(
|
||||
if (entry.type == TransportType.LAN) R.drawable.ic_wifi_local else R.drawable.ic_bluetooth,
|
||||
size = 16.dp,
|
||||
tint = ink.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.players_play),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(ChessGreen)
|
||||
.clickable { onJoin(entry.type, timeControl, entry.address) }
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pickTime) {
|
||||
AppDialog(
|
||||
onDismissRequest = { pickTime = false },
|
||||
title = stringResource(R.string.time_control_label),
|
||||
actions = { DialogAction(stringResource(R.string.chat_close), { pickTime = false }) }
|
||||
) {
|
||||
TimeControlGrid(selected = timeControl, onSelect = { timeControl = it; last.nearbyTime = it; pickTime = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Concentric rings pulsing around the knight: the search is always running. */
|
||||
@Composable
|
||||
private fun SearchPulse() {
|
||||
val transition = androidx.compose.animation.core.rememberInfiniteTransition(label = "pulse")
|
||||
val phase by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = androidx.compose.animation.core.infiniteRepeatable(
|
||||
androidx.compose.animation.core.tween(1800, easing = androidx.compose.animation.core.LinearEasing)
|
||||
),
|
||||
label = "phase"
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
|
||||
androidx.compose.foundation.Canvas(modifier = Modifier.size(120.dp)) {
|
||||
for (k in 0 until 3) {
|
||||
val p = (phase + k / 3f) % 1f
|
||||
drawCircle(
|
||||
color = ChessGreen.copy(alpha = (1f - p) * 0.45f),
|
||||
radius = size.minDimension / 2f * (0.3f + 0.7f * p),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx())
|
||||
)
|
||||
}
|
||||
}
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(R.drawable.piece_white_knight),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.nearby_searching), color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.components.ListDivider
|
||||
import cc.skysparkle.matewave.ui.components.ListGroup
|
||||
import cc.skysparkle.matewave.ui.components.ListRow
|
||||
import cc.skysparkle.matewave.ui.components.SectionCaption
|
||||
import cc.skysparkle.matewave.ui.components.SetupSegmented
|
||||
import cc.skysparkle.matewave.ui.components.GlassButton
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.data.FriendEntry
|
||||
import cc.skysparkle.matewave.data.FriendsStore
|
||||
import cc.skysparkle.matewave.presence.LocalPresence
|
||||
import cc.skysparkle.matewave.ui.components.AppBottomBar
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.BottomTab
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
|
||||
@Composable
|
||||
fun FriendsScreen(
|
||||
presence: LocalPresence,
|
||||
onHomeClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onPlay: () -> Unit = {}
|
||||
) {
|
||||
var onlineOnly by remember { mutableStateOf(false) }
|
||||
var version by remember { mutableStateOf(0) }
|
||||
var opened by remember { mutableStateOf<FriendEntry?>(null) }
|
||||
|
||||
val myProfile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
|
||||
|
||||
val peers by presence.peers.collectAsState()
|
||||
val friends = remember(peers, version) { FriendsStore.list() }
|
||||
|
||||
// Fresh search on opening, then refresh "5 min ago" labels while the screen is visible.
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
presence.refresh()
|
||||
while (true) {
|
||||
kotlinx.coroutines.delay(30_000)
|
||||
version++
|
||||
}
|
||||
}
|
||||
|
||||
GlassScaffold(
|
||||
title = stringResource(R.string.friends_title),
|
||||
onBack = null,
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
current = BottomTab.FRIENDS,
|
||||
onHomeClick = onHomeClick,
|
||||
onFriendsClick = { },
|
||||
onSettingsClick = onSettingsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
accountAvatarBase64 = myProfile.avatarBase64
|
||||
)
|
||||
}
|
||||
) {
|
||||
val savedIds = friends.map { it.userId }.toSet()
|
||||
val strangers = peers.values.filter { it.playerId !in savedIds }
|
||||
|
||||
if (friends.isEmpty() && strangers.isEmpty()) {
|
||||
// Empty state with the action that fills the list.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(stringResource(R.string.friends_nobody_title), color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.friends_nobody_hint), color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodyMedium)
|
||||
GlassButton(onClick = onPlay, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.friends_play_nearby), color = ink)
|
||||
}
|
||||
return@GlassScaffold
|
||||
}
|
||||
|
||||
SetupSegmented(
|
||||
options = listOf(stringResource(R.string.friends_filter_all), stringResource(R.string.friends_filter_online)),
|
||||
selectedIndex = if (onlineOnly) 1 else 0
|
||||
) { onlineOnly = it == 1 }
|
||||
|
||||
val visible = friends
|
||||
.filter { !onlineOnly || presence.isOnline(it.userId) }
|
||||
.sortedWith(compareByDescending<FriendEntry> { presence.isOnline(it.userId) }.thenByDescending { it.lastPlayedMs })
|
||||
val favorites = visible.filter { it.favorite }
|
||||
val others = visible.filter { !it.favorite }
|
||||
|
||||
FriendGroup(
|
||||
title = stringResource(R.string.friends_group_favorites),
|
||||
list = favorites,
|
||||
isOnline = presence::isOnline,
|
||||
onOpen = { opened = it },
|
||||
onToggleFavorite = { FriendsStore.toggleFavorite(it.userId); version++ },
|
||||
onPlay = onPlay
|
||||
)
|
||||
FriendGroup(
|
||||
title = if (favorites.isNotEmpty()) stringResource(R.string.friends_group_others) else null,
|
||||
list = others,
|
||||
isOnline = presence::isOnline,
|
||||
onOpen = { opened = it },
|
||||
onToggleFavorite = { FriendsStore.toggleFavorite(it.userId); version++ },
|
||||
onPlay = onPlay
|
||||
)
|
||||
|
||||
if (strangers.isNotEmpty()) {
|
||||
SectionCaption(stringResource(R.string.friends_group_nearby))
|
||||
ListGroup {
|
||||
strangers.sortedBy { it.name }.forEachIndexed { index, peer ->
|
||||
if (index > 0) ListDivider(inset = 68)
|
||||
ListRow(
|
||||
title = displayName(peer.name, peer.playerId),
|
||||
subtitle = stringResource(R.string.friends_rating, peer.elo),
|
||||
leading = { AvatarCircle(base64 = null, size = 40.dp) },
|
||||
trailing = { PlayChip(onPlay) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visible.isEmpty() && strangers.isEmpty()) {
|
||||
Text(stringResource(R.string.friends_filter_empty), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
opened?.let { friend ->
|
||||
FriendCard(friend = friend, online = presence.isOnline(friend.userId), onDismiss = { opened = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FriendGroup(
|
||||
title: String?,
|
||||
list: List<FriendEntry>,
|
||||
isOnline: (String) -> Boolean,
|
||||
onOpen: (FriendEntry) -> Unit,
|
||||
onToggleFavorite: (FriendEntry) -> Unit,
|
||||
onPlay: () -> Unit
|
||||
) {
|
||||
if (list.isEmpty()) return
|
||||
if (title != null) SectionCaption(title)
|
||||
ListGroup {
|
||||
list.forEachIndexed { index, friend ->
|
||||
if (index > 0) ListDivider(inset = 68)
|
||||
FriendRow(
|
||||
friend = friend,
|
||||
online = isOnline(friend.userId),
|
||||
onOpen = { onOpen(friend) },
|
||||
onToggleFavorite = { onToggleFavorite(friend) },
|
||||
onPlay = onPlay
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Players who never set a name are all called "Player"; a short piece of their id tells them
|
||||
* apart in the list.
|
||||
*/
|
||||
@Composable
|
||||
private fun displayName(name: String, id: String): String {
|
||||
val default = stringResource(R.string.player_default_name)
|
||||
val generic = name.isBlank() || name == default || name == "Player"
|
||||
return if (generic) "${name.ifBlank { default }} · ${id.filter { it.isLetterOrDigit() }.take(4).uppercase()}" else name
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayChip(onClick: () -> Unit) {
|
||||
Text(
|
||||
stringResource(R.string.friends_play),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(ChessGreen)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FriendCard(friend: FriendEntry, online: Boolean, onDismiss: () -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(cc.skysparkle.matewave.ui.components.DialogShape)
|
||||
.background(cc.skysparkle.matewave.ui.components.DialogSurface)
|
||||
) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(cc.skysparkle.matewave.ui.theme.LocalInk provides Color.White) {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(150.dp)) {
|
||||
val bg = cc.skysparkle.matewave.ui.components.rememberChatImage(friend.backgroundBase64)
|
||||
if (bg != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = bg,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().height(150.dp),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = androidx.compose.ui.res.painterResource(R.drawable.bg_profile_default),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().height(150.dp),
|
||||
contentScale = androidx.compose.ui.layout.ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(150.dp).background(
|
||||
androidx.compose.ui.graphics.Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.15f),
|
||||
1f to Color.Black.copy(alpha = 0.75f)
|
||||
)
|
||||
)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
AvatarCircle(base64 = friend.avatarBase64, size = 64.dp)
|
||||
Column {
|
||||
Text(friend.name, color = ink, style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.friends_rating, friend.elo),
|
||||
color = ink.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
if (online) stringResource(R.string.friends_online)
|
||||
else lastSeenText(maxOf(friend.lastPlayedMs, friend.lastSeenMs)),
|
||||
color = if (online) Color(0xFF7BE495) else ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
cc.skysparkle.matewave.ui.components.DialogAction(
|
||||
stringResource(R.string.chat_close),
|
||||
onClick = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FriendRow(
|
||||
friend: FriendEntry,
|
||||
online: Boolean,
|
||||
onOpen: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
onPlay: () -> Unit
|
||||
) {
|
||||
ListRow(
|
||||
title = displayName(friend.name, friend.userId),
|
||||
subtitle = stringResource(R.string.friends_rating, friend.elo) + " · " +
|
||||
(if (online) stringResource(R.string.friends_online) else lastSeenText(maxOf(friend.lastPlayedMs, friend.lastSeenMs))),
|
||||
subtitleColor = if (online) ChessGreen else ink.copy(alpha = 0.55f),
|
||||
leading = {
|
||||
Box {
|
||||
AvatarCircle(base64 = friend.avatarBase64, size = 40.dp)
|
||||
if (online) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(11.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ChessGreen)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
if (online) PlayChip(onPlay)
|
||||
AppIcon(
|
||||
R.drawable.ic_star,
|
||||
size = 20.dp,
|
||||
tint = if (friend.favorite) StarGold else ink.copy(alpha = 0.25f),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onToggleFavorite)
|
||||
.padding(6.dp)
|
||||
)
|
||||
},
|
||||
onClick = onOpen
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun lastSeenText(lastSeenMs: Long): String {
|
||||
if (lastSeenMs <= 0) return stringResource(R.string.friends_offline)
|
||||
val minutes = (System.currentTimeMillis() - lastSeenMs) / 60_000
|
||||
return when {
|
||||
minutes < 60 -> stringResource(R.string.friends_minutes_ago, minutes.coerceAtLeast(1).toInt())
|
||||
minutes < 60 * 24 -> stringResource(R.string.friends_hours_ago, (minutes / 60).toInt())
|
||||
minutes < 60 * 48 -> stringResource(R.string.friends_yesterday)
|
||||
else -> stringResource(R.string.friends_days_ago, (minutes / (60 * 24)).toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.components.DialogText
|
||||
import cc.skysparkle.matewave.ui.components.DialogAction
|
||||
import cc.skysparkle.matewave.ui.components.AppDialog
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.ButtonIcon
|
||||
import cc.skysparkle.matewave.engine.ChessEngine
|
||||
import cc.skysparkle.matewave.engine.GameStatus
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.network.ConnectionState
|
||||
import cc.skysparkle.matewave.ui.components.ChatDialog
|
||||
import cc.skysparkle.matewave.ui.components.ChessBoardView
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.PromotionDialog
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.viewmodel.GameMode
|
||||
import cc.skysparkle.matewave.viewmodel.GameUiState
|
||||
import cc.skysparkle.matewave.viewmodel.GameViewModel
|
||||
|
||||
@Composable
|
||||
fun GameScreen(viewModel: GameViewModel, onExit: () -> Unit) {
|
||||
val exitGame = {
|
||||
viewModel.leaveNetworkGame()
|
||||
onExit()
|
||||
}
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var showChat by remember { mutableStateOf(false) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val showOpeningBar = remember { cc.skysparkle.matewave.settings.BoardAppearance(context).showOpening }
|
||||
|
||||
// Leaving an ongoing network game counts as a loss, so ask first; a game against the AI is saved.
|
||||
var confirmLeave by remember { mutableStateOf(false) }
|
||||
var confirmResign by remember { mutableStateOf(false) }
|
||||
val requestExit: () -> Unit = {
|
||||
val ongoing = state.status == GameStatus.ONGOING && state.gameStarted
|
||||
if (ongoing && state.mode == GameMode.VS_PLAYER_NETWORK) {
|
||||
confirmLeave = true
|
||||
} else {
|
||||
exitGame()
|
||||
}
|
||||
}
|
||||
androidx.activity.compose.BackHandler(onBack = requestExit)
|
||||
|
||||
var gameOverHidden by remember(state.status, state.opponentLeft) { mutableStateOf(false) }
|
||||
|
||||
// Opening shown in the top bar: "A40 · Englund Gambit" with a THEORY / out-of-book badge.
|
||||
val start = state.startBoard
|
||||
val sans = state.moveSans
|
||||
val showOpening = showOpeningBar && state.mode != GameMode.PUZZLE
|
||||
val fromStart = remember(start) { cc.skysparkle.matewave.engine.Notation.isStandardStart(start) }
|
||||
val opening = remember(sans, fromStart) {
|
||||
if (fromStart) cc.skysparkle.matewave.openings.OpeningBook.openingName(sans) else null
|
||||
}
|
||||
val bookPlies = remember(sans, fromStart) {
|
||||
if (fromStart) cc.skysparkle.matewave.openings.OpeningBook.bookDepth(sans) else 0
|
||||
}
|
||||
val inBook = sans.isNotEmpty() && bookPlies >= sans.size
|
||||
val bookStatus = when {
|
||||
!fromStart || sans.isEmpty() -> null
|
||||
inBook -> stringResource(R.string.opening_in_theory)
|
||||
bookPlies < cc.skysparkle.matewave.openings.OpeningBook.MAX_TREE_PLY ->
|
||||
stringResource(R.string.opening_out_of_book, bookPlies / 2 + 1)
|
||||
else -> null
|
||||
}
|
||||
|
||||
GlassScaffold(
|
||||
title = "",
|
||||
onBack = requestExit,
|
||||
titleContent = if (showOpening && opening != null) {
|
||||
{ OpeningTitle("${opening.eco} · ${opening.name}", bookStatus, inBook) }
|
||||
} else null
|
||||
) {
|
||||
if (state.mode == GameMode.VS_PLAYER_NETWORK) ConnectionBanner(state)
|
||||
TrustBanners(state)
|
||||
|
||||
val material = remember(state.board) { cc.skysparkle.matewave.engine.Material.balance(state.board) }
|
||||
val bottomColor = state.myColor
|
||||
val topColor = if (bottomColor == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE
|
||||
val kingInCheckSquare = if (ChessEngine.isInCheck(state.board, state.board.sideToMove)) {
|
||||
state.board.kingSquare(state.board.sideToMove)
|
||||
} else null
|
||||
|
||||
PlayerRow(state, topColor, isMe = false, material = material)
|
||||
|
||||
ChessBoardView(
|
||||
board = state.board,
|
||||
selectedSquare = state.selectedSquare,
|
||||
legalTargets = state.legalTargetsFromSelected,
|
||||
kingInCheckSquare = kingInCheckSquare,
|
||||
flipped = state.myColor == PieceColor.BLACK,
|
||||
onSquareClick = viewModel::onSquareClicked,
|
||||
lastMove = state.lastMove,
|
||||
interactionEnabled = state.status == GameStatus.ONGOING && state.pendingPromotion == null &&
|
||||
(state.mode != GameMode.VS_PLAYER_NETWORK ||
|
||||
(state.connectionState == ConnectionState.CONNECTED &&
|
||||
state.handshake == cc.skysparkle.matewave.viewmodel.HandshakeState.PLAYING))
|
||||
)
|
||||
|
||||
PlayerRow(state, bottomColor, isMe = true, material = material)
|
||||
|
||||
// The move ribbon: only moves here, the opening name lives in the top bar.
|
||||
if (showOpening) {
|
||||
cc.skysparkle.matewave.ui.components.MovesBar(
|
||||
sans = sans,
|
||||
startBoard = start,
|
||||
currentIndex = sans.size,
|
||||
opening = null,
|
||||
bookPlies = bookPlies
|
||||
)
|
||||
}
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = state.opponentNotice == cc.skysparkle.matewave.viewmodel.OpponentNotice.DRAW_DECLINED,
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically()
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AppIcon(R.drawable.ic_draw, size = 18.dp, tint = Color(0xFFFFB86B))
|
||||
Text(stringResource(R.string.draw_declined_notice), color = ink, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(state.opponentNotice) {
|
||||
if (state.opponentNotice == cc.skysparkle.matewave.viewmodel.OpponentNotice.DRAW_DECLINED) {
|
||||
kotlinx.coroutines.delay(NOTICE_VISIBLE_MS)
|
||||
viewModel.dismissOpponentNotice()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.mode == GameMode.VS_PLAYER_NETWORK && state.status == GameStatus.ONGOING) {
|
||||
// Both actions neutral: resigning must not look like the main action.
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
GlassOutlinedButton(
|
||||
onClick = { viewModel.offerDraw() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.drawOfferSentByMe
|
||||
) {
|
||||
Text(
|
||||
if (state.drawOfferSentByMe) stringResource(R.string.draw_offer_waiting) else stringResource(R.string.offer_draw),
|
||||
color = ink,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
GlassOutlinedButton(onClick = { confirmResign = true }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.resign), color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
ChatButton(
|
||||
unreadCount = state.unreadChatCount,
|
||||
onClick = { viewModel.markChatRead(); showChat = true }
|
||||
)
|
||||
}
|
||||
|
||||
// The result dialog was closed to look at the board: one tap brings it back.
|
||||
if (state.status != GameStatus.ONGOING && gameOverHidden) {
|
||||
GlassOutlinedButton(onClick = { gameOverHidden = false }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.game_over_title), color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmLeave) {
|
||||
AppDialog(
|
||||
onDismissRequest = { confirmLeave = false },
|
||||
title = stringResource(R.string.game_leave_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), { confirmLeave = false }, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(stringResource(R.string.game_leave_confirm), { confirmLeave = false; exitGame() })
|
||||
}
|
||||
) {
|
||||
DialogText(stringResource(R.string.game_leave_network))
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmResign) {
|
||||
AppDialog(
|
||||
onDismissRequest = { confirmResign = false },
|
||||
title = stringResource(R.string.game_resign_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), { confirmResign = false }, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(stringResource(R.string.resign), { confirmResign = false; viewModel.resign() })
|
||||
}
|
||||
) {
|
||||
DialogText(stringResource(R.string.game_resign_text))
|
||||
}
|
||||
}
|
||||
|
||||
state.pendingPromotion?.let {
|
||||
PromotionDialog(
|
||||
color = state.board.sideToMove,
|
||||
onChoose = viewModel::choosePromotion,
|
||||
onDismiss = viewModel::cancelPromotion
|
||||
)
|
||||
}
|
||||
|
||||
if (state.drawOfferFromOpponent) {
|
||||
IncomingDrawOfferDialog(
|
||||
onAccept = { viewModel.respondToDrawOffer(true) },
|
||||
onDecline = { viewModel.respondToDrawOffer(false) }
|
||||
)
|
||||
}
|
||||
|
||||
if (showChat) {
|
||||
ChatDialog(
|
||||
messages = state.chatMessages,
|
||||
onSendText = viewModel::sendChatMessage,
|
||||
onSendImage = viewModel::sendChatImage,
|
||||
onDismiss = { showChat = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.status != GameStatus.ONGOING && !gameOverHidden) {
|
||||
GameOverDialog(
|
||||
state.status, state,
|
||||
onRematch = {
|
||||
viewModel.rematchVsAi()
|
||||
},
|
||||
onExit = exitGame,
|
||||
onDismiss = { gameOverHidden = true },
|
||||
onOfferRematch = { viewModel.offerRematch() }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.rematchOfferFromOpponent) {
|
||||
RematchOfferDialog(
|
||||
onAccept = { viewModel.acceptRematch() },
|
||||
onDecline = { viewModel.declineRematch() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RematchOfferDialog(onAccept: () -> Unit, onDecline: () -> Unit) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDecline,
|
||||
title = stringResource(R.string.rematch_offer_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.rematch_decline), onDecline, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(stringResource(R.string.rematch_accept), onAccept)
|
||||
}
|
||||
) {
|
||||
DialogText(stringResource(R.string.rematch_offer_text))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatButton(unreadCount: Int, onClick: () -> Unit) {
|
||||
Box {
|
||||
GlassOutlinedButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
ButtonIcon(R.drawable.ic_chat)
|
||||
Text(stringResource(R.string.chat_button), color = ink)
|
||||
}
|
||||
if (unreadCount > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ChessGreen),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(unreadCount.toString(), color = Color.White, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingDrawOfferDialog(onAccept: () -> Unit, onDecline: () -> Unit) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDecline,
|
||||
title = stringResource(R.string.draw_offer_received_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.decline), onDecline, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(stringResource(R.string.accept), onAccept)
|
||||
}
|
||||
) {
|
||||
DialogText(stringResource(R.string.draw_offer_received_text))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameOverDialog(
|
||||
status: GameStatus,
|
||||
state: GameUiState,
|
||||
onRematch: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onOfferRematch: () -> Unit = {}
|
||||
) {
|
||||
val colorWhite = stringResource(R.string.color_white)
|
||||
val colorBlack = stringResource(R.string.color_black)
|
||||
val message = when (status) {
|
||||
GameStatus.CHECKMATE -> {
|
||||
val winner = if (state.board.sideToMove == PieceColor.WHITE) colorBlack else colorWhite
|
||||
stringResource(R.string.checkmate_winner, winner)
|
||||
}
|
||||
GameStatus.STALEMATE -> stringResource(R.string.stalemate)
|
||||
GameStatus.DRAW_50_MOVES -> stringResource(R.string.draw_50_moves)
|
||||
GameStatus.DRAW_REPETITION -> stringResource(R.string.draw_repetition)
|
||||
GameStatus.DRAW_MATERIAL -> stringResource(R.string.draw_material)
|
||||
GameStatus.TIMEOUT -> {
|
||||
val winner = if (state.timedOutSide == PieceColor.WHITE) colorBlack else colorWhite
|
||||
stringResource(R.string.timeout_winner, winner)
|
||||
}
|
||||
GameStatus.TIMEOUT_DRAW -> stringResource(R.string.timeout_draw)
|
||||
GameStatus.RESIGNATION -> {
|
||||
val winner = if (state.resignedSide == PieceColor.WHITE) colorBlack else colorWhite
|
||||
stringResource(R.string.resignation_winner, winner)
|
||||
}
|
||||
GameStatus.DRAW_AGREEMENT -> stringResource(R.string.draw_agreement)
|
||||
GameStatus.ONGOING -> return
|
||||
}
|
||||
|
||||
val waitingForRematch = state.mode == GameMode.VS_PLAYER_NETWORK && !state.opponentLeft && state.rematchOfferedByMe
|
||||
|
||||
val shownMessage = if (state.opponentLeftMidGame) stringResource(R.string.opponent_left_win) else message
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.game_over_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.back_to_menu_short), onExit, color = ink.copy(alpha = 0.75f))
|
||||
if (state.mode == GameMode.VS_AI) {
|
||||
DialogAction(stringResource(R.string.play_again), onRematch)
|
||||
} else if (state.mode == GameMode.VS_PLAYER_NETWORK && !state.opponentLeft && !waitingForRematch) {
|
||||
DialogAction(stringResource(R.string.rematch_offer), onOfferRematch)
|
||||
}
|
||||
}
|
||||
) {
|
||||
DialogText(shownMessage)
|
||||
if (waitingForRematch) {
|
||||
DialogText(stringResource(R.string.rematch_waiting))
|
||||
}
|
||||
|
||||
if (state.opponentNotice == cc.skysparkle.matewave.viewmodel.OpponentNotice.REMATCH_DECLINED) {
|
||||
Text(
|
||||
stringResource(R.string.rematch_declined_notice),
|
||||
color = Color(0xFFFFB86B),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (state.opponentLeft && !state.opponentLeftMidGame) {
|
||||
Text(
|
||||
stringResource(R.string.opponent_left_after_game),
|
||||
color = Color(0xFFFFB86B),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val NO_LIMIT_THRESHOLD_SECONDS = 365L * 24 * 3600
|
||||
|
||||
private const val NOTICE_VISIBLE_MS = 4_000L
|
||||
|
||||
@Composable
|
||||
private fun SideCaptured(material: cc.skysparkle.matewave.engine.MaterialBalance, side: PieceColor) {
|
||||
cc.skysparkle.matewave.ui.components.CapturedPiecesRow(
|
||||
captured = if (side == PieceColor.WHITE) material.capturedByWhite else material.capturedByBlack,
|
||||
capturedColor = if (side == PieceColor.WHITE) PieceColor.BLACK else PieceColor.WHITE,
|
||||
advantage = if (side == PieceColor.WHITE) material.advantage else -material.advantage
|
||||
)
|
||||
}
|
||||
|
||||
/** Opening name in the top bar with a small status badge underneath. */
|
||||
@Composable
|
||||
private fun OpeningTitle(name: String, status: String?, inBook: Boolean) {
|
||||
Column(modifier = Modifier.padding(start = 4.dp)) {
|
||||
Text(
|
||||
name,
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
if (status != null) {
|
||||
val tint = if (inBook) ChessGreen else Color(0xFFFFB86B)
|
||||
Text(
|
||||
status.uppercase(),
|
||||
color = tint,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(tint.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A player strip: activity dot, name and rating, captured pieces, and a large clock. The side to
|
||||
* move gets a bright clock on a soft plate; the other side is dimmed. No cards around it.
|
||||
*/
|
||||
@Composable
|
||||
private fun PlayerRow(
|
||||
state: GameUiState,
|
||||
side: PieceColor,
|
||||
isMe: Boolean,
|
||||
material: cc.skysparkle.matewave.engine.MaterialBalance
|
||||
) {
|
||||
val active = state.status == GameStatus.ONGOING && state.board.sideToMove == side
|
||||
// Re-read after the game: the rating changes when it ends.
|
||||
val profile = remember(state.status) { cc.skysparkle.matewave.data.ProfileStore.get() }
|
||||
val (name, elo, avatar) = when {
|
||||
isMe -> Triple(profile.name, profile.elo, profile.avatarBase64)
|
||||
state.mode == GameMode.VS_AI -> Triple(
|
||||
stringResource(R.string.game_ai_name, stringResource(difficultyName(state.aiDifficulty))),
|
||||
cc.skysparkle.matewave.data.ProfileStore.aiNominalElo(state.aiDifficulty),
|
||||
null
|
||||
)
|
||||
else -> Triple(
|
||||
state.opponentProfile?.name ?: stringResource(R.string.game_opponent),
|
||||
state.opponentProfile?.elo,
|
||||
state.opponentProfile?.avatarBase64
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (active) ChessGreen else ink.copy(alpha = 0.15f))
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
AvatarCircle(base64 = avatar, size = 34.dp)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
name,
|
||||
color = if (active) ink else ink.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
|
||||
)
|
||||
if (!isMe && state.mode == GameMode.VS_PLAYER_NETWORK &&
|
||||
state.handshake == cc.skysparkle.matewave.viewmodel.HandshakeState.PLAYING &&
|
||||
!state.opponentUntrusted && !state.opponentUnverified
|
||||
) {
|
||||
AppIcon(R.drawable.ic_encrypted, size = 14.dp, tint = ChessGreen)
|
||||
}
|
||||
}
|
||||
if (elo != null) {
|
||||
Text(stringResource(R.string.friends_elo_value, elo), color = ink.copy(alpha = 0.55f), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
SideCaptured(material, side)
|
||||
}
|
||||
state.clockState?.let { cs ->
|
||||
BigClock(if (side == PieceColor.WHITE) cs.whiteMillis else cs.blackMillis, active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BigClock(millis: Long, active: Boolean) {
|
||||
val totalSeconds = millis / 1000
|
||||
val low = active && totalSeconds < 20
|
||||
val color = when {
|
||||
low -> Color(0xFFFF6B6B)
|
||||
active -> ink
|
||||
else -> ink.copy(alpha = 0.45f)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (active) ink.copy(alpha = 0.12f) else Color.Transparent)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (totalSeconds >= NO_LIMIT_THRESHOLD_SECONDS) {
|
||||
AppIcon(R.drawable.ic_infinity, size = 28.dp, tint = color)
|
||||
} else {
|
||||
Text(
|
||||
"%d:%02d".format(totalSeconds / 60, totalSeconds % 60),
|
||||
color = color,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionBanner(state: GameUiState) {
|
||||
when (state.connectionState) {
|
||||
ConnectionState.CONNECTING -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CircularProgressIndicator(color = ink, strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.connecting_to_opponent), color = ink, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
ConnectionState.FAILED -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AppIcon(R.drawable.ic_error, size = 18.dp, tint = Color(0xFFFF6B6B))
|
||||
Text(stringResource(R.string.connection_failed), color = Color(0xFFFF6B6B), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
ConnectionState.DISCONNECTED -> if (state.gameStarted) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AppIcon(R.drawable.ic_signal_disconnected, size = 18.dp, tint = Color(0xFFFF6B6B))
|
||||
Text(stringResource(R.string.connection_lost), color = Color(0xFFFF6B6B), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
ConnectionState.CONNECTED -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustBanners(state: GameUiState) {
|
||||
val (icon, text, color) = when {
|
||||
state.opponentUntrusted -> Triple(R.drawable.ic_no_encryption, stringResource(R.string.trust_impersonation), Color(0xFFFF6B6B))
|
||||
state.opponentOutdated -> Triple(R.drawable.ic_error, stringResource(R.string.trust_outdated), Color(0xFFFF6B6B))
|
||||
state.opponentUnverified -> Triple(R.drawable.ic_no_encryption, stringResource(R.string.trust_unverified), Color(0xFFFFC46B))
|
||||
else -> Triple(0, null, Color.Unspecified)
|
||||
}
|
||||
if (text != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AppIcon(icon, size = 16.dp, tint = color)
|
||||
Text(text, color = color, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
state.newPeerFingerprint?.let { fingerprint ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AppIcon(R.drawable.ic_key, size = 16.dp, tint = Color(0xFFFFC46B))
|
||||
Text(stringResource(R.string.trust_first_meeting, fingerprint), color = Color(0xFFFFC46B), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Name of an AI level, shared by the setup, game and home screens. */
|
||||
internal fun difficultyName(d: cc.skysparkle.matewave.ai.AiDifficulty): Int = when (d) {
|
||||
cc.skysparkle.matewave.ai.AiDifficulty.EASY -> R.string.difficulty_easy
|
||||
cc.skysparkle.matewave.ai.AiDifficulty.MEDIUM -> R.string.difficulty_medium
|
||||
cc.skysparkle.matewave.ai.AiDifficulty.HARD -> R.string.difficulty_hard
|
||||
cc.skysparkle.matewave.ai.AiDifficulty.EXPERT -> R.string.difficulty_expert
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import android.content.Context
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.components.AppDialog
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.ButtonIcon
|
||||
import cc.skysparkle.matewave.ui.components.DialogAccent
|
||||
import cc.skysparkle.matewave.ui.components.DialogAction
|
||||
import cc.skysparkle.matewave.ui.components.GlassCard
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.SetupSectionLabel
|
||||
|
||||
private data class LicenseEntry(
|
||||
val name: String,
|
||||
val author: String,
|
||||
val version: String?,
|
||||
val license: String,
|
||||
val url: String,
|
||||
val noteRes: Int? = null
|
||||
)
|
||||
|
||||
private const val APACHE = "Apache License 2.0"
|
||||
private const val AOSP = "The Android Open Source Project"
|
||||
|
||||
private val LIBRARIES = listOf(
|
||||
LicenseEntry(
|
||||
"Jetpack Compose (UI, Foundation, Material 3)", AOSP, "BOM 2026.09.00", APACHE,
|
||||
"https://developer.android.com/jetpack/androidx/releases/compose"
|
||||
),
|
||||
LicenseEntry(
|
||||
"AndroidX Activity Compose", AOSP, "1.13.0", APACHE,
|
||||
"https://developer.android.com/jetpack/androidx/releases/activity"
|
||||
),
|
||||
LicenseEntry(
|
||||
"AndroidX Core KTX", AOSP, "1.18.0", APACHE,
|
||||
"https://developer.android.com/jetpack/androidx/releases/core"
|
||||
),
|
||||
LicenseEntry(
|
||||
"AndroidX Navigation Compose", AOSP, "2.9.7", APACHE,
|
||||
"https://developer.android.com/jetpack/androidx/releases/navigation"
|
||||
),
|
||||
LicenseEntry(
|
||||
"AndroidX Lifecycle ViewModel Compose", AOSP, "2.11.0", APACHE,
|
||||
"https://developer.android.com/jetpack/androidx/releases/lifecycle"
|
||||
),
|
||||
LicenseEntry(
|
||||
"AndroidX \u2014 other libraries (pulled in by the ones above)", AOSP, null, APACHE,
|
||||
"https://developer.android.com/jetpack/androidx"
|
||||
),
|
||||
LicenseEntry(
|
||||
"ZXing Core (QR codes)", "ZXing authors", "3.5.4", APACHE,
|
||||
"https://github.com/zxing/zxing"
|
||||
),
|
||||
LicenseEntry(
|
||||
"kotlinx.coroutines", "JetBrains", "1.11.0", APACHE,
|
||||
"https://github.com/Kotlin/kotlinx.coroutines"
|
||||
),
|
||||
LicenseEntry(
|
||||
"Kotlin Standard Library", "JetBrains", null, APACHE,
|
||||
"https://github.com/JetBrains/kotlin"
|
||||
)
|
||||
)
|
||||
|
||||
private val DATA = listOf(
|
||||
LicenseEntry(
|
||||
"Lichess Puzzle Database", "Lichess", null, "CC0 1.0",
|
||||
"https://database.lichess.org/#puzzles", R.string.licenses_note_puzzles
|
||||
),
|
||||
LicenseEntry(
|
||||
"Lichess Standard Games Database (2013-05)", "Lichess", null, "CC0 1.0",
|
||||
"https://database.lichess.org/#standard_games", R.string.licenses_note_games
|
||||
),
|
||||
LicenseEntry(
|
||||
"lichess-org/chess-openings", "Lichess contributors", null, "CC0 1.0",
|
||||
"https://github.com/lichess-org/chess-openings", R.string.licenses_note_openings
|
||||
)
|
||||
)
|
||||
|
||||
private val ASSETS = listOf(
|
||||
LicenseEntry(
|
||||
"Misty forest scene with pine trees in haze", "Jean-Daniel Francoeur", null, "Pexels License",
|
||||
"https://www.pexels.com/photo/misty-forest-scene-with-pine-trees-in-haze-31737036/",
|
||||
R.string.licenses_note_photo
|
||||
),
|
||||
LicenseEntry(
|
||||
"Material Symbols", "Google", null, APACHE,
|
||||
"https://iconbuddy.com/material-symbols", R.string.licenses_note_icons
|
||||
),
|
||||
LicenseEntry(
|
||||
"Vector Chess Pieces", "RhosGFX", null, "CC0 1.0",
|
||||
"https://rhosgfx.itch.io/vector-chess-pieces", R.string.licenses_note_pieces
|
||||
)
|
||||
)
|
||||
|
||||
private val RADIO = listOf(
|
||||
LicenseEntry(
|
||||
"Radio Swiss Jazz", "SRG SSR", null, "\u00A9 SRG SSR",
|
||||
"https://www.radioswissjazz.ch/en/reception/internet", R.string.licenses_note_swissjazz
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LicensesScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var showApache by remember { mutableStateOf(false) }
|
||||
|
||||
GlassScaffold(title = stringResource(R.string.licenses_title), onBack = onBack) {
|
||||
Text(
|
||||
stringResource(R.string.licenses_intro),
|
||||
color = ink.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
SetupSectionLabel(stringResource(R.string.licenses_group_libraries))
|
||||
LIBRARIES.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
|
||||
GlassOutlinedButton(onClick = { showApache = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
ButtonIcon(R.drawable.ic_copyright)
|
||||
Text(stringResource(R.string.licenses_apache_full), color = ink)
|
||||
}
|
||||
|
||||
SetupSectionLabel(stringResource(R.string.licenses_group_data))
|
||||
DATA.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
|
||||
|
||||
SetupSectionLabel(stringResource(R.string.licenses_group_assets))
|
||||
ASSETS.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
|
||||
|
||||
SetupSectionLabel(stringResource(R.string.licenses_group_radio))
|
||||
Text(
|
||||
stringResource(R.string.licenses_radio_hint),
|
||||
color = ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
RADIO.forEach { entry -> LicenseCard(entry) { openUrl(context, entry.url) } }
|
||||
}
|
||||
|
||||
if (showApache) {
|
||||
ApacheLicenseDialog(onDismiss = { showApache = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LicenseCard(entry: LicenseEntry, onClick: () -> Unit) {
|
||||
GlassCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(entry.name, color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
listOfNotNull(entry.author, entry.version).joinToString(" \u00B7 "),
|
||||
color = ink.copy(alpha = 0.65f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text(entry.license, color = DialogAccent, style = MaterialTheme.typography.bodySmall)
|
||||
entry.noteRes?.let { note ->
|
||||
Text(
|
||||
stringResource(note),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
AppIcon(R.drawable.ic_arrow_forward, size = 16.dp, tint = ink.copy(alpha = 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApacheLicenseDialog(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val text = remember {
|
||||
runCatching {
|
||||
context.resources.openRawResource(R.raw.license_apache_2_0)
|
||||
.bufferedReader().use { it.readText() }
|
||||
}.getOrDefault("https://www.apache.org/licenses/LICENSE-2.0")
|
||||
.trim()
|
||||
.split(Regex("\n\\s*\n"))
|
||||
.joinToString("\n\n") { paragraph -> paragraph.lines().joinToString(" ") { it.trim() } }
|
||||
}
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = APACHE,
|
||||
actions = { DialogAction(stringResource(R.string.chat_close), onDismiss) }
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 380.dp).verticalScroll(rememberScrollState())) {
|
||||
Text(text, color = ink.copy(alpha = 0.78f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(context: Context, url: String) = cc.skysparkle.matewave.settings.AppLinks.open(context, url)
|
||||
@@ -0,0 +1,371 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import cc.skysparkle.matewave.ui.components.ListDivider
|
||||
import cc.skysparkle.matewave.ui.components.ListGroup
|
||||
import cc.skysparkle.matewave.ui.components.ListRow
|
||||
import cc.skysparkle.matewave.ui.components.SectionCaption
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.Image
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.components.DialogText
|
||||
import cc.skysparkle.matewave.ui.components.DialogAction
|
||||
import cc.skysparkle.matewave.ui.components.AppDialog
|
||||
import cc.skysparkle.matewave.stats.Stats
|
||||
import cc.skysparkle.matewave.ui.components.AppBottomBar
|
||||
import cc.skysparkle.matewave.ui.components.BottomTab
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.TabletMaxContentWidth
|
||||
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
|
||||
import cc.skysparkle.matewave.ui.theme.glass
|
||||
import cc.skysparkle.matewave.viewmodel.GameViewModel
|
||||
import cc.skysparkle.matewave.viewmodel.ResumeInfo
|
||||
import cc.skysparkle.matewave.ui.components.displayName
|
||||
|
||||
@Composable
|
||||
fun MainMenuScreen(
|
||||
viewModel: GameViewModel,
|
||||
studyViewModel: cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel,
|
||||
onContinueGame: () -> Unit,
|
||||
onQuickGame: () -> Unit,
|
||||
onVsAiClick: () -> Unit,
|
||||
onPuzzlesClick: () -> Unit,
|
||||
onStudyClick: () -> Unit,
|
||||
onVsPlayerClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onFriendsClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resumable by viewModel.resumable.collectAsState()
|
||||
val ongoing = resumable
|
||||
val hasOngoingGame = ongoing != null
|
||||
val profile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
|
||||
val lastSetup = remember { cc.skysparkle.matewave.settings.LastGameSetup(context) }
|
||||
LaunchedEffect(Unit) { studyViewModel.init(context) }
|
||||
val study by studyViewModel.overview.collectAsState()
|
||||
|
||||
var showRatingGate by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Column(modifier = Modifier.fillMaxSize().widthIn(max = TabletMaxContentWidth)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(top = 16.dp, start = 20.dp, end = 20.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.logo_knight),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(width = 30.dp, height = 44.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Greeting(modifier = Modifier.weight(1f))
|
||||
// The rating is always in sight.
|
||||
Text(
|
||||
"${profile.elo}",
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(ink.copy(alpha = 0.10f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
// The most urgent thing first: an unfinished game.
|
||||
if (ongoing != null) ContinueCard(ongoing, onContinueGame)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
ModeTile(
|
||||
iconRes = R.drawable.ic_ai_app,
|
||||
title = stringResource(R.string.mode_ai_title),
|
||||
subtitle = stringResource(R.string.mode_ai_subtitle),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { if (profile.levelChosen) onVsAiClick() else showRatingGate = true }
|
||||
)
|
||||
ModeTile(
|
||||
iconRes = R.drawable.ic_profile_photo,
|
||||
title = stringResource(R.string.mode_online_title),
|
||||
subtitle = stringResource(R.string.mode_online_subtitle),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { if (profile.levelChosen) onVsPlayerClick() else showRatingGate = true }
|
||||
)
|
||||
}
|
||||
|
||||
SectionCaption(stringResource(R.string.home_today))
|
||||
ListGroup {
|
||||
if (!hasOngoingGame) {
|
||||
ListRow(
|
||||
title = stringResource(R.string.quick_game_title),
|
||||
subtitle = stringResource(
|
||||
R.string.quick_game_subtitle,
|
||||
stringResource(difficultyName(lastSetup.aiDifficulty)),
|
||||
lastSetup.aiTime.displayName()
|
||||
),
|
||||
icon = R.drawable.ic_quick_game,
|
||||
iconTint = ChessGreen,
|
||||
chevron = true,
|
||||
onClick = { if (profile.levelChosen) onQuickGame() else showRatingGate = true }
|
||||
)
|
||||
ListDivider()
|
||||
}
|
||||
if (study.dueCards > 0) {
|
||||
ListRow(
|
||||
title = stringResource(R.string.study_review_title),
|
||||
subtitle = stringResource(R.string.study_review_summary, study.dueCards, study.dueCourses),
|
||||
icon = R.drawable.ic_review,
|
||||
iconTint = cc.skysparkle.matewave.ui.screens.StarGold,
|
||||
chevron = true,
|
||||
onClick = onStudyClick
|
||||
)
|
||||
} else {
|
||||
ListRow(
|
||||
title = stringResource(R.string.study_title),
|
||||
subtitle = stringResource(R.string.study_menu_subtitle),
|
||||
icon = R.drawable.ic_crown,
|
||||
chevron = true,
|
||||
onClick = onStudyClick
|
||||
)
|
||||
}
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.puzzles_title),
|
||||
subtitle = stringResource(R.string.puzzles_subtitle),
|
||||
icon = R.drawable.ic_puzzle,
|
||||
chevron = true,
|
||||
onClick = onPuzzlesClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.navigationBarsPadding()) {
|
||||
AppBottomBar(
|
||||
current = BottomTab.HOME,
|
||||
onHomeClick = { },
|
||||
onFriendsClick = onFriendsClick,
|
||||
onSettingsClick = onSettingsClick,
|
||||
onAccountClick = onAccountClick,
|
||||
accountAvatarBase64 = profile.avatarBase64
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRatingGate) {
|
||||
RatingGateDialog(
|
||||
onOpenAccount = { showRatingGate = false; onAccountClick() },
|
||||
onDismiss = { showRatingGate = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Greeting(modifier: Modifier = Modifier) {
|
||||
val hour = remember { java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) }
|
||||
val greeting = when (hour) {
|
||||
in 5..11 -> stringResource(R.string.greeting_morning)
|
||||
in 12..17 -> stringResource(R.string.greeting_afternoon)
|
||||
in 18..22 -> stringResource(R.string.greeting_evening)
|
||||
else -> stringResource(R.string.greeting_night)
|
||||
}
|
||||
Column(modifier = modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(greeting, color = ink, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("👋", fontSize = 20.sp)
|
||||
}
|
||||
Text(stringResource(R.string.greeting_ready), color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/** An unfinished game against the AI: move number, whose turn and the clock, one tap to resume. */
|
||||
@Composable
|
||||
private fun ContinueCard(info: ResumeInfo, onClick: () -> Unit) {
|
||||
val turn = stringResource(if (info.myTurn) R.string.home_your_move else R.string.home_ai_move)
|
||||
val clock = info.myMillis?.let { ms ->
|
||||
val s = ms.coerceAtLeast(0) / 1000
|
||||
"%d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
val details = stringResource(R.string.home_continue_details, info.moveNumber, turn) + (clock?.let { " · $it" } ?: "")
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.glass(RoundedCornerShape(UiCornerRadius))
|
||||
.border(1.dp, ChessGreen.copy(alpha = 0.6f), RoundedCornerShape(UiCornerRadius))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.continue_game_title), color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text(details, color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).background(ChessGreen),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
cc.skysparkle.matewave.ui.components.AppIcon(R.drawable.ic_arrow_forward, size = 20.dp, tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One of the two equal ways to start a game. */
|
||||
@Composable
|
||||
private fun ModeTile(iconRes: Int, title: String, subtitle: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.glass(RoundedCornerShape(UiCornerRadius))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(ink),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text(subtitle, color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RatingGateDialog(onOpenAccount: () -> Unit, onDismiss: () -> Unit) {
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.rating_gate_title),
|
||||
actions = {
|
||||
DialogAction(stringResource(R.string.account_cancel), onDismiss, color = ink.copy(alpha = 0.75f))
|
||||
DialogAction(stringResource(R.string.rating_gate_open), onOpenAccount)
|
||||
}
|
||||
) {
|
||||
DialogText(stringResource(R.string.rating_gate_text))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StatsDialog(onDismiss: () -> Unit) {
|
||||
val curve = remember { Stats.ratingCurve(cc.skysparkle.matewave.data.ProfileStore.get().elo) }
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = stringResource(R.string.stats_dialog_title),
|
||||
actions = { DialogAction(stringResource(R.string.chat_close), onDismiss) }
|
||||
) {
|
||||
DialogText(stringResource(R.string.stats_dialog_summary, Stats.wins(), Stats.winPercent(), Stats.streak()))
|
||||
MiniRatingGraph(points = curve, modifier = Modifier.fillMaxWidth().height(140.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(stringResource(R.string.stats_min, curve.min()), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.labelMedium)
|
||||
Text(stringResource(R.string.stats_now, curve.last()), color = ink, style = MaterialTheme.typography.labelMedium)
|
||||
Text(stringResource(R.string.stats_max, curve.max()), color = ink.copy(alpha = 0.6f), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.stats_graph_caption),
|
||||
color = ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MiniRatingGraph(points: List<Int>, modifier: Modifier = Modifier) {
|
||||
val lineInk = ink
|
||||
Canvas(modifier = modifier) {
|
||||
if (points.size < 2) return@Canvas
|
||||
val minV = points.min().toFloat()
|
||||
val maxV = points.max().toFloat()
|
||||
val range = (maxV - minV).coerceAtLeast(1f)
|
||||
val stepX = size.width / (points.size - 1)
|
||||
|
||||
fun yOf(v: Int) = size.height - ((v - minV) / range) * size.height
|
||||
|
||||
val gridLines = 4
|
||||
for (i in 0..gridLines) {
|
||||
val y = size.height / gridLines * i
|
||||
drawLine(lineInk.copy(alpha = 0.08f), Offset(0f, y), Offset(size.width, y), strokeWidth = 1f)
|
||||
}
|
||||
// Reference line: the rating at the start of the shown history.
|
||||
val zeroY = yOf(points.first())
|
||||
drawLine(lineInk.copy(alpha = 0.3f), Offset(0f, zeroY), Offset(size.width, zeroY), strokeWidth = 1.5f)
|
||||
|
||||
val line = Path().apply {
|
||||
moveTo(0f, yOf(points[0]))
|
||||
points.forEachIndexed { i, v -> if (i > 0) lineTo(i * stepX, yOf(v)) }
|
||||
}
|
||||
val fill = Path().apply {
|
||||
addPath(line)
|
||||
lineTo(size.width, size.height)
|
||||
lineTo(0f, size.height)
|
||||
close()
|
||||
}
|
||||
drawPath(
|
||||
fill,
|
||||
brush = Brush.verticalGradient(listOf(ChessGreen.copy(alpha = 0.35f), ChessGreen.copy(alpha = 0f)))
|
||||
)
|
||||
drawPath(line, color = ChessGreen, style = Stroke(width = 4f, cap = StrokeCap.Round))
|
||||
|
||||
points.forEachIndexed { i, v ->
|
||||
val center = Offset(i * stepX, yOf(v))
|
||||
drawCircle(color = lineInk, radius = 5f, center = center)
|
||||
drawCircle(color = ChessGreen, radius = 3f, center = center)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.openings.CourseInfo
|
||||
import cc.skysparkle.matewave.openings.CourseStatus
|
||||
import cc.skysparkle.matewave.openings.artRes
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.GlassButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.SetupSegmented
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.UiCornerRadius
|
||||
import cc.skysparkle.matewave.ui.theme.glass
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
import cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel
|
||||
import cc.skysparkle.matewave.viewmodel.StudyOverview
|
||||
|
||||
val StarGold = Color(0xFFFFC857)
|
||||
|
||||
@Composable
|
||||
fun OpeningStudyScreen(viewModel: OpeningStudyViewModel, onBack: () -> Unit, onOpenTrainer: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) { viewModel.init(context) }
|
||||
val overview by viewModel.overview.collectAsState()
|
||||
var side by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
GlassScaffold(
|
||||
title = stringResource(R.string.study_title),
|
||||
onBack = onBack,
|
||||
actions = { HeaderProgress(overview) }
|
||||
) {
|
||||
val total = overview.courses.size.coerceAtLeast(1)
|
||||
ProgressBar(overview.courses.sumOf { it.stars }.toFloat() / (total * 3), height = 4)
|
||||
|
||||
NextAction(overview, viewModel, onOpenTrainer)
|
||||
|
||||
SetupSegmented(
|
||||
options = listOf(stringResource(R.string.study_for_white), stringResource(R.string.study_for_black)),
|
||||
selectedIndex = side
|
||||
) { side = it }
|
||||
|
||||
val lockedMessage = overview.learning?.let { stringResource(R.string.study_locked_toast, it.course.name) }
|
||||
val sideColor = if (side == 0) PieceColor.WHITE else PieceColor.BLACK
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val columns = when {
|
||||
maxWidth < 360.dp -> 2
|
||||
maxWidth < 600.dp -> 3
|
||||
else -> 4
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
overview.courses.filter { it.course.side == sideColor }.chunked(columns).forEach { row ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
row.forEach { info ->
|
||||
CourseCard(info, Modifier.weight(1f)) {
|
||||
if (viewModel.open(info.course)) onOpenTrainer()
|
||||
else if (lockedMessage != null) Toast.makeText(context, lockedMessage, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
repeat(columns - row.size) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "3/24 ★ 1" in the top bar. */
|
||||
@Composable
|
||||
private fun HeaderProgress(overview: StudyOverview) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) {
|
||||
Text("${overview.learnedCount}/${overview.courses.size}", color = ink.copy(alpha = 0.8f), style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
AppIcon(R.drawable.ic_star_badge, size = 16.dp, tint = StarGold)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("${overview.masteredCount}", color = ink.copy(alpha = 0.8f), style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one thing to do now, shown only when there is something to do: review, continue the
|
||||
* course in progress, or start the first course. Otherwise just the time of the next review.
|
||||
*/
|
||||
@Composable
|
||||
private fun NextAction(overview: StudyOverview, viewModel: OpeningStudyViewModel, onOpenTrainer: () -> Unit) {
|
||||
val firstNew = overview.courses.firstOrNull { it.status == CourseStatus.NEW }
|
||||
val learning = overview.learning
|
||||
val nextDueAt = overview.nextDueAt
|
||||
when {
|
||||
overview.dueCards > 0 -> ActionCard(
|
||||
icon = R.drawable.ic_review,
|
||||
title = stringResource(R.string.study_review_title),
|
||||
subtitle = stringResource(R.string.study_review_summary, overview.dueCards, overview.dueCourses),
|
||||
button = stringResource(R.string.study_review_button)
|
||||
) { if (viewModel.reviewNext()) onOpenTrainer() }
|
||||
learning != null -> ActionCard(
|
||||
icon = R.drawable.ic_book,
|
||||
title = stringResource(R.string.study_continue_title),
|
||||
subtitle = learning.course.name,
|
||||
button = stringResource(R.string.study_continue_button),
|
||||
progress = learning.learned.toFloat() / learning.total.coerceAtLeast(1)
|
||||
) { if (viewModel.open(learning.course)) onOpenTrainer() }
|
||||
overview.learnedCount == 0 && firstNew != null -> ActionCard(
|
||||
icon = R.drawable.ic_book,
|
||||
title = stringResource(R.string.study_start_with, firstNew.course.name),
|
||||
subtitle = null,
|
||||
button = stringResource(R.string.study_start_button)
|
||||
) { if (viewModel.open(firstNew.course)) onOpenTrainer() }
|
||||
nextDueAt != null -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppIcon(R.drawable.ic_review, size = 16.dp, tint = ink.copy(alpha = 0.55f))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.study_next_review, formatDueIn(nextDueAt)), color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionCard(icon: Int, title: String, subtitle: String?, button: String, progress: Float? = null, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.glass(RoundedCornerShape(UiCornerRadius))
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppIcon(icon, size = 22.dp, tint = StarGold)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, color = ink, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (subtitle != null) {
|
||||
Text(subtitle, color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (progress != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
ProgressBar(progress, height = 4)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
GlassButton(onClick = onClick) { Text(button, color = ink, maxLines = 1) }
|
||||
}
|
||||
}
|
||||
|
||||
/** A course card without a frame: the knight on a soft glow, name, size and one status line. */
|
||||
@Composable
|
||||
private fun CourseCard(info: CourseInfo, modifier: Modifier, onClick: () -> Unit) {
|
||||
val locked = info.status == CourseStatus.LOCKED
|
||||
val glow = when {
|
||||
info.mastered -> StarGold.copy(alpha = 0.20f)
|
||||
info.status == CourseStatus.DUE -> ChessGreen.copy(alpha = 0.22f)
|
||||
else -> ink.copy(alpha = 0.08f)
|
||||
}
|
||||
val family = info.course.name.substringBefore(':').trim()
|
||||
val variation = info.course.name.substringAfter(':', "").trim()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.alpha(if (locked) 0.4f else 1f)
|
||||
.clip(RoundedCornerShape(UiCornerRadius))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.85f)
|
||||
.background(Brush.radialGradient(listOf(glow, Color.Transparent))),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(info.course.artRes()),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().padding(6.dp)
|
||||
)
|
||||
when {
|
||||
info.mastered -> AppIcon(R.drawable.ic_star_badge, size = 22.dp, tint = StarGold, modifier = Modifier.align(Alignment.TopEnd))
|
||||
info.status == CourseStatus.DUE -> AppIcon(R.drawable.ic_review, size = 20.dp, tint = ChessGreen, modifier = Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(family, color = ink, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
variation.ifEmpty { " " },
|
||||
color = ink.copy(alpha = 0.65f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
minLines = 2,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppIcon(R.drawable.ic_timer, size = 12.dp, tint = ink.copy(alpha = 0.55f))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
LocalContext.current.resources.getQuantityString(R.plurals.study_moves_count, info.total, info.total),
|
||||
color = ink.copy(alpha = 0.55f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
// One status line of fixed height, so rows stay even; empty for a new course.
|
||||
Box(modifier = Modifier.fillMaxWidth().height(20.dp), contentAlignment = Alignment.CenterStart) {
|
||||
when (info.status) {
|
||||
CourseStatus.NEW -> Unit
|
||||
CourseStatus.LOCKED -> Text(stringResource(R.string.study_locked_short), color = ink.copy(alpha = 0.7f), style = MaterialTheme.typography.labelSmall)
|
||||
CourseStatus.LEARNING -> ProgressBar(info.learned.toFloat() / info.total.coerceAtLeast(1), height = 4)
|
||||
CourseStatus.DUE -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(7.dp).clip(CircleShape).background(ChessGreen))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.study_status_due, info.dueCards), color = ChessGreen, style = MaterialTheme.typography.labelSmall, maxLines = 1)
|
||||
}
|
||||
CourseStatus.WAITING -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (info.stars > 0) Stars(info.stars, size = 12.dp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
info.nextDueAt?.let {
|
||||
Text(formatDueIn(it), color = ink.copy(alpha = 0.55f), style = MaterialTheme.typography.labelSmall, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Stars(count: Int, size: androidx.compose.ui.unit.Dp = 16.dp) {
|
||||
Row {
|
||||
repeat(3) { i ->
|
||||
AppIcon(R.drawable.ic_star, size = size, tint = if (i < count) StarGold else ink.copy(alpha = 0.2f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProgressBar(fraction: Float, height: Int = 8) {
|
||||
val f = fraction.coerceIn(0f, 1f)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height.dp)
|
||||
.clip(RoundedCornerShape((height / 2).dp))
|
||||
.background(ink.copy(alpha = 0.12f))
|
||||
) {
|
||||
if (f > 0f) Box(Modifier.fillMaxWidth(f).fillMaxHeight().clip(RoundedCornerShape((height / 2).dp)).background(ChessGreen))
|
||||
}
|
||||
}
|
||||
|
||||
/** "5 min", "4 h", "3 d" until [at]; "now" if already due. */
|
||||
@Composable
|
||||
fun formatDueIn(at: Long): String {
|
||||
val diff = at - System.currentTimeMillis()
|
||||
val minute = 60_000L
|
||||
val hour = 60 * minute
|
||||
val day = 24 * hour
|
||||
return when {
|
||||
diff <= 0 -> stringResource(R.string.study_due_now)
|
||||
diff < hour -> stringResource(R.string.study_minutes, ((diff + minute - 1) / minute).toInt())
|
||||
diff < day -> stringResource(R.string.study_hours, ((diff + hour - 1) / hour).toInt())
|
||||
else -> stringResource(R.string.study_days, ((diff + day - 1) / day).toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.engine.Board
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.openings.OpeningBook
|
||||
import cc.skysparkle.matewave.ui.components.ButtonIcon
|
||||
import cc.skysparkle.matewave.ui.components.ChessBoardView
|
||||
import cc.skysparkle.matewave.ui.components.GlassButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassCard
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.MovesBar
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
import cc.skysparkle.matewave.viewmodel.OpeningStudyViewModel
|
||||
import cc.skysparkle.matewave.viewmodel.SessionMode
|
||||
import cc.skysparkle.matewave.viewmodel.TrainerPhase
|
||||
|
||||
@Composable
|
||||
fun OpeningTrainerScreen(viewModel: OpeningStudyViewModel, onBack: () -> Unit) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
DisposableEffect(viewModel) { onDispose { viewModel.leave() } }
|
||||
val course = state.course ?: return
|
||||
val startBoard = remember { Board.startPosition() }
|
||||
val sans = state.sans
|
||||
val opening = remember(sans) { OpeningBook.openingName(sans) }
|
||||
|
||||
GlassScaffold(title = course.name, onBack = onBack) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
stringResource(
|
||||
when (state.mode) {
|
||||
SessionMode.LEARN -> R.string.study_mode_learn
|
||||
SessionMode.REVIEW -> R.string.study_mode_review
|
||||
SessionMode.PRACTICE -> R.string.study_mode_practice
|
||||
}
|
||||
),
|
||||
color = ChessGreen,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
val asked = state.asked.size
|
||||
if (asked > 0 && state.phase != TrainerPhase.COMPLETE) {
|
||||
Text(
|
||||
stringResource(R.string.study_progress, minOf(state.answered + 1, asked), asked),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
ProgressBar(state.answered.toFloat() / state.asked.size.coerceAtLeast(1))
|
||||
|
||||
MovesBar(sans = sans, startBoard = startBoard, currentIndex = sans.size, opening = opening)
|
||||
|
||||
ChessBoardView(
|
||||
board = state.board,
|
||||
selectedSquare = state.selectedSquare,
|
||||
legalTargets = state.legalTargets,
|
||||
kingInCheckSquare = null,
|
||||
flipped = course.side == PieceColor.BLACK,
|
||||
onSquareClick = { viewModel.onSquareClick(it) },
|
||||
lastMove = state.lastMove,
|
||||
hintSquare = state.hintSquare,
|
||||
interactionEnabled = state.phase == TrainerPhase.YOUR_MOVE || state.phase == TrainerPhase.WRONG
|
||||
)
|
||||
|
||||
when (state.phase) {
|
||||
TrainerPhase.YOUR_MOVE -> Text(
|
||||
stringResource(if (state.mode == SessionMode.LEARN) R.string.study_learn_prompt else R.string.study_recall_prompt),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
TrainerPhase.AUTO_MOVE -> Text(
|
||||
stringResource(R.string.study_opponent),
|
||||
color = ink.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
TrainerPhase.WRONG -> Text(
|
||||
stringResource(R.string.study_wrong),
|
||||
color = Color(0xFFFF8A80),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
TrainerPhase.COMPLETE -> {
|
||||
val result = state.result
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(if (state.mode == SessionMode.LEARN) R.string.study_result_learned else R.string.study_result_review),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Stars(result?.stars ?: 0, size = 22.dp)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val change = state.starChange
|
||||
val stars = result?.stars ?: 0
|
||||
val (starText, starColor) = when {
|
||||
change != null && change.after > change.before ->
|
||||
stringResource(R.string.study_star_gained) to StarGold
|
||||
change != null && change.after < change.before ->
|
||||
stringResource(R.string.study_star_lost) to Color(0xFFFF8A80)
|
||||
stars >= 3 -> stringResource(R.string.study_all_stars) to StarGold
|
||||
state.mode == SessionMode.PRACTICE -> stringResource(R.string.study_practice_no_stars) to ink.copy(alpha = 0.65f)
|
||||
stars > 0 -> stringResource(R.string.study_reviews_to_next, result?.reviewsToNext ?: 0) to ink.copy(alpha = 0.8f)
|
||||
else -> "" to ink
|
||||
}
|
||||
if (starText.isNotEmpty()) {
|
||||
Text(starText, color = starColor, style = MaterialTheme.typography.bodyMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
if (state.asked.isNotEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.study_result_first_try, state.firstTry, state.asked.size),
|
||||
color = ink.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
result?.nextDueAt?.let {
|
||||
Text(
|
||||
stringResource(R.string.study_next_review, formatDueIn(it)),
|
||||
color = ink.copy(alpha = 0.65f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
GlassOutlinedButton(onClick = { viewModel.practiceAgain() }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.study_again), color = ink, maxLines = 1)
|
||||
}
|
||||
GlassButton(onClick = { if (!viewModel.continueStudy()) onBack() }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.study_next), color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.phase != TrainerPhase.COMPLETE) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (state.mode != SessionMode.LEARN) {
|
||||
GlassOutlinedButton(
|
||||
onClick = { viewModel.showHint() },
|
||||
enabled = state.phase == TrainerPhase.YOUR_MOVE,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
ButtonIcon(R.drawable.ic_hint)
|
||||
Text(stringResource(R.string.puzzle_hint), color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
// A review is not restarted: it would only replay the moves still due.
|
||||
if (state.mode != SessionMode.REVIEW) {
|
||||
GlassOutlinedButton(onClick = { viewModel.restart() }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.study_restart), color = ink, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.settings.FeedbackSettings
|
||||
import cc.skysparkle.matewave.network.AppPermissions
|
||||
import cc.skysparkle.matewave.network.DisconnectReason
|
||||
import cc.skysparkle.matewave.network.NetworkLog
|
||||
import cc.skysparkle.matewave.network.NetworkRequirements
|
||||
import cc.skysparkle.matewave.ui.components.GlassCard
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.rememberPermissionRequester
|
||||
|
||||
@Composable
|
||||
fun PermissionsScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var tick by remember { mutableStateOf(0) }
|
||||
val status = remember(tick) { NetworkRequirements.check(context) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) { kotlinx.coroutines.delay(2_000); tick++ }
|
||||
}
|
||||
|
||||
val requestNearby = rememberPermissionRequester(
|
||||
permissions = AppPermissions.nearby,
|
||||
rationaleTitle = stringResource(R.string.mesh_permission_title),
|
||||
rationaleText = stringResource(R.string.mesh_permission_text),
|
||||
onResult = { tick++ }
|
||||
)
|
||||
|
||||
GlassScaffold(title = stringResource(R.string.permissions_card_title), onBack = onBack) {
|
||||
PermissionItem(
|
||||
iconRes = R.drawable.ic_communication,
|
||||
granted = status.permissionsGranted,
|
||||
title = stringResource(R.string.perm_nearby_title),
|
||||
subtitle = stringResource(R.string.perm_nearby_text),
|
||||
onGrant = { requestNearby() }
|
||||
)
|
||||
|
||||
val presenceSettings = remember { cc.skysparkle.matewave.presence.PresenceSettings(context) }
|
||||
var announce by remember { mutableStateOf(presenceSettings.announceEnabled) }
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_online, size = 24.dp)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.perm_announce_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.perm_announce_text),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = announce,
|
||||
onCheckedChange = {
|
||||
announce = it
|
||||
presenceSettings.announceEnabled = it
|
||||
|
||||
cc.skysparkle.matewave.network.Transports.applyAnnounceSetting()
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = Color(0xFF4F8A5B)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val feedbackSettings = remember { FeedbackSettings(context) }
|
||||
var vibration by remember { mutableStateOf(feedbackSettings.vibrationEnabled) }
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_vibration, size = 24.dp)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.perm_vibration_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.perm_vibration_text),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = vibration,
|
||||
onCheckedChange = {
|
||||
vibration = it
|
||||
feedbackSettings.vibrationEnabled = it
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = Color(0xFF4F8A5B)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var background by remember { mutableStateOf(presenceSettings.backgroundEnabled) }
|
||||
val requestNotifications = rememberPermissionRequester(
|
||||
permissions = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else emptyList(),
|
||||
rationaleTitle = stringResource(R.string.perm_background_title),
|
||||
rationaleText = stringResource(R.string.perm_background_notification),
|
||||
onResult = { granted ->
|
||||
if (granted) {
|
||||
cc.skysparkle.matewave.presence.PresenceService.start(context)
|
||||
} else {
|
||||
background = false
|
||||
presenceSettings.backgroundEnabled = false
|
||||
}
|
||||
}
|
||||
)
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_background_activity, size = 24.dp)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.perm_background_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.perm_background_text),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = background,
|
||||
onCheckedChange = {
|
||||
background = it
|
||||
presenceSettings.backgroundEnabled = it
|
||||
if (it) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU &&
|
||||
!cc.skysparkle.matewave.network.AppPermissions.allGranted(
|
||||
context, listOf(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
)
|
||||
) {
|
||||
requestNotifications()
|
||||
} else {
|
||||
cc.skysparkle.matewave.presence.PresenceService.start(context)
|
||||
}
|
||||
} else {
|
||||
cc.skysparkle.matewave.presence.PresenceService.stop(context)
|
||||
}
|
||||
},
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = Color(0xFF4F8A5B)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (background) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(
|
||||
R.drawable.ic_notification, tint = Color(0xFFFFC46B), size = 16.dp
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.perm_background_notification),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (background && !cc.skysparkle.matewave.network.BatteryOptimization.isIgnoring(context)) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.perm_battery_needed),
|
||||
color = Color(0xFFFFC46B),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
GlassOutlinedButton(
|
||||
onClick = { cc.skysparkle.matewave.network.BatteryOptimization.openSettings(context) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_go_settings)
|
||||
Text(stringResource(R.string.perm_battery_open), color = ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val lastReason by NetworkLog.lastReason.collectAsState()
|
||||
if (lastReason != DisconnectReason.NONE) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(
|
||||
R.drawable.ic_error, tint = Color(0xFFFFC46B), size = 16.dp
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.diag_last_reason, stringResource(lastReason.textRes)),
|
||||
color = Color(0xFFFFC46B),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val metricsTick by cc.skysparkle.matewave.network.NetworkMetrics.updated.collectAsState()
|
||||
val metricsText = remember(metricsTick) {
|
||||
cc.skysparkle.matewave.network.NetworkMetrics.asText(context.resources)
|
||||
}
|
||||
Text(
|
||||
metricsText.trim(),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
GlassOutlinedButton(
|
||||
onClick = {
|
||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE)
|
||||
as android.content.ClipboardManager
|
||||
|
||||
val report = buildString {
|
||||
appendLine(context.getString(R.string.diag_report_counters))
|
||||
append(cc.skysparkle.matewave.network.NetworkMetrics.asText(context.resources))
|
||||
appendLine()
|
||||
appendLine(context.getString(R.string.diag_report_events))
|
||||
append(NetworkLog.asText().ifBlank { context.getString(R.string.diag_log_empty) })
|
||||
}
|
||||
clipboard.setPrimaryClip(
|
||||
android.content.ClipData.newPlainText("network report", report)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
cc.skysparkle.matewave.ui.components.ButtonIcon(R.drawable.ic_copy)
|
||||
Text(stringResource(R.string.diag_copy_log), color = ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionItem(
|
||||
granted: Boolean,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onGrant: () -> Unit,
|
||||
iconRes: Int? = null
|
||||
) {
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
cc.skysparkle.matewave.ui.components.AppIcon(
|
||||
resId = if (granted) R.drawable.ic_approve else R.drawable.ic_cancel,
|
||||
size = 20.dp,
|
||||
tint = if (granted) Color(0xFF7BE495) else Color(0xFFFF6B6B)
|
||||
)
|
||||
if (iconRes != null) {
|
||||
cc.skysparkle.matewave.ui.components.AppIcon(iconRes, size = 22.dp)
|
||||
}
|
||||
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
Text(subtitle, color = ink.copy(alpha = 0.75f), style = MaterialTheme.typography.bodySmall)
|
||||
if (!granted) {
|
||||
GlassOutlinedButton(onClick = onGrant, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.perm_grant), color = ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.engine.ChessEngine
|
||||
import cc.skysparkle.matewave.engine.Notation
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.puzzles.Puzzle
|
||||
import cc.skysparkle.matewave.puzzles.PuzzleRepository
|
||||
import cc.skysparkle.matewave.puzzles.themeTitleRes
|
||||
import cc.skysparkle.matewave.stats.Stats
|
||||
import cc.skysparkle.matewave.ui.components.ButtonIcon
|
||||
import cc.skysparkle.matewave.ui.components.ChessBoardView
|
||||
import cc.skysparkle.matewave.ui.components.GlassButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.components.PromotionDialog
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
import cc.skysparkle.matewave.viewmodel.GameViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val WrongRed = Color(0xFFFF6B6B)
|
||||
|
||||
@Composable
|
||||
fun PuzzleSolveScreen(viewModel: GameViewModel, onBack: () -> Unit, onNextPuzzle: (Puzzle) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val puzzle = state.puzzle
|
||||
|
||||
// Confirmation on the board: the destination of a correct move flashes green; a wrong
|
||||
// move stays red until it is taken back.
|
||||
var greenFlash by remember { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(state.puzzleMoveIndex, state.puzzleSolved) {
|
||||
if ((state.puzzleMoveIndex > 0 || state.puzzleSolved) && !state.puzzleFailedAttempt) {
|
||||
greenFlash = state.lastMove?.to
|
||||
delay(450)
|
||||
greenFlash = null
|
||||
}
|
||||
}
|
||||
val redSquare = if (state.puzzleFailedAttempt && state.puzzleAutoMoving) state.lastMove?.to else null
|
||||
|
||||
GlassScaffold(
|
||||
title = puzzle?.let { stringResource(R.string.puzzle_title_rating, it.rating) } ?: stringResource(R.string.puzzle_title_fallback),
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
if (puzzle != null) {
|
||||
Text(
|
||||
stringResource(puzzle.themeTitleRes()).uppercase(),
|
||||
color = ChessGreen,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(ChessGreen.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
// On tablets the board is capped and centred, so the task and buttons fit without scrolling.
|
||||
ChessBoardView(
|
||||
board = state.board,
|
||||
selectedSquare = state.selectedSquare,
|
||||
legalTargets = state.legalTargetsFromSelected,
|
||||
kingInCheckSquare = if (ChessEngine.isInCheck(state.board, state.board.sideToMove)) {
|
||||
state.board.kingSquare(state.board.sideToMove)
|
||||
} else null,
|
||||
flipped = state.myColor == PieceColor.BLACK,
|
||||
onSquareClick = viewModel::onSquareClicked,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).widthIn(max = 560.dp),
|
||||
lastMove = state.lastMove,
|
||||
hintSquare = state.puzzleHintFrom,
|
||||
interactionEnabled = state.puzzleStarted && !state.puzzleAutoMoving && !state.puzzleSolved && state.pendingPromotion == null,
|
||||
flashSquare = redSquare ?: greenFlash,
|
||||
flashColor = if (redSquare != null) WrongRed.copy(alpha = 0.55f) else ChessGreen.copy(alpha = 0.55f)
|
||||
)
|
||||
|
||||
// The task, in the same format as the status in a game: a dot in the side's colour.
|
||||
val (text, color) = when {
|
||||
state.puzzleSolved -> "✓ " + stringResource(R.string.puzzle_solved) to ChessGreen
|
||||
state.puzzleFailedAttempt -> "✗ " + stringResource(R.string.puzzle_wrong_move) to WrongRed
|
||||
state.puzzleAutoMoving -> stringResource(R.string.opponent_turn) to ink.copy(alpha = 0.7f)
|
||||
state.myColor == PieceColor.WHITE -> stringResource(R.string.puzzle_find_best_white) to ink
|
||||
else -> stringResource(R.string.puzzle_find_best_black) to ink
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (state.myColor == PieceColor.WHITE) Color(0xFFF0F0F0) else Color(0xFF222222))
|
||||
.border(1.dp, ink.copy(alpha = 0.5f), CircleShape)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(text, color = color, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
// Progress through multi-move puzzles: one dot per move of the solver.
|
||||
if (puzzle != null) {
|
||||
val total = (puzzle.solutionUci.size + 1) / 2
|
||||
if (total > 1) {
|
||||
val done = if (state.puzzleSolved) total else state.puzzleMoveIndex / 2
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
repeat(total) { i ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (i < done) ChessGreen else ink.copy(alpha = 0.2f))
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.puzzle_move_of, minOf(done + 1, total), total),
|
||||
color = ink.copy(alpha = 0.55f),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.puzzleSolutionRevealed && !state.puzzleSolved) {
|
||||
val expectedUci = puzzle?.solutionUci?.getOrNull(state.puzzleMoveIndex)
|
||||
val san = expectedUci?.let { uci ->
|
||||
ChessEngine.legalMoves(state.board).firstOrNull { it.toUci() == uci }?.let { Notation.toSan(state.board, it, withCheck = true) }
|
||||
}
|
||||
Text(
|
||||
text = if (san != null) stringResource(R.string.puzzle_solution_prefix, san) else stringResource(R.string.puzzle_solution_unavailable),
|
||||
color = ink.copy(alpha = 0.85f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
if (state.puzzleSolved) {
|
||||
val next = remember(puzzle?.id) {
|
||||
PuzzleRepository.pickForRating(
|
||||
context,
|
||||
cc.skysparkle.matewave.data.ProfileStore.get().elo,
|
||||
Stats.solvedPuzzles(),
|
||||
exclude = puzzle?.id
|
||||
)
|
||||
}
|
||||
if (next != null) {
|
||||
GlassButton(onClick = { onNextPuzzle(next) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.puzzle_next), color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.puzzle_solved_count, Stats.solvedPuzzles().size),
|
||||
color = ink.copy(alpha = 0.55f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (state.puzzleStarted) {
|
||||
// The hint is the action; showing the solution is giving up, so it is a quiet link.
|
||||
GlassOutlinedButton(
|
||||
onClick = { viewModel.showPuzzleHint() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.puzzleAutoMoving
|
||||
) {
|
||||
ButtonIcon(R.drawable.ic_hint)
|
||||
Text(stringResource(R.string.puzzle_hint), color = ink)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.puzzle_show_solution),
|
||||
color = ink.copy(alpha = if (state.puzzleAutoMoving) 0.3f else 0.6f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = !state.puzzleAutoMoving) { viewModel.revealPuzzleSolution() }
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.pendingPromotion?.let {
|
||||
PromotionDialog(
|
||||
color = state.board.sideToMove,
|
||||
onChoose = viewModel::choosePromotion,
|
||||
onDismiss = viewModel::cancelPromotion
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.components.ListDivider
|
||||
import cc.skysparkle.matewave.ui.components.ListGroup
|
||||
import cc.skysparkle.matewave.ui.components.ListRow
|
||||
import cc.skysparkle.matewave.ui.components.SectionCaption
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ui.components.DialogAccent
|
||||
import cc.skysparkle.matewave.ui.components.DialogAction
|
||||
import cc.skysparkle.matewave.ui.components.AppDialog
|
||||
import cc.skysparkle.matewave.audio.RadioState
|
||||
import cc.skysparkle.matewave.audio.SoundManager
|
||||
import cc.skysparkle.matewave.ui.components.AppBottomBar
|
||||
import cc.skysparkle.matewave.ui.components.BottomTab
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onHomeClick: () -> Unit,
|
||||
onFriendsClick: () -> Unit,
|
||||
onAccountClick: () -> Unit,
|
||||
onOpenPermissions: () -> Unit,
|
||||
onOpenAppearance: () -> Unit,
|
||||
onOpenLicenses: () -> Unit,
|
||||
onOpenShare: () -> Unit
|
||||
) {
|
||||
var musicOn by remember { mutableStateOf(SoundManager.musicEnabled) }
|
||||
var sfxOn by remember { mutableStateOf(SoundManager.sfxEnabled) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val myProfile = remember { cc.skysparkle.matewave.data.ProfileStore.get() }
|
||||
val versionName = remember {
|
||||
runCatching {
|
||||
val pm = context.packageManager
|
||||
val info = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
pm.getPackageInfo(context.packageName, android.content.pm.PackageManager.PackageInfoFlags.of(0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pm.getPackageInfo(context.packageName, 0)
|
||||
}
|
||||
info.versionName
|
||||
}.getOrNull() ?: "—"
|
||||
}
|
||||
|
||||
var showAbout by remember { mutableStateOf(false) }
|
||||
var statusTick by remember { mutableStateOf(0) }
|
||||
val status = remember(statusTick) {
|
||||
cc.skysparkle.matewave.network.NetworkRequirements.check(context)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) { kotlinx.coroutines.delay(3_000); statusTick++ }
|
||||
}
|
||||
|
||||
val feedback = remember { cc.skysparkle.matewave.settings.FeedbackSettings(context) }
|
||||
val appearance = remember { cc.skysparkle.matewave.settings.BoardAppearance(context) }
|
||||
var vibrationOn by remember { mutableStateOf(feedback.vibrationEnabled) }
|
||||
var openingBarOn by remember { mutableStateOf(appearance.showOpening) }
|
||||
val radioState by SoundManager.radioState.collectAsState()
|
||||
|
||||
GlassScaffold(
|
||||
title = stringResource(R.string.nav_settings),
|
||||
onBack = null,
|
||||
bottomBar = {
|
||||
AppBottomBar(
|
||||
current = BottomTab.SETTINGS,
|
||||
onHomeClick = onHomeClick,
|
||||
onFriendsClick = onFriendsClick,
|
||||
onSettingsClick = { },
|
||||
onAccountClick = onAccountClick,
|
||||
accountAvatarBase64 = myProfile.avatarBase64
|
||||
)
|
||||
}
|
||||
) {
|
||||
SectionCaption(stringResource(R.string.settings_section_sound))
|
||||
ListGroup {
|
||||
ListRow(
|
||||
title = stringResource(R.string.settings_radio),
|
||||
subtitle = stringResource(R.string.settings_radio_subtitle),
|
||||
icon = R.drawable.ic_music,
|
||||
// Radio status sits in the row instead of a stray line under it.
|
||||
value = if (!musicOn) null else when (radioState) {
|
||||
RadioState.CONNECTING -> stringResource(R.string.settings_radio_connecting)
|
||||
RadioState.ERROR -> stringResource(R.string.settings_radio_error)
|
||||
else -> null
|
||||
},
|
||||
valueColor = if (radioState == RadioState.ERROR) Color(0xFFFF6B6B) else ink.copy(alpha = 0.55f),
|
||||
trailing = { SettingSwitch(musicOn) { musicOn = it; SoundManager.setMusicEnabled(it) } }
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.settings_sfx),
|
||||
subtitle = stringResource(R.string.settings_sfx_subtitle),
|
||||
icon = R.drawable.ic_sound_effect,
|
||||
trailing = { SettingSwitch(sfxOn) { sfxOn = it; SoundManager.setSfxEnabled(it) } }
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.perm_vibration_title),
|
||||
icon = R.drawable.ic_vibration,
|
||||
trailing = { SettingSwitch(vibrationOn) { vibrationOn = it; feedback.vibrationEnabled = it } }
|
||||
)
|
||||
}
|
||||
|
||||
SectionCaption(stringResource(R.string.settings_section_game))
|
||||
ListGroup {
|
||||
ListRow(
|
||||
title = stringResource(R.string.appearance_title),
|
||||
subtitle = stringResource(R.string.appearance_subtitle),
|
||||
icon = R.drawable.ic_board_view,
|
||||
chevron = true,
|
||||
onClick = onOpenAppearance
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.appearance_opening_in_game),
|
||||
icon = R.drawable.ic_book,
|
||||
trailing = { SettingSwitch(openingBarOn) { openingBarOn = it; appearance.showOpening = it } }
|
||||
)
|
||||
}
|
||||
|
||||
SectionCaption(stringResource(R.string.settings_section_system))
|
||||
ListGroup {
|
||||
ListRow(
|
||||
title = stringResource(R.string.permissions_card_title),
|
||||
icon = if (status.onlineBlocked) R.drawable.ic_permissions else R.drawable.ic_check_permission,
|
||||
iconTint = if (status.onlineBlocked) Color(0xFFFF6B6B) else ChessGreen,
|
||||
value = stringResource(if (status.onlineBlocked) R.string.permissions_card_blocked_short else R.string.permissions_card_ok_short),
|
||||
valueColor = if (status.onlineBlocked) Color(0xFFFF6B6B) else ink.copy(alpha = 0.55f),
|
||||
chevron = true,
|
||||
onClick = onOpenPermissions
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.privacy_policy),
|
||||
icon = R.drawable.ic_policy,
|
||||
chevron = true,
|
||||
onClick = { cc.skysparkle.matewave.settings.AppLinks.open(context, cc.skysparkle.matewave.settings.AppLinks.PRIVACY_POLICY) }
|
||||
)
|
||||
ListDivider()
|
||||
ListRow(
|
||||
title = stringResource(R.string.about_section),
|
||||
icon = R.drawable.ic_info,
|
||||
value = versionName,
|
||||
chevron = true,
|
||||
onClick = { showAbout = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showAbout) {
|
||||
AppDialog(
|
||||
onDismissRequest = { showAbout = false },
|
||||
title = stringResource(R.string.about_section),
|
||||
actions = { DialogAction(stringResource(R.string.chat_close), onClick = { showAbout = false }) }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_card),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(72.dp).clip(RoundedCornerShape(16.dp))
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.app_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.settings_version, versionName),
|
||||
color = DialogAccent,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.settings_description),
|
||||
color = ink.copy(alpha = 0.78f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
AboutRow(
|
||||
iconRes = R.drawable.ic_share,
|
||||
title = stringResource(R.string.share_row_title),
|
||||
subtitle = stringResource(R.string.share_row_subtitle),
|
||||
onClick = {
|
||||
showAbout = false
|
||||
onOpenShare()
|
||||
}
|
||||
)
|
||||
AboutRow(
|
||||
iconRes = R.drawable.ic_copyright,
|
||||
title = stringResource(R.string.licenses_title),
|
||||
subtitle = stringResource(R.string.licenses_subtitle),
|
||||
onClick = {
|
||||
showAbout = false
|
||||
onOpenLicenses()
|
||||
}
|
||||
)
|
||||
AboutRow(
|
||||
iconRes = R.drawable.ic_repo,
|
||||
title = stringResource(R.string.settings_repo_title),
|
||||
subtitle = cc.skysparkle.matewave.settings.AppLinks.REPOSITORY_LABEL,
|
||||
onClick = { cc.skysparkle.matewave.settings.AppLinks.open(context, cc.skysparkle.matewave.settings.AppLinks.REPOSITORY) }
|
||||
)
|
||||
AboutRow(
|
||||
iconRes = R.drawable.ic_policy,
|
||||
title = stringResource(R.string.privacy_policy),
|
||||
subtitle = "skysparkle.cc/privacy",
|
||||
onClick = { cc.skysparkle.matewave.settings.AppLinks.open(context, cc.skysparkle.matewave.settings.AppLinks.PRIVACY_POLICY) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutRow(iconRes: Int, title: String, subtitle: String, onClick: () -> Unit) {
|
||||
val rowShape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(rowShape)
|
||||
.background(ink.copy(alpha = 0.07f))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
cc.skysparkle.matewave.ui.components.AppIcon(iconRes, size = 28.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, color = ink, style = MaterialTheme.typography.titleMedium)
|
||||
Text(subtitle, color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
cc.skysparkle.matewave.ui.components.AppIcon(
|
||||
R.drawable.ic_arrow_forward, size = 16.dp, tint = ink.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedTrackColor = ChessGreen,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedTrackColor = ink.copy(alpha = 0.15f),
|
||||
uncheckedThumbColor = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import android.content.Intent
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.share.ApkShareServer
|
||||
import cc.skysparkle.matewave.share.ApkSharing
|
||||
import cc.skysparkle.matewave.share.LocalNetwork
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.ButtonIcon
|
||||
import cc.skysparkle.matewave.ui.components.DialogAccent
|
||||
import cc.skysparkle.matewave.ui.components.GlassCard
|
||||
import cc.skysparkle.matewave.ui.components.GlassOutlinedButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ShareApkScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val view = LocalView.current
|
||||
val appName = stringResource(R.string.app_title)
|
||||
val apk = remember { ApkSharing.apkFile(context) }
|
||||
var url by remember { mutableStateOf<String?>(null) }
|
||||
var preparing by remember { mutableStateOf(false) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val server = ApkShareServer(apk, appName)
|
||||
val ip = LocalNetwork.wifiIpv4()
|
||||
if (ip != null && apk.exists()) {
|
||||
val port = runCatching { server.start() }.getOrNull()
|
||||
if (port != null) url = "http://$ip:$port/"
|
||||
}
|
||||
view.keepScreenOn = true
|
||||
onDispose {
|
||||
server.stop()
|
||||
view.keepScreenOn = false
|
||||
}
|
||||
}
|
||||
|
||||
GlassScaffold(title = stringResource(R.string.share_row_title), onBack = onBack) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
AppIcon(R.drawable.ic_wifi, size = 44.dp, tint = DialogAccent)
|
||||
Text(
|
||||
stringResource(R.string.share_wifi_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.share_wifi_subtitle),
|
||||
color = ink.copy(alpha = 0.75f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
val address = url
|
||||
if (address != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(240.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
|
||||
.background(Color.White)
|
||||
.padding(18.dp)
|
||||
) {
|
||||
QrCode(address, Modifier.size(204.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(ink.copy(alpha = 0.10f))
|
||||
.padding(vertical = 14.dp, horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(address, color = ink.copy(alpha = 0.85f), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.share_no_wifi),
|
||||
color = Color(0xFFFFC46B),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.share_apk_size, Formatter.formatShortFileSize(context, apk.length())),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (ApkSharing.isSplitInstall(context)) {
|
||||
Text(
|
||||
stringResource(R.string.share_split_warning),
|
||||
color = Color(0xFFFFC46B),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GlassCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(R.string.share_instructions_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Column(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf(
|
||||
R.string.share_step_1, R.string.share_step_2, R.string.share_step_3, R.string.share_step_4
|
||||
).forEachIndexed { index, step ->
|
||||
Text(
|
||||
"${index + 1}. ${stringResource(step)}",
|
||||
color = ink.copy(alpha = 0.85f),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = ink.copy(alpha = 0.15f))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.share_other_title),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.share_other_hint),
|
||||
color = ink.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
GlassOutlinedButton(
|
||||
onClick = {
|
||||
if (!preparing) {
|
||||
preparing = true
|
||||
failed = false
|
||||
scope.launch {
|
||||
val uri = runCatching { ApkSharing.copyForSharing(context, appName) }.getOrNull()
|
||||
preparing = false
|
||||
if (uri == null) {
|
||||
failed = true
|
||||
} else {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ButtonIcon(R.drawable.ic_share)
|
||||
Text(
|
||||
stringResource(if (preparing) R.string.share_preparing else R.string.share_button),
|
||||
color = ink
|
||||
)
|
||||
}
|
||||
if (failed) {
|
||||
Text(
|
||||
stringResource(R.string.share_failed),
|
||||
color = Color(0xFFFF6B6B),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrCode(text: String, modifier: Modifier = Modifier) {
|
||||
val matrix = remember(text) {
|
||||
QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, mapOf(EncodeHintType.MARGIN to 0))
|
||||
}
|
||||
Canvas(modifier = modifier) {
|
||||
val modules = matrix.width
|
||||
val cell = size.width / modules
|
||||
for (y in 0 until modules) {
|
||||
for (x in 0 until modules) {
|
||||
if (matrix.get(x, y)) {
|
||||
drawRect(Color.Black, Offset(x * cell, y * cell), Size(cell + 0.6f, cell + 0.6f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cc.skysparkle.matewave.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import cc.skysparkle.matewave.ui.components.AppIcon
|
||||
import cc.skysparkle.matewave.ui.components.SectionCaption
|
||||
import cc.skysparkle.matewave.ui.components.TimeControlGrid
|
||||
import cc.skysparkle.matewave.ui.components.displayName
|
||||
|
||||
import cc.skysparkle.matewave.ui.theme.ink
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import cc.skysparkle.matewave.ui.components.SetupSegmented
|
||||
import cc.skysparkle.matewave.ui.theme.ChessGreen
|
||||
import cc.skysparkle.matewave.ui.theme.PanelBorder
|
||||
import cc.skysparkle.matewave.R
|
||||
import cc.skysparkle.matewave.ai.AiDifficulty
|
||||
import cc.skysparkle.matewave.clock.TimeControl
|
||||
import cc.skysparkle.matewave.engine.PieceColor
|
||||
import cc.skysparkle.matewave.ui.components.GlassButton
|
||||
import cc.skysparkle.matewave.ui.components.GlassScaffold
|
||||
|
||||
@Composable
|
||||
private fun difficultyLabel(difficulty: AiDifficulty): String = stringResource(difficultyName(difficulty))
|
||||
|
||||
@Composable
|
||||
fun VsAiSetupScreen(
|
||||
onBack: () -> Unit,
|
||||
onStart: (AiDifficulty, PieceColor?, TimeControl) -> Unit
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val last = remember { cc.skysparkle.matewave.settings.LastGameSetup(context) }
|
||||
var difficulty by remember { mutableStateOf(last.aiDifficulty) }
|
||||
var color by remember { mutableStateOf(last.aiColor) }
|
||||
var timeControl by remember { mutableStateOf(last.aiTime) }
|
||||
val levels = AiDifficulty.entries
|
||||
|
||||
GlassScaffold(
|
||||
title = stringResource(R.string.vsai_title),
|
||||
onBack = onBack,
|
||||
// The start button stays at the bottom, in reach of the thumb, with a summary of the choice.
|
||||
bottomBar = {
|
||||
Box(modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
GlassButton(
|
||||
onClick = {
|
||||
last.aiDifficulty = difficulty
|
||||
last.aiColor = color
|
||||
last.aiTime = timeControl
|
||||
onStart(difficulty, color, timeControl)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.vsai_play_summary, difficultyLabel(difficulty), timeControl.displayName()),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Who you are playing: changes with the level.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp).clip(CircleShape).background(ink.copy(alpha = 0.10f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AppIcon(R.drawable.ic_ai_app, size = 28.dp)
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.game_ai_name, difficultyLabel(difficulty)) + " ≈" +
|
||||
cc.skysparkle.matewave.data.ProfileStore.aiNominalElo(difficulty),
|
||||
color = ink,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(stringResource(aiDescription(difficulty)), color = ink.copy(alpha = 0.65f), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
SectionCaption(stringResource(R.string.difficulty_label))
|
||||
SetupSegmented(
|
||||
options = levels.map { difficultyLabel(it) },
|
||||
selectedIndex = levels.indexOf(difficulty)
|
||||
) { difficulty = levels[it] }
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
levels.forEach {
|
||||
Text(
|
||||
"≈" + cc.skysparkle.matewave.data.ProfileStore.aiNominalElo(it),
|
||||
color = ink.copy(alpha = if (it == difficulty) 0.8f else 0.45f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SectionCaption(stringResource(R.string.your_color))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ColorChip(R.drawable.ic_white_colour, stringResource(R.string.color_white), color == PieceColor.WHITE) { color = PieceColor.WHITE }
|
||||
ColorChip(R.drawable.ic_black_colour, stringResource(R.string.color_black), color == PieceColor.BLACK) { color = PieceColor.BLACK }
|
||||
ColorChip(R.drawable.ic_random_colour, stringResource(R.string.color_random), color == null) { color = null }
|
||||
}
|
||||
|
||||
SectionCaption(stringResource(R.string.time_control_label))
|
||||
TimeControlGrid(selected = timeControl, onSelect = { timeControl = it })
|
||||
}
|
||||
}
|
||||
|
||||
private fun aiDescription(d: AiDifficulty): Int = when (d) {
|
||||
AiDifficulty.EASY -> R.string.ai_desc_easy
|
||||
AiDifficulty.MEDIUM -> R.string.ai_desc_medium
|
||||
AiDifficulty.HARD -> R.string.ai_desc_hard
|
||||
AiDifficulty.EXPERT -> R.string.ai_desc_expert
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorChip(iconRes: Int, text: String, selected: Boolean, onClick: () -> Unit) {
|
||||
val shape = RoundedCornerShape(50)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(if (selected) ChessGreen.copy(alpha = 0.35f) else ink.copy(alpha = 0.10f))
|
||||
.border(1.dp, if (selected) ChessGreen else PanelBorder, shape)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(text, color = ink, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cc.skysparkle.matewave.ui.theme
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val PanelFill = Color(0xFF14171C).copy(alpha = 0.86f)
|
||||
val PanelBorder = Color.White.copy(alpha = 0.10f)
|
||||
|
||||
val UiCornerRadius = 8.dp
|
||||
val GlassShape: Shape = RoundedCornerShape(UiCornerRadius)
|
||||
|
||||
val TabletMaxContentWidth = 600.dp
|
||||
|
||||
fun Modifier.glass(shape: Shape = GlassShape): Modifier = this
|
||||
.clip(shape)
|
||||
.background(PanelFill)
|
||||
|
||||
val GlassBorder = PanelBorder
|
||||
|
||||
val PhotoScrim = Brush.verticalGradient(
|
||||
0.00f to Color.Black.copy(alpha = 0.84f),
|
||||
0.10f to Color.Black.copy(alpha = 0.72f),
|
||||
0.22f to Color.Black.copy(alpha = 0.58f),
|
||||
0.38f to Color.Black.copy(alpha = 0.48f),
|
||||
0.55f to Color.Black.copy(alpha = 0.47f),
|
||||
0.72f to Color.Black.copy(alpha = 0.55f),
|
||||
0.88f to Color.Black.copy(alpha = 0.68f),
|
||||
1.00f to Color.Black.copy(alpha = 0.80f)
|
||||
)
|
||||
|
||||
val DialogScrimColor = Color.Black.copy(alpha = 0.62f)
|
||||
|
||||
val FloatingBarFill = Color.Black.copy(alpha = 0.25f)
|
||||
@@ -0,0 +1,13 @@
|
||||
package cc.skysparkle.matewave.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val LocalInk = compositionLocalOf { Color.White }
|
||||
|
||||
val ink: Color
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = LocalInk.current
|
||||
@@ -0,0 +1,41 @@
|
||||
package cc.skysparkle.matewave.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val ChessGreen = Color(0xFF3E7C4A)
|
||||
val ChessGreenDark = Color(0xFF1B4D2E)
|
||||
val HighlightMove = Color(0x8858A6FF)
|
||||
val HighlightCheck = Color(0x88E53935)
|
||||
val HighlightLastMove = Color(0x664FC3F7)
|
||||
val HighlightHint = Color(0x88FFC107)
|
||||
|
||||
private val DarkScheme = darkColorScheme(
|
||||
primary = ChessGreen,
|
||||
secondary = ChessGreenDark,
|
||||
background = Color(0xFF121212),
|
||||
surface = Color(0xFF1E1E1E)
|
||||
)
|
||||
|
||||
/** The app has a single dark theme; system bars are set up in MainActivity. */
|
||||
@Composable
|
||||
fun MatewaveTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = DarkScheme) {
|
||||
CompositionLocalProvider(LocalInk provides Color.White, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun whiteOutlinedTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = ink,
|
||||
unfocusedTextColor = ink,
|
||||
focusedBorderColor = ink.copy(alpha = 0.6f),
|
||||
unfocusedBorderColor = ink.copy(alpha = 0.3f),
|
||||
focusedLabelColor = ink.copy(alpha = 0.8f),
|
||||
unfocusedLabelColor = ink.copy(alpha = 0.6f),
|
||||
cursorColor = ink
|
||||
)
|
||||
@@ -0,0 +1,305 @@
|
||||
package cc.skysparkle.matewave.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cc.skysparkle.matewave.audio.SoundManager
|
||||
import cc.skysparkle.matewave.engine.Board
|
||||
import cc.skysparkle.matewave.engine.ChessEngine
|
||||
import cc.skysparkle.matewave.engine.Move
|
||||
import cc.skysparkle.matewave.engine.PieceType
|
||||
import cc.skysparkle.matewave.openings.CourseInfo
|
||||
import cc.skysparkle.matewave.openings.CourseStatus
|
||||
import cc.skysparkle.matewave.openings.OpeningBook
|
||||
import cc.skysparkle.matewave.openings.OpeningCourse
|
||||
import cc.skysparkle.matewave.openings.OpeningCourses
|
||||
import cc.skysparkle.matewave.openings.OpeningSrs
|
||||
import cc.skysparkle.matewave.settings.Haptics
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** LEARN: new moves with the piece highlighted. REVIEW: only due moves. PRACTICE: every move. */
|
||||
enum class SessionMode { LEARN, REVIEW, PRACTICE }
|
||||
|
||||
enum class TrainerPhase { YOUR_MOVE, AUTO_MOVE, WRONG, COMPLETE }
|
||||
|
||||
data class TrainerState(
|
||||
val course: OpeningCourse? = null,
|
||||
val mode: SessionMode = SessionMode.LEARN,
|
||||
val board: Board = Board.startPosition(),
|
||||
val ply: Int = 0,
|
||||
val phase: TrainerPhase = TrainerPhase.AUTO_MOVE,
|
||||
val selectedSquare: Int? = null,
|
||||
val legalTargets: List<Move> = emptyList(),
|
||||
val lastMove: Move? = null,
|
||||
val hintSquare: Int? = null,
|
||||
/** Plies the learner has to answer in this session. */
|
||||
val asked: List<Int> = emptyList(),
|
||||
val answered: Int = 0,
|
||||
val firstTry: Int = 0,
|
||||
val wrongOnThisMove: Boolean = false,
|
||||
val result: CourseInfo? = null,
|
||||
/** Set after a review of due moves: stars gained, kept or lost. */
|
||||
val starChange: cc.skysparkle.matewave.openings.StarChange? = null
|
||||
) {
|
||||
val sans: List<String> get() = course?.sans?.take(ply) ?: emptyList()
|
||||
}
|
||||
|
||||
data class StudyOverview(
|
||||
val courses: List<CourseInfo> = emptyList(),
|
||||
val dueCards: Int = 0,
|
||||
val dueCourses: Int = 0,
|
||||
val nextDueAt: Long? = null,
|
||||
val learning: CourseInfo? = null
|
||||
) {
|
||||
val learnedCount: Int get() = courses.count { it.total > 0 && it.learned == it.total }
|
||||
val masteredCount: Int get() = courses.count { it.mastered }
|
||||
}
|
||||
|
||||
class OpeningStudyViewModel : ViewModel() {
|
||||
|
||||
private val _overview = MutableStateFlow(StudyOverview())
|
||||
val overview: StateFlow<StudyOverview> = _overview
|
||||
|
||||
private val _state = MutableStateFlow(TrainerState())
|
||||
val state: StateFlow<TrainerState> = _state
|
||||
|
||||
private var srs: OpeningSrs? = null
|
||||
private var courses: List<OpeningCourse> = emptyList()
|
||||
private var autoJob: Job? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
if (srs != null) { refresh(); return }
|
||||
srs = OpeningSrs(context)
|
||||
viewModelScope.launch {
|
||||
courses = withContext(Dispatchers.IO) {
|
||||
runCatching { OpeningBook.load(context) }
|
||||
runCatching { OpeningCourses.load(context) }.getOrDefault(emptyList())
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recomputes statuses; due times change with the clock, so call it whenever the list is shown. */
|
||||
fun refresh() {
|
||||
val s = srs ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
val inProgress = courses.firstOrNull { s.info(it, now, false).status == CourseStatus.LEARNING }
|
||||
val infos = courses.map { s.info(it, now, anotherInProgress = inProgress != null && inProgress != it) }
|
||||
_overview.value = StudyOverview(
|
||||
courses = infos,
|
||||
dueCards = infos.sumOf { it.dueCards },
|
||||
dueCourses = infos.count { it.status == CourseStatus.DUE },
|
||||
nextDueAt = infos.filter { it.status == CourseStatus.WAITING }.mapNotNull { it.nextDueAt }.minOrNull(),
|
||||
learning = infos.firstOrNull { it.status == CourseStatus.LEARNING }
|
||||
)
|
||||
}
|
||||
|
||||
fun infoOf(course: OpeningCourse): CourseInfo? = _overview.value.courses.firstOrNull { it.course == course }
|
||||
|
||||
/** Opens a course in the mode its state calls for. Returns false for a locked course. */
|
||||
fun open(course: OpeningCourse): Boolean {
|
||||
val info = infoOf(course) ?: return false
|
||||
val mode = when (info.status) {
|
||||
CourseStatus.LOCKED -> return false
|
||||
CourseStatus.NEW, CourseStatus.LEARNING -> SessionMode.LEARN
|
||||
CourseStatus.DUE -> SessionMode.REVIEW
|
||||
CourseStatus.WAITING -> SessionMode.PRACTICE
|
||||
}
|
||||
start(course, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
fun reviewNext(): Boolean {
|
||||
val next = _overview.value.courses.firstOrNull { it.status == CourseStatus.DUE } ?: return false
|
||||
start(next.course, SessionMode.REVIEW)
|
||||
return true
|
||||
}
|
||||
|
||||
fun practiceAgain() {
|
||||
_state.value.course?.let { start(it, SessionMode.PRACTICE) }
|
||||
}
|
||||
|
||||
/** Next thing to do after a session: a due review, then continuing or starting a course. */
|
||||
fun continueStudy(): Boolean {
|
||||
refresh()
|
||||
val o = _overview.value
|
||||
val current = _state.value.course
|
||||
val target = o.courses.firstOrNull { it.status == CourseStatus.DUE }
|
||||
?: o.learning
|
||||
?: o.courses.firstOrNull { it.status == CourseStatus.NEW && it.course != current }
|
||||
?: return false
|
||||
return open(target.course)
|
||||
}
|
||||
|
||||
private fun start(course: OpeningCourse, mode: SessionMode) {
|
||||
autoJob?.cancel()
|
||||
val s = srs ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
val learnerPlies = course.moves.indices.filter { course.isLearnerMove(it) }
|
||||
val asked = when (mode) {
|
||||
SessionMode.LEARN -> learnerPlies.filter { s.level(course, it) == 0 }
|
||||
SessionMode.REVIEW -> learnerPlies.filter { s.isDue(course, it, now) }
|
||||
SessionMode.PRACTICE -> learnerPlies
|
||||
}
|
||||
// Learning resumes where it stopped: everything before the first new move is set up instantly.
|
||||
val resumePly = if (mode == SessionMode.LEARN) (asked.firstOrNull() ?: 0) else 0
|
||||
var board = Board.startPosition()
|
||||
for (i in 0 until resumePly) board = ChessEngine.applyMove(board, course.moves[i])
|
||||
_state.value = TrainerState(
|
||||
course = course,
|
||||
mode = mode,
|
||||
board = board,
|
||||
ply = resumePly,
|
||||
lastMove = course.moves.getOrNull(resumePly - 1),
|
||||
asked = asked
|
||||
)
|
||||
continueLine()
|
||||
}
|
||||
|
||||
fun restart() {
|
||||
val s = _state.value
|
||||
val course = s.course ?: return
|
||||
start(course, s.mode)
|
||||
}
|
||||
|
||||
fun showHint() {
|
||||
val s = _state.value
|
||||
val course = s.course ?: return
|
||||
if (s.phase != TrainerPhase.YOUR_MOVE && s.phase != TrainerPhase.WRONG) return
|
||||
// A hint counts like a mistake for scheduling: the move was not recalled.
|
||||
if (!s.wrongOnThisMove && s.mode != SessionMode.LEARN) {
|
||||
srs?.fail(course, s.ply, System.currentTimeMillis())
|
||||
}
|
||||
_state.value = s.copy(hintSquare = course.moves[s.ply].from, wrongOnThisMove = true)
|
||||
}
|
||||
|
||||
fun onSquareClick(square: Int) {
|
||||
val s = _state.value
|
||||
val course = s.course ?: return
|
||||
if (s.phase != TrainerPhase.YOUR_MOVE && s.phase != TrainerPhase.WRONG) return
|
||||
if (s.selectedSquare != null) {
|
||||
val candidates = s.legalTargets.filter { it.to == square }
|
||||
if (candidates.isNotEmpty()) {
|
||||
val move = candidates.firstOrNull { it.promotion == null || it.promotion == PieceType.QUEEN } ?: candidates.first()
|
||||
answer(course, move)
|
||||
return
|
||||
}
|
||||
}
|
||||
val piece = s.board.squares[square]
|
||||
if (piece != null && piece.color == s.board.sideToMove) {
|
||||
SoundManager.playSelect()
|
||||
Haptics.select()
|
||||
_state.value = s.copy(selectedSquare = square, legalTargets = ChessEngine.legalMovesFrom(s.board, square))
|
||||
} else {
|
||||
_state.value = s.copy(selectedSquare = null, legalTargets = emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun answer(course: OpeningCourse, move: Move) {
|
||||
val s = _state.value
|
||||
val expected = course.moves[s.ply]
|
||||
val now = System.currentTimeMillis()
|
||||
if (move.from != expected.from || move.to != expected.to || move.promotion != expected.promotion) {
|
||||
Haptics.alert()
|
||||
if (!s.wrongOnThisMove && s.mode != SessionMode.LEARN) srs?.fail(course, s.ply, now)
|
||||
_state.value = s.copy(
|
||||
phase = TrainerPhase.WRONG,
|
||||
wrongOnThisMove = true,
|
||||
// After a mistake the right piece is shown, so the line can be finished.
|
||||
hintSquare = expected.from,
|
||||
selectedSquare = null,
|
||||
legalTargets = emptyList()
|
||||
)
|
||||
return
|
||||
}
|
||||
when (s.mode) {
|
||||
SessionMode.LEARN -> srs?.learn(course, s.ply, now)
|
||||
else -> if (!s.wrongOnThisMove) srs?.success(course, s.ply, now)
|
||||
}
|
||||
_state.value = s.copy(answered = s.answered + 1, firstTry = s.firstTry + if (s.wrongOnThisMove) 0 else 1)
|
||||
play(expected)
|
||||
continueLine()
|
||||
}
|
||||
|
||||
private fun play(move: Move) {
|
||||
val s = _state.value
|
||||
val capture = s.board.squares[move.to] != null || move.isEnPassant
|
||||
if (capture) SoundManager.playCapture() else SoundManager.playMove()
|
||||
if (capture) Haptics.capture() else Haptics.move()
|
||||
_state.value = s.copy(
|
||||
board = ChessEngine.applyMove(s.board, move),
|
||||
ply = s.ply + 1,
|
||||
lastMove = move,
|
||||
selectedSquare = null,
|
||||
legalTargets = emptyList(),
|
||||
hintSquare = null,
|
||||
wrongOnThisMove = false
|
||||
)
|
||||
}
|
||||
|
||||
/** Auto-plays the opponent's moves and the learner's moves that are not asked this session. */
|
||||
private fun continueLine() {
|
||||
val s = _state.value
|
||||
val course = s.course ?: return
|
||||
when {
|
||||
s.ply >= course.moves.size -> finish()
|
||||
course.isLearnerMove(s.ply) && s.ply in s.asked -> {
|
||||
val hint = if (s.mode == SessionMode.LEARN) course.moves[s.ply].from else null
|
||||
_state.value = s.copy(phase = TrainerPhase.YOUR_MOVE, hintSquare = hint)
|
||||
}
|
||||
else -> {
|
||||
_state.value = s.copy(phase = TrainerPhase.AUTO_MOVE)
|
||||
val ply = s.ply
|
||||
autoJob = viewModelScope.launch {
|
||||
delay(if (course.isLearnerMove(ply)) KNOWN_MOVE_DELAY_MS else REPLY_DELAY_MS)
|
||||
val now = _state.value
|
||||
if (now.course != course || now.ply != ply) return@launch
|
||||
play(course.moves[ply])
|
||||
continueLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trainer screen was closed. A review left after a mistake counts as failed, so quitting
|
||||
* cannot save a star that a finished review would have cost.
|
||||
*/
|
||||
fun leave() {
|
||||
autoJob?.cancel()
|
||||
val s = _state.value
|
||||
val course = s.course ?: return
|
||||
if (s.phase == TrainerPhase.COMPLETE) return
|
||||
val mistake = s.firstTry < s.answered || s.wrongOnThisMove
|
||||
if (s.mode == SessionMode.REVIEW && s.asked.isNotEmpty() && mistake) {
|
||||
val change = srs?.recordReview(course, flawless = false)
|
||||
refresh()
|
||||
_state.value = s.copy(phase = TrainerPhase.COMPLETE, hintSquare = null, result = infoOf(course), starChange = change)
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish() {
|
||||
val s = _state.value
|
||||
val course = s.course ?: return
|
||||
// Only reviews of due moves change stars: they are spaced by the schedule, so stars
|
||||
// cannot be farmed by repeating a line. Practice sessions leave stars alone.
|
||||
val change = if (s.mode == SessionMode.REVIEW && s.asked.isNotEmpty()) {
|
||||
srs?.recordReview(course, flawless = s.firstTry == s.asked.size)
|
||||
} else null
|
||||
refresh()
|
||||
_state.value = s.copy(phase = TrainerPhase.COMPLETE, hintSquare = null, result = infoOf(course), starChange = change)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val REPLY_DELAY_MS = 500L
|
||||
const val KNOWN_MOVE_DELAY_MS = 300L
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |