This commit is contained in:
2026-06-12 14:56:10 +03:00
parent 59d33f8061
commit 2490c02e11
340 changed files with 2286 additions and 4961 deletions
+42
View File
@@ -0,0 +1,42 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version '2.2.0'
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0'
id 'org.openjfx.javafxplugin' version '0.1.0'
id 'com.gradleup.shadow' version '9.3.1'
id 'application'
}
group = 'lab8.prog'
version = '1.0'
repositories {
mavenCentral()
}
javafx {
version = '21'
modules = ['javafx.controls', 'javafx.graphics']
}
dependencies {
implementation project(':common')
implementation project(':client')
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.8.1'
}
application {
mainClass = 'MainKt'
}
shadowJar {
archiveBaseName.set('Lab8-gui-client')
archiveVersion.set('1.0')
manifest {
attributes 'Main-Class': 'MainKt'
}
}
kotlin {
jvmToolchain(17)
}
+23
View File
@@ -0,0 +1,23 @@
import javafx.application.Application
import javafx.stage.Stage
import model.Session
import view.AuthView
import view.MainView
class App : Application() {
override fun start(stage: Stage) {
AuthView(stage) {
val mainStage = Stage()
MainView(mainStage).show()
}.show()
}
override fun stop() {
Session.client?.close()
}
}
fun main() {
Application.launch(App::class.java)
}
@@ -0,0 +1,51 @@
package controller
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import localization.AppLocale
import model.Session
import network.AuthType
import guiclient.network.ClientProvider
import javafx.application.Platform
class AuthController {
private val scope = CoroutineScope(Dispatchers.Default)
fun authenticate(
host: String,
port: Int,
username: String,
password: String,
isLogin: Boolean,
onResult: (Boolean, String) -> Unit,
) {
scope.launch {
Session.client?.close()
val provider = ClientProvider(host, port)
val type = if (isLogin) AuthType.LOGIN else AuthType.REGISTER
val response = provider.auth(username, password, type)
if (response == null) {
Platform.runLater { onResult(false, AppLocale.get("auth.error.server")) }
return@launch
}
if (response.success) {
provider.setToken(response.accessToken ?: "")
provider.setUUID(response.id ?: "")
Session.client = provider
Session.username = username
Session.userId = response.id?.let { kotlin.runCatching { java.util.UUID.fromString(it) }.getOrNull() }
Platform.runLater { onResult(true, "") }
} else {
val errorKey = if (isLogin) "auth.error.failed" else "auth.error.register.failed"
Platform.runLater { onResult(false, response.message.ifEmpty { AppLocale.get(errorKey) }) }
}
}
}
}
@@ -0,0 +1,196 @@
package controller
import javafx.application.Platform
import javafx.collections.FXCollections
import javafx.collections.ListChangeListener
import javafx.collections.ObservableList
import kotlinx.coroutines.*
import localization.AppLocale
import model.HumanBeingProperty
import model.Session
import util.StreamUtils
import java.util.Comparator
import java.util.UUID
class MainController {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var pollingJob: Job? = null
val filteredList: ObservableList<HumanBeingProperty> = FXCollections.observableArrayList()
private var currentFilters: Map<String, String> = emptyMap()
private var currentComparators: List<Comparator<HumanBeingProperty>> = emptyList()
private val collectionListener = ListChangeListener<HumanBeingProperty> { _ ->
reapplyFiltersAndSort()
}
fun start() {
Session.collection.addListener(collectionListener)
startPolling()
}
fun stop() {
pollingJob?.cancel()
scope.cancel()
Session.collection.removeListener(collectionListener)
}
private fun startPolling() {
pollingJob = scope.launch {
while (isActive) {
delay(3000)
refreshCollection()
}
}
}
fun requestRefresh() {
scope.launch {
refreshCollection()
}
}
private suspend fun refreshCollection() {
val response = Session.client?.execute("show") ?: return
val currentIds = Session.collection.map { it.id.value }.toSet()
val serverItems = parseShowResponse(response.message) ?: return
val serverIds = serverItems.map { it.id.value }.toSet()
val toRemove = Session.collection.filter { it.id.value !in serverIds }
val toAdd = serverItems.filter { it.id.value !in currentIds }
val toUpdate = serverItems.filter { item ->
val existing = Session.collection.find { it.id.value == item.id.value }
existing != null && existing.name.value != item.name.value
}
Platform.runLater {
Session.collection.removeAll(toRemove)
Session.collection.addAll(toAdd)
for (updated in toUpdate) {
val idx = Session.collection.indexOfFirst { it.id.value == updated.id.value }
if (idx >= 0) {
Session.collection[idx] = updated
}
}
}
}
private fun parseShowResponse(message: String): List<HumanBeingProperty>? {
if (message.isBlank()) return emptyList()
val lines = message.lines().filter { it.isNotBlank() }
val items = mutableListOf<HumanBeingProperty>()
var currentLines = mutableListOf<String>()
var braceCount = 0
for (line in lines) {
currentLines.add(line)
braceCount += line.count { it == '{' }
braceCount -= line.count { it == '}' }
if (braceCount == 0 && currentLines.isNotEmpty()) {
val parsed = parseHumanBeingBlock(currentLines)
if (parsed != null) items.add(parsed)
currentLines.clear()
}
}
return items
}
private fun parseHumanBeingBlock(lines: List<String>): HumanBeingProperty? {
val text = lines.joinToString("\n")
val id = extractValue(text, "id\\s*=\\s*([\\w-]+)")?.let { safeUUID(it) }
val ownerId = extractValue(text, "owner\\s*=\\s*([\\w-]+)")?.let { safeUUID(it) } ?: return null
val name = extractValue(text, "name\\s*=\\s*(.+?)\\s*\\(a\\.k\\.a\\. Ryan Gosling\\)") ?: return null
val coordX = extractValue(text, "x=(-?\\d+)")?.toLongOrNull() ?: 0L
val coordY = extractValue(text, "y=(-?\\d+)")?.toIntOrNull() ?: 0
val creationDate = extractValue(text, "creationDate\\s*=\\s*(\\S+)")?.let { safeLocalDate(it) }
val realHero = extractValue(text, "realHero\\s*=\\s*(\\w+)")?.toBooleanStrictOrNull() ?: false
val hasToothpick = extractValue(text, "hasToothpick\\s*=\\s*(\\w+)")?.toBooleanStrictOrNull() ?: false
val impactSpeed = extractValue(text, "impactSpeed\\s*=\\s*(-?\\d+\\.?\\d*)")?.toDoubleOrNull() ?: 0.0
val soundtrackName = extractValue(text, "soundtrackName\\s*=\\s*(.+)") ?: ""
val minutesOfWaiting = extractValue(text, "minutesOfWait\\s*=\\s*(-?\\d+\\.?\\d*)")?.toFloatOrNull() ?: 0f
val weaponType = extractValue(text, "weaponType\\s*=\\s*(\\w+)")?.let { safeWeaponType(it) }
val carCool = extractValue(text, "car\\.cool\\s*=\\s*(\\w+)")?.toBooleanStrictOrNull() ?: false
val date = creationDate ?: java.time.LocalDate.now()
val human = models.HumanBeing(
id = id,
ownerId = ownerId,
name = name,
coordinates = models.Coordinates(coordX, coordY),
creationDate = date,
realHero = realHero,
hasToothpick = hasToothpick,
impactSpeed = impactSpeed,
soundtrackName = soundtrackName,
minutesOfWaiting = minutesOfWaiting,
weaponType = weaponType,
car = models.Car(carCool),
)
return HumanBeingProperty(human)
}
private fun extractValue(text: String, pattern: String): String? {
val regex = Regex(pattern)
return regex.find(text)?.groupValues?.getOrNull(1)
}
private fun safeUUID(value: String): UUID? {
return try { UUID.fromString(value) } catch (_: Exception) { null }
}
private fun safeLocalDate(value: String): java.time.LocalDate? {
return try { java.time.LocalDate.parse(value) } catch (_: Exception) { null }
}
private fun safeWeaponType(value: String): models.WeaponType? {
return try { models.WeaponType.valueOf(value.uppercase()) } catch (_: Exception) { null }
}
fun updateFilters(filters: Map<String, String>) {
currentFilters = filters
reapplyFiltersAndSort()
}
fun updateComparators(comparators: List<Comparator<HumanBeingProperty>>) {
currentComparators = comparators
reapplyFiltersAndSort()
}
private fun reapplyFiltersAndSort() {
val result = StreamUtils.applyFiltersAndSort(
Session.collection,
currentFilters,
currentComparators,
)
Platform.runLater {
filteredList.setAll(result)
}
}
fun executeCommand(
name: String,
args: List<String> = emptyList(),
humanBeing: models.HumanBeing? = null,
callback: (Boolean, String) -> Unit = { _, _ -> },
) {
scope.launch {
val response = Session.client?.execute(name, args, humanBeing)
val success = response?.success ?: false
val message = response?.message ?: AppLocale.get("auth.error.server")
Platform.runLater { callback(success, message) }
}
}
fun deleteObject(humanBeingProperty: HumanBeingProperty, callback: (Boolean, String) -> Unit) {
val id = humanBeingProperty.id.value ?: return
executeCommand(name = "remove_by_id", args = listOf(id.toString()), callback = callback)
}
}
@@ -0,0 +1,54 @@
package guiclient.network
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import models.HumanBeing
import network.NetworkManager as ClientNetworkManager
import network.AuthRequest
import network.AuthResponse
import network.AuthType
import network.Request
import network.Response
import java.security.MessageDigest
import java.util.UUID
class ClientProvider(
host: String,
port: Int,
) {
private val net = ClientNetworkManager(host, port)
suspend fun auth(login: String, password: String, type: AuthType): AuthResponse? =
withContext(Dispatchers.IO) {
net.sendRequest(AuthRequest(login, md5Hash(password), type))
}
suspend fun execute(commandName: String, args: List<String> = emptyList(), humanBeing: HumanBeing? = null): Response? =
withContext(Dispatchers.IO) {
val request = Request(
commandName = commandName,
args = args,
humanBeing = humanBeing,
jwt = net.getToken()
)
net.sendRequest(request)
}
fun getToken(): String = net.getToken()
fun setToken(token: String) = net.setToken(token)
fun clearToken() = net.clearToken()
fun getUUID(): UUID = net.getUUID()
fun setUUID(id: String) = net.setUUID(id)
fun close() = net.close()
private fun md5Hash(input: String): String {
val md = MessageDigest.getInstance("MD5")
val hashBytes = md.digest(input.toByteArray())
return hashBytes.joinToString("") { String.format("%02x", it) }
}
}
@@ -0,0 +1,263 @@
package localization
import javafx.beans.property.ObjectProperty
import javafx.beans.property.SimpleObjectProperty
import javafx.scene.control.Labeled
import java.util.Locale
object AppLocale {
val currentLocale: ObjectProperty<Locale> = SimpleObjectProperty(Locale("ru"))
private val strings: Map<Locale, Map<String, String>> = mapOf(
Locale("ru") to mapOf(
"auth.title" to "Вход / Регистрация",
"auth.login" to "Вход",
"auth.register" to "Регистрация",
"auth.username" to "Имя пользователя",
"auth.password" to "Пароль",
"auth.host" to "Сервер",
"auth.port" to "Порт",
"auth.error.invalid.port" to "Некорректный порт (1–65535)",
"auth.submit.login" to "Войти",
"auth.submit.register" to "Зарегистрироваться",
"auth.error.empty" to "Заполните все поля",
"auth.error.failed" to "Ошибка входа. Проверьте логин и пароль.",
"auth.error.register.failed" to "Ошибка регистрации. Возможно, пользователь уже существует.",
"auth.error.server" to "Сервер недоступен",
"auth.success.login" to "Успешный вход",
"auth.success.register" to "Успешная регистрация",
"main.title" to "Коллекция HumanBeing",
"main.user" to "Пользователь",
"main.logout" to "Выйти",
"main.locale" to "Язык",
"main.filter" to "Фильтр",
"visualization.title" to "Визуализация",
"table.name" to "Имя",
"table.coordinates.x" to "X",
"table.coordinates.y" to "Y",
"table.creationDate" to "Дата создания",
"table.realHero" to "Герой",
"table.hasToothpick" to "Зубочистка",
"table.impactSpeed" to "Скорость",
"table.soundtrackName" to "Саундтрек",
"table.minutesOfWaiting" to "Минуты ожидания",
"table.weaponType" to "Оружие",
"table.car.cool" to "Крутая тачка",
"table.owner" to "Владелец",
"button.add" to "Добавить",
"button.edit" to "Редактировать",
"button.delete" to "Удалить",
"button.refresh" to "Обновить",
"dialog.title.add" to "Добавление объекта",
"dialog.title.edit" to "Редактирование объекта",
"dialog.ok" to "OK",
"dialog.cancel" to "Отмена",
"dialog.error.validation" to "Ошибка валидации",
"dialog.error.name" to "Имя не может быть пустым",
"dialog.error.number" to "Некорректное число",
"dialog.error.no.user" to "Ошибка: пользователь не идентифицирован",
"info.title" to "Информация об объекте",
"info.owner" to "Владелец",
"info.id" to "ID",
"delete.confirm" to "Удалить этот объект?",
"delete.confirm.title" to "Подтверждение удаления",
"error.no.selection" to "Объект не выбран",
"error.not.owner" to "Вы не владелец этого объекта",
),
Locale("nl") to mapOf(
"auth.title" to "Inloggen / Registreren",
"auth.login" to "Inloggen",
"auth.register" to "Registreren",
"auth.username" to "Gebruikersnaam",
"auth.password" to "Wachtwoord",
"auth.host" to "Server",
"auth.port" to "Poort",
"auth.error.invalid.port" to "Ongeldige poort (1–65535)",
"auth.submit.login" to "Inloggen",
"auth.submit.register" to "Registreren",
"auth.error.empty" to "Vul alle velden in",
"auth.error.failed" to "Inloggen mislukt. Controleer uw gegevens.",
"auth.error.register.failed" to "Registratie mislukt. Gebruiker bestaat mogelijk al.",
"auth.error.server" to "Server niet bereikbaar",
"auth.success.login" to "Succesvol ingelogd",
"auth.success.register" to "Succesvol geregistreerd",
"main.title" to "HumanBeing Collectie",
"main.user" to "Gebruiker",
"main.logout" to "Uitloggen",
"main.locale" to "Taal",
"main.filter" to "Filter",
"visualization.title" to "Visualisatie",
"table.name" to "Naam",
"table.coordinates.x" to "X",
"table.coordinates.y" to "Y",
"table.creationDate" to "Aanmaakdatum",
"table.realHero" to "Held",
"table.hasToothpick" to "Tandenstoker",
"table.impactSpeed" to "Snelheid",
"table.soundtrackName" to "Soundtrack",
"table.minutesOfWaiting" to "Wachtminuten",
"table.weaponType" to "Wapen",
"table.car.cool" to "Coole auto",
"table.owner" to "Eigenaar",
"button.add" to "Toevoegen",
"button.edit" to "Bewerken",
"button.delete" to "Verwijderen",
"button.refresh" to "Vernieuwen",
"dialog.title.add" to "Object toevoegen",
"dialog.title.edit" to "Object bewerken",
"dialog.ok" to "OK",
"dialog.cancel" to "Annuleren",
"dialog.error.validation" to "Validatiefout",
"dialog.error.name" to "Naam mag niet leeg zijn",
"dialog.error.number" to "Ongeldig nummer",
"dialog.error.no.user" to "Fout: gebruiker niet geïdentificeerd",
"info.title" to "Objectinformatie",
"info.owner" to "Eigenaar",
"info.id" to "ID",
"delete.confirm" to "Dit object verwijderen?",
"delete.confirm.title" to "Verwijderen bevestigen",
"error.no.selection" to "Geen object geselecteerd",
"error.not.owner" to "U bent niet de eigenaar van dit object",
),
Locale("hu") to mapOf(
"auth.title" to "Bejelentkezés / Regisztráció",
"auth.login" to "Bejelentkezés",
"auth.register" to "Regisztráció",
"auth.username" to "Felhasználónév",
"auth.password" to "Jelszó",
"auth.host" to "Szerver",
"auth.port" to "Port",
"auth.error.invalid.port" to "Érvénytelen port (1–65535)",
"auth.submit.login" to "Belépés",
"auth.submit.register" to "Regisztrálás",
"auth.error.empty" to "Töltse ki az összes mezőt",
"auth.error.failed" to "Sikertelen bejelentkezés. Ellenőrizze adatait.",
"auth.error.register.failed" to "Sikertelen regisztráció. Lehet, hogy a felhasználó már létezik.",
"auth.error.server" to "Szerver nem elérhető",
"auth.success.login" to "Sikeres bejelentkezés",
"auth.success.register" to "Sikeres regisztráció",
"main.title" to "HumanBeing Gyűjtemény",
"main.user" to "Felhasználó",
"main.logout" to "Kijelentkezés",
"main.locale" to "Nyelv",
"main.filter" to "Szűrő",
"visualization.title" to "Vizualizáció",
"table.name" to "Név",
"table.coordinates.x" to "X",
"table.coordinates.y" to "Y",
"table.creationDate" to "Létrehozás dátuma",
"table.realHero" to "Hős",
"table.hasToothpick" to "Fogpiszkáló",
"table.impactSpeed" to "Sebesség",
"table.soundtrackName" to "Soundtrack",
"table.minutesOfWaiting" to "Várakozási percek",
"table.weaponType" to "Fegyver",
"table.car.cool" to "Menő autó",
"table.owner" to "Tulajdonos",
"button.add" to "Hozzáadás",
"button.edit" to "Szerkesztés",
"button.delete" to "Törlés",
"button.refresh" to "Frissítés",
"dialog.title.add" to "Objektum hozzáadása",
"dialog.title.edit" to "Objektum szerkesztése",
"dialog.ok" to "OK",
"dialog.cancel" to "Mégse",
"dialog.error.validation" to "Érvényesítési hiba",
"dialog.error.name" to "A név nem lehet üres",
"dialog.error.number" to "Érvénytelen szám",
"dialog.error.no.user" to "Hiba: a felhasználó nincs azonosítva",
"info.title" to "Objektum információ",
"info.owner" to "Tulajdonos",
"info.id" to "ID",
"delete.confirm" to "Törli ezt az objektumot?",
"delete.confirm.title" to "Törlés megerősítése",
"error.no.selection" to "Nincs objektum kiválasztva",
"error.not.owner" to "Ön nem a tulajdonosa ennek az objektumnak",
),
Locale("en", "AU") to mapOf(
"auth.title" to "Sign In / Sign Up",
"auth.login" to "Sign In",
"auth.register" to "Sign Up",
"auth.username" to "Username",
"auth.password" to "Password",
"auth.host" to "Server",
"auth.port" to "Port",
"auth.error.invalid.port" to "Invalid port (1–65535)",
"auth.submit.login" to "Sign In",
"auth.submit.register" to "Sign Up",
"auth.error.empty" to "Fill in all fields, mate",
"auth.error.failed" to "Login failed. Check ya credentials.",
"auth.error.register.failed" to "Registration failed. Reckon that user already exists.",
"auth.error.server" to "Server's cactus",
"auth.success.login" to "G'day! Logged in successfully",
"auth.success.register" to "Registered, bonza!",
"main.title" to "HumanBeing Collection",
"main.user" to "User",
"main.logout" to "Logout",
"main.locale" to "Language",
"main.filter" to "Filter",
"visualization.title" to "Visualisation",
"table.name" to "Name",
"table.coordinates.x" to "X",
"table.coordinates.y" to "Y",
"table.creationDate" to "Creation Date",
"table.realHero" to "Hero",
"table.hasToothpick" to "Toothpick",
"table.impactSpeed" to "Impact Speed",
"table.soundtrackName" to "Soundtrack",
"table.minutesOfWaiting" to "Minutes of Waitin'",
"table.weaponType" to "Weapon",
"table.car.cool" to "Carn't",
"table.owner" to "Owner",
"button.add" to "Add",
"button.edit" to "Edit",
"button.delete" to "Delete",
"button.refresh" to "Refresh",
"dialog.title.add" to "Add Object",
"dialog.title.edit" to "Edit Object",
"dialog.ok" to "OK",
"dialog.cancel" to "Cancel",
"dialog.error.validation" to "Validation Error",
"dialog.error.name" to "Name can't be empty",
"dialog.error.number" to "Invalid number, mate",
"dialog.error.no.user" to "Error: user not identified, mate",
"info.title" to "Object Info",
"info.owner" to "Owner",
"info.id" to "ID",
"delete.confirm" to "Reckon you wanna delete this?",
"delete.confirm.title" to "Confirm Deletion",
"error.no.selection" to "No object selected",
"error.not.owner" to "You're not the owner of this object",
),
)
fun get(key: String): String {
return strings[currentLocale.value]?.get(key) ?: key
}
fun bind(property: javafx.beans.property.Property<String>, key: String) {
property.value = get(key)
currentLocale.addListener { _, _, _ ->
property.value = get(key)
}
}
fun bindLabel(labeled: Labeled, key: String) {
bind(labeled.textProperty(), key)
}
fun bindPrompt(textField: javafx.scene.control.TextInputControl, key: String) {
bind(textField.promptTextProperty(), key)
}
fun availableLocales(): List<Locale> = strings.keys.toList()
fun localeDisplayName(locale: Locale): String = when (locale) {
Locale("ru") -> "Русский"
Locale("nl") -> "Nederlands"
Locale("hu") -> "Magyar"
Locale("en", "AU") -> "English (Australia)"
else -> locale.displayName
}
}
@@ -0,0 +1,44 @@
package model
import javafx.beans.property.*
import models.Car
import models.Coordinates
import models.HumanBeing
import models.WeaponType
import java.time.LocalDate
import java.util.UUID
class HumanBeingProperty(humanBeing: HumanBeing) {
val id = SimpleObjectProperty<UUID?>(humanBeing.id)
val ownerId = SimpleObjectProperty<UUID>(humanBeing.ownerId)
val name = SimpleStringProperty(humanBeing.name)
val coordinateX = SimpleObjectProperty<Long>(humanBeing.coordinates.x)
val coordinateY = SimpleObjectProperty<Int>(humanBeing.coordinates.y)
val creationDate = SimpleObjectProperty<LocalDate>(humanBeing.creationDate)
val realHero = SimpleBooleanProperty(humanBeing.realHero)
val hasToothpick = SimpleBooleanProperty(humanBeing.hasToothpick)
val impactSpeed = SimpleObjectProperty<Double>(humanBeing.impactSpeed)
val soundtrackName = SimpleStringProperty(humanBeing.soundtrackName)
val minutesOfWaiting = SimpleObjectProperty<Float>(humanBeing.minutesOfWaiting)
val weaponType = SimpleObjectProperty<WeaponType?>(humanBeing.weaponType)
val carCool = SimpleBooleanProperty(humanBeing.car.cool)
fun toHumanBeing(): HumanBeing = HumanBeing(
id = id.value,
ownerId = ownerId.value,
name = name.value,
coordinates = Coordinates(coordinateX.value, coordinateY.value),
creationDate = creationDate.value,
realHero = realHero.value,
hasToothpick = hasToothpick.value,
impactSpeed = impactSpeed.value,
soundtrackName = soundtrackName.value,
minutesOfWaiting = minutesOfWaiting.value,
weaponType = weaponType.value,
car = Car(carCool.value),
)
companion object {
fun fromHumanBeing(h: HumanBeing): HumanBeingProperty = HumanBeingProperty(h)
}
}
@@ -0,0 +1,28 @@
package model
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import guiclient.network.ClientProvider
import java.util.UUID
object Session {
var client: ClientProvider? = null
var token: String = ""
var userId: UUID? = null
var username: String = ""
val collection: ObservableList<HumanBeingProperty> = FXCollections.observableArrayList()
val userIdFilter: UUID?
get() = userId
fun isOwnObject(ownerId: UUID): Boolean = userId == ownerId
fun clear() {
token = ""
userId = null
username = ""
collection.clear()
}
}
@@ -0,0 +1,71 @@
package util
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import model.HumanBeingProperty
import java.util.Comparator
import java.util.stream.Collectors
import java.util.stream.Stream
object StreamUtils {
fun filter(
items: List<HumanBeingProperty>,
propertyExtractor: (HumanBeingProperty) -> String,
query: String,
): Stream<HumanBeingProperty> {
if (query.isBlank()) return items.stream()
val lower = query.trim().lowercase()
return items.stream()
.filter { propertyExtractor(it).lowercase().contains(lower) }
}
fun sort(
stream: Stream<HumanBeingProperty>,
comparator: Comparator<HumanBeingProperty>?,
): Stream<HumanBeingProperty> {
if (comparator == null) return stream
return stream.sorted(comparator)
}
fun applyFiltersAndSort(
items: List<HumanBeingProperty>,
filters: Map<String, String>,
comparators: List<Comparator<HumanBeingProperty>>,
): ObservableList<HumanBeingProperty> {
var stream = items.stream()
for ((key, query) in filters) {
if (query.isNotBlank()) {
val lower = query.trim().lowercase()
stream = stream.filter { prop ->
val fieldValue = when (key) {
"name" -> prop.name.value
"coordinates.x" -> prop.coordinateX.value.toString()
"coordinates.y" -> prop.coordinateY.value.toString()
"creationDate" -> prop.creationDate.value.toString()
"realHero" -> prop.realHero.value.toString()
"hasToothpick" -> prop.hasToothpick.value.toString()
"impactSpeed" -> prop.impactSpeed.value.toString()
"soundtrackName" -> prop.soundtrackName.value
"minutesOfWaiting" -> prop.minutesOfWaiting.value.toString()
"weaponType" -> prop.weaponType.value?.name ?: ""
"car.cool" -> prop.carCool.value.toString()
else -> ""
}
fieldValue.lowercase().contains(lower)
}
}
}
if (comparators.isNotEmpty()) {
val combined = comparators.reduce { a, b -> a.thenComparing(b) }
stream = stream.sorted(combined)
}
return stream.collect(Collectors.collectingAndThen(
Collectors.toList(),
{ FXCollections.observableArrayList(it) }
))
}
}
+142
View File
@@ -0,0 +1,142 @@
package view
import controller.AuthController
import javafx.geometry.Insets
import javafx.geometry.Pos
import javafx.scene.Scene
import javafx.scene.control.*
import javafx.scene.layout.HBox
import javafx.scene.layout.VBox
import javafx.stage.Stage
import localization.AppLocale
class AuthView(private val stage: Stage, private val onSuccess: (() -> Unit)? = null) {
private val controller = AuthController()
private val hostField = TextField("localhost")
private val portField = TextField("8080")
private val usernameField = TextField()
private val passwordField = PasswordField()
private val loginToggle = ToggleButton()
private val registerToggle = ToggleButton()
private val submitButton = Button()
private val errorLabel = Label()
private val localeCombo = ComboBox<LocaleDisplay>()
fun show() {
updateTitle()
stage.scene = Scene(buildUI(), 400.0, 440.0)
stage.isResizable = false
stage.show()
}
private fun buildUI(): VBox {
val toggleGroup = ToggleGroup()
loginToggle.toggleGroup = toggleGroup
registerToggle.toggleGroup = toggleGroup
loginToggle.isSelected = true
AppLocale.bindLabel(loginToggle, "auth.login")
AppLocale.bindLabel(registerToggle, "auth.register")
val toggleBox = HBox(10.0, loginToggle, registerToggle)
val usernameLabel = Label()
AppLocale.bindLabel(usernameLabel, "auth.username")
AppLocale.bindPrompt(usernameField, "auth.username")
val usernameBox = VBox(3.0, usernameLabel, usernameField)
val passwordLabel = Label()
AppLocale.bindLabel(passwordLabel, "auth.password")
AppLocale.bindPrompt(passwordField, "auth.password")
val passwordBox = VBox(3.0, passwordLabel, passwordField)
hostField.promptText = "localhost"
val hostBox = VBox(3.0, Label().also { AppLocale.bindLabel(it, "auth.host") }, hostField)
portField.promptText = "8080"
val portBox = VBox(3.0, Label().also { AppLocale.bindLabel(it, "auth.port") }, portField)
val serverBox = HBox(8.0, hostBox, portBox)
AppLocale.availableLocales().forEach { loc ->
localeCombo.items.add(LocaleDisplay(AppLocale.localeDisplayName(loc), loc))
}
localeCombo.selectionModel.selectFirst()
localeCombo.setOnAction {
val selected = localeCombo.selectionModel.selectedItem ?: return@setOnAction
AppLocale.currentLocale.set(selected.locale)
updateTitle()
}
val localeLabel = Label()
AppLocale.bindLabel(localeLabel, "main.locale")
val localeBox = HBox(10.0, localeLabel, localeCombo)
submitButton.setOnAction { onLogin() }
bindSubmitText()
toggleGroup.selectedToggleProperty().addListener { _, _, _ -> bindSubmitText() }
errorLabel.style = "-fx-text-fill: red;"
val form = VBox(12.0,
toggleBox,
usernameBox,
passwordBox,
serverBox,
localeBox,
submitButton,
errorLabel,
)
form.alignment = Pos.CENTER
form.padding = Insets(30.0)
return form
}
private fun bindSubmitText() {
val key = if (loginToggle.isSelected) "auth.submit.login" else "auth.submit.register"
AppLocale.bind(submitButton.textProperty(), key)
}
private fun updateTitle() {
stage.title = AppLocale.get("auth.title")
}
private fun onLogin() {
val host = hostField.text.trim()
val portText = portField.text.trim()
val username = usernameField.text.trim()
val password = passwordField.text.trim()
if (host.isEmpty() || portText.isEmpty() || username.isEmpty() || password.isEmpty()) {
errorLabel.text = AppLocale.get("auth.error.empty")
return
}
val port = portText.toIntOrNull()
if (port == null || port !in 1..65535) {
errorLabel.text = AppLocale.get("auth.error.invalid.port")
return
}
val isLogin = loginToggle.isSelected
submitButton.isDisable = true
errorLabel.text = ""
controller.authenticate(host, port, username, password, isLogin) { success, message ->
submitButton.isDisable = false
if (success) {
stage.close()
onSuccess?.invoke()
} else {
errorLabel.text = message
}
}
}
private class LocaleDisplay(val displayName: String, val locale: java.util.Locale) {
override fun toString() = displayName
}
}
@@ -0,0 +1,224 @@
package view
import javafx.animation.AnimationTimer
import javafx.scene.canvas.Canvas
import javafx.scene.canvas.GraphicsContext
import javafx.scene.control.Alert
import javafx.scene.control.ButtonType
import javafx.scene.input.MouseEvent
import javafx.scene.paint.Color
import javafx.scene.text.Font
import localization.AppLocale
import model.HumanBeingProperty
import model.Session
class CollectionCanvas : Canvas() {
private val gc: GraphicsContext = graphicsContext2D
private var animationTime = 0.0
private var canvasWidth: Double = 0.0
private var canvasHeight: Double = 0.0
private val padding = 40.0
private val defaultRange = 200.0
private var scaleX: Double = 1.0
private var scaleY: Double = 1.0
private var offsetX: Double = 0.0
private var offsetY: Double = 0.0
private var minRadius = 8.0
private var maxRadius = 35.0
private val renderTimer: AnimationTimer = object : AnimationTimer() {
override fun handle(now: Long) {
animationTime = now / 1_000_000_000.0
render()
}
}
init {
widthProperty().addListener { _, _, _ -> scheduleRender() }
heightProperty().addListener { _, _, _ -> scheduleRender() }
addEventHandler(MouseEvent.MOUSE_CLICKED, this::onClick)
renderTimer.start()
Session.collection.addListener { _: javafx.collections.ListChangeListener.Change<out HumanBeingProperty>? ->
scheduleRender()
}
}
private fun scheduleRender() {
render()
}
private fun render() {
canvasWidth = width
canvasHeight = height
if (canvasWidth <= 0.0 || canvasHeight <= 0.0) return
gc.clearRect(0.0, 0.0, canvasWidth, canvasHeight)
drawBackground()
val items = Session.collection.toList()
if (items.isEmpty()) {
drawEmptyMessage()
return
}
computeScale(items)
val glowIntensity = computeGlowIntensity()
for (item in items) {
drawItem(item, glowIntensity)
}
}
private fun drawBackground() {
gc.fill = Color.web("#1a1a2e")
gc.fillRect(0.0, 0.0, canvasWidth, canvasHeight)
gc.stroke = Color.web("#16213e")
gc.lineWidth = 0.5
val gridSize = 40.0
var x = 0.0
while (x <= canvasWidth) {
gc.strokeLine(x, 0.0, x, canvasHeight)
x += gridSize
}
var y = 0.0
while (y <= canvasHeight) {
gc.strokeLine(0.0, y, canvasWidth, y)
y += gridSize
}
}
private fun drawEmptyMessage() {
gc.fill = Color.GRAY
gc.font = Font(18.0)
gc.fillText(AppLocale.get("visualization.title"), canvasWidth / 2 - 60, canvasHeight / 2)
}
private fun computeScale(items: List<HumanBeingProperty>) {
val minX = items.minOf { it.coordinateX.value }
val maxX = items.maxOf { it.coordinateX.value }
val minY = items.minOf { it.coordinateY.value }
val maxY = items.maxOf { it.coordinateY.value }
val rangeX = if (maxX == minX) defaultRange else (maxX - minX).toDouble()
val rangeY = if (maxY == minY) defaultRange else (maxY - minY).toDouble()
val availWidth = canvasWidth - 2 * padding
val availHeight = canvasHeight - 2 * padding
scaleX = availWidth / rangeX
scaleY = availHeight / rangeY
offsetX = padding - minX * scaleX
offsetY = padding - minY * scaleY
}
private fun computeGlowIntensity(): Double {
return 0.4 + 0.4 * (1.0 + kotlin.math.sin(animationTime * 3.0)) / 2.0
}
private fun drawItem(item: HumanBeingProperty, glowIntensity: Double) {
val px = worldToScreenX(item.coordinateX.value.toDouble())
val py = worldToScreenY(item.coordinateY.value.toDouble())
val radius = mapImpactSpeed(item.impactSpeed.value)
val color = ownerColor(item.ownerId.value)
val glowColor = Color(color.red, color.green, color.blue, glowIntensity * 0.5)
gc.save()
gc.setFill(glowColor)
gc.fillOval(px - radius * 1.8, py - radius * 1.8, radius * 3.6, radius * 3.6)
gc.setFill(color)
gc.fillOval(px - radius, py - radius, radius * 2, radius * 2)
gc.setStroke(Color.WHITE)
gc.setLineWidth(1.5)
gc.strokeOval(px - radius, py - radius, radius * 2, radius * 2)
gc.setFill(Color.WHITE)
gc.setFont(Font(10.0))
val label = item.name.value.take(10)
gc.fillText(label, px - radius, py - radius - 4)
gc.restore()
}
private fun worldToScreenX(worldX: Double): Double = worldX * scaleX + offsetX
private fun worldToScreenY(worldY: Double): Double = worldY * scaleY + offsetY
private fun screenToWorldX(screenX: Double): Double = (screenX - offsetX) / scaleX
private fun screenToWorldY(screenY: Double): Double = (screenY - offsetY) / scaleY
private fun mapImpactSpeed(speed: Double): Double {
val items = Session.collection
if (items.isEmpty()) return minRadius
val minSpeed = items.minOf { it.impactSpeed.value }
val maxSpeed = items.maxOf { it.impactSpeed.value }
if (maxSpeed == minSpeed) return (minRadius + maxRadius) / 2
return minRadius + (speed - minSpeed) / (maxSpeed - minSpeed) * (maxRadius - minRadius)
}
private fun ownerColor(ownerId: java.util.UUID): Color {
val hue = (ownerId.hashCode() and 0x7FFFFFFF) % 360
return Color.hsb(hue.toDouble(), 0.7, 0.9)
}
private fun onClick(event: MouseEvent) {
val mx = event.x
val my = event.y
val items = Session.collection.toList()
val hitRadius = 15.0
for (item in items) {
val px = worldToScreenX(item.coordinateX.value.toDouble())
val py = worldToScreenY(item.coordinateY.value.toDouble())
val r = mapImpactSpeed(item.impactSpeed.value)
val dx = mx - px
val dy = my - py
if (dx * dx + dy * dy <= (r + hitRadius) * (r + hitRadius)) {
showItemInfo(item)
return
}
}
}
private fun showItemInfo(item: HumanBeingProperty) {
val info = buildString {
appendLine("${AppLocale.get("info.id")}: ${item.id.value}")
appendLine("${AppLocale.get("table.name")}: ${item.name.value}")
appendLine("X: ${item.coordinateX.value}, Y: ${item.coordinateY.value}")
appendLine("${AppLocale.get("table.creationDate")}: ${item.creationDate.value}")
appendLine("${AppLocale.get("table.realHero")}: ${item.realHero.value}")
appendLine("${AppLocale.get("table.hasToothpick")}: ${item.hasToothpick.value}")
appendLine("${AppLocale.get("table.impactSpeed")}: ${item.impactSpeed.value}")
appendLine("${AppLocale.get("table.soundtrackName")}: ${item.soundtrackName.value}")
appendLine("${AppLocale.get("table.minutesOfWaiting")}: ${item.minutesOfWaiting.value}")
appendLine("${AppLocale.get("table.weaponType")}: ${item.weaponType.value?.name ?: "—"}")
appendLine("${AppLocale.get("table.car.cool")}: ${item.carCool.value}")
appendLine("${AppLocale.get("info.owner")}: ${item.ownerId.value}")
}
val alert = Alert(Alert.AlertType.INFORMATION, info.trimEnd(), ButtonType.OK)
alert.title = AppLocale.get("info.title")
alert.headerText = item.name.value
alert.show()
}
override fun isResizable(): Boolean = true
override fun prefWidth(height: Double): Double = 400.0
override fun prefHeight(width: Double): Double = 400.0
}
@@ -0,0 +1,176 @@
package view
import javafx.geometry.Insets
import javafx.geometry.Pos
import javafx.scene.control.*
import javafx.scene.layout.GridPane
import javafx.scene.layout.VBox
import javafx.stage.Modality
import javafx.stage.Stage
import javafx.stage.Window
import localization.AppLocale
import models.Car
import models.Coordinates
import models.HumanBeing
import models.WeaponType
import model.Session
import java.time.LocalDate
class EditDialog(private val owner: Window, private val existing: HumanBeing? = null) {
private val dialog = Stage()
private val nameField = TextField()
private val coordXField = TextField()
private val coordYField = TextField()
private val realHeroCheck = CheckBox()
private val hasToothpickCheck = CheckBox()
private val impactSpeedField = TextField()
private val soundtrackField = TextField()
private val minutesField = TextField()
private val weaponTypeCombo = ComboBox<WeaponTypeOption>()
private val carCoolCheck = CheckBox()
private var result: HumanBeing? = null
fun showAndWait(): HumanBeing? {
dialog.initModality(Modality.WINDOW_MODAL)
dialog.initOwner(owner)
dialog.scene = javafx.scene.Scene(buildUI(), 420.0, 520.0)
dialog.isResizable = false
if (existing != null) populateFields(existing)
updateTitle()
dialog.showAndWait()
return result
}
private fun buildUI(): VBox {
val form = GridPane()
form.hgap = 8.0
form.vgap = 8.0
form.padding = Insets(16.0)
form.alignment = Pos.CENTER_LEFT
var row = 0
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.name") }, nameField)
row++
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.coordinates.x") }, coordXField)
row++
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.coordinates.y") }, coordYField)
row++
realHeroCheck.isSelected = true
form.addRow(row, realHeroCheck.also { AppLocale.bindLabel(it, "table.realHero") })
row++
hasToothpickCheck.isSelected = true
form.addRow(row, hasToothpickCheck.also { AppLocale.bindLabel(it, "table.hasToothpick") })
row++
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.impactSpeed") }, impactSpeedField)
row++
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.soundtrackName") }, soundtrackField)
row++
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.minutesOfWaiting") }, minutesField)
row++
weaponTypeCombo.items.add(WeaponTypeOption("—", null))
WeaponType.entries.forEach { wt ->
weaponTypeCombo.items.add(WeaponTypeOption(wt.name, wt))
}
weaponTypeCombo.selectionModel.selectFirst()
form.addRow(row, Label().also { AppLocale.bindLabel(it, "table.weaponType") }, weaponTypeCombo)
row++
carCoolCheck.isSelected = true
form.addRow(row, carCoolCheck.also { AppLocale.bindLabel(it, "table.car.cool") })
val buttonBar = javafx.scene.layout.HBox(8.0).apply {
alignment = Pos.CENTER_RIGHT
padding = Insets(0.0, 16.0, 16.0, 16.0)
children.addAll(
Button().also {
AppLocale.bindLabel(it, "dialog.ok")
it.setOnAction { onOk() }
},
Button().also {
AppLocale.bindLabel(it, "dialog.cancel")
it.setOnAction { dialog.close() }
},
)
}
return VBox(8.0, form, buttonBar)
}
private fun updateTitle() {
val key = if (existing != null) "dialog.title.edit" else "dialog.title.add"
dialog.title = AppLocale.get(key)
}
private fun populateFields(h: HumanBeing) {
nameField.text = h.name
coordXField.text = h.coordinates.x.toString()
coordYField.text = h.coordinates.y.toString()
realHeroCheck.isSelected = h.realHero
hasToothpickCheck.isSelected = h.hasToothpick
impactSpeedField.text = h.impactSpeed.toString()
soundtrackField.text = h.soundtrackName
minutesField.text = h.minutesOfWaiting.toString()
if (h.weaponType != null) {
val selected = weaponTypeCombo.items.find { it.type == h.weaponType }
if (selected != null) weaponTypeCombo.selectionModel.select(selected)
}
carCoolCheck.isSelected = h.car.cool
}
private fun onOk() {
val name = nameField.text.trim()
if (name.isEmpty()) {
showError(AppLocale.get("dialog.error.name"))
return
}
val coordX = coordXField.text.trim().toLongOrNull()
if (coordX == null) { showError("X: " + AppLocale.get("dialog.error.number")); return }
val coordY = coordYField.text.trim().toIntOrNull()
if (coordY == null) { showError("Y: " + AppLocale.get("dialog.error.number")); return }
val impactSpeed = impactSpeedField.text.trim().toDoubleOrNull()
if (impactSpeed == null) { showError("impactSpeed: " + AppLocale.get("dialog.error.number")); return }
val minutes = minutesField.text.trim().toFloatOrNull()
if (minutes == null) { showError("minutesOfWaiting: " + AppLocale.get("dialog.error.number")); return }
val soundtrack = soundtrackField.text.trim()
result = HumanBeing(
id = existing?.id,
ownerId = existing?.ownerId ?: Session.userId ?: run { showError(AppLocale.get("dialog.error.no.user")); return },
name = name,
coordinates = Coordinates(coordX, coordY),
creationDate = existing?.creationDate ?: LocalDate.now(),
realHero = realHeroCheck.isSelected,
hasToothpick = hasToothpickCheck.isSelected,
impactSpeed = impactSpeed,
soundtrackName = soundtrack,
minutesOfWaiting = minutes,
weaponType = weaponTypeCombo.selectionModel.selectedItem?.type,
car = Car(carCoolCheck.isSelected),
)
dialog.close()
}
private fun showError(message: String) {
val alert = Alert(Alert.AlertType.ERROR, message, ButtonType.OK)
alert.initOwner(dialog)
alert.show()
}
private data class WeaponTypeOption(val label: String, val type: WeaponType?) {
override fun toString() = label
}
}
+316
View File
@@ -0,0 +1,316 @@
package view
import controller.MainController
import javafx.beans.property.SimpleObjectProperty
import javafx.geometry.Insets
import javafx.geometry.Orientation
import javafx.geometry.Pos
import javafx.scene.Node
import javafx.scene.Scene
import view.CollectionCanvas
import javafx.scene.control.*
import javafx.scene.layout.*
import javafx.stage.Stage
import models.HumanBeing
import localization.AppLocale
import model.HumanBeingProperty
import model.Session
import java.util.Comparator
class MainView(private val stage: Stage) {
private val controller = MainController()
private val tableView = TableView<HumanBeingProperty>()
private val canvas = CollectionCanvas()
private val canvasPlaceholder = Label()
private val usernameLabel = Label()
private val localeCombo = ComboBox<LocaleDisplay>()
private val addButton = Button()
private val editButton = Button()
private val deleteButton = Button()
private val refreshButton = Button()
private val filterFields = mutableMapOf<String, TextField>()
private var sortColumn: String? = null
private var sortAscending = true
fun show() {
updateTitle()
val scene = Scene(buildUI(), 1100.0, 700.0)
stage.scene = scene
stage.minWidth = 800.0
stage.minHeight = 500.0
stage.show()
controller.start()
}
private fun buildUI(): BorderPane {
val root = BorderPane()
root.top = buildToolbar()
root.center = buildContent()
root.bottom = buildCommandBar()
return root
}
private fun buildToolbar(): ToolBar {
AppLocale.bindLabel(usernameLabel, "main.user")
updateUsernameText()
val logoutItem = MenuItem()
AppLocale.bind(logoutItem.textProperty(), "main.logout")
logoutItem.setOnAction { onLogout() }
val userMenu = MenuButton().also { menu ->
AppLocale.bindLabel(menu, "main.user")
menu.items.add(logoutItem)
}
AppLocale.availableLocales().forEach { loc ->
localeCombo.items.add(LocaleDisplay(AppLocale.localeDisplayName(loc), loc))
}
val currentLocaleIndex = AppLocale.availableLocales().indexOf(AppLocale.currentLocale.value)
if (currentLocaleIndex >= 0) localeCombo.selectionModel.select(currentLocaleIndex)
localeCombo.setOnAction {
val selected = localeCombo.selectionModel.selectedItem ?: return@setOnAction
AppLocale.currentLocale.set(selected.locale)
updateLocale()
}
val spacer = Region()
HBox.setHgrow(spacer, Priority.ALWAYS)
val toolbar = ToolBar(
Label().also { AppLocale.bindLabel(it, "main.user"); it.visibleProperty().bind(usernameLabel.visibleProperty()) },
usernameLabel,
Separator(Orientation.VERTICAL),
Label().also { AppLocale.bindLabel(it, "main.locale") },
localeCombo,
spacer,
userMenu,
)
return toolbar
}
private fun buildContent(): Node {
val splitPane = SplitPane()
splitPane.orientation = Orientation.HORIZONTAL
splitPane.items.addAll(buildTablePane(), buildCanvasPane())
splitPane.setDividerPositions(0.55)
return splitPane
}
private fun buildTablePane(): Node {
val filterBar = GridPane()
filterBar.hgap = 4.0
filterBar.vgap = 2.0
filterBar.padding = Insets(4.0)
val columns = listOf(
"name" to { p: HumanBeingProperty -> p.name.value },
"coordinates.x" to { p: HumanBeingProperty -> p.coordinateX.value.toString() },
"coordinates.y" to { p: HumanBeingProperty -> p.coordinateY.value.toString() },
"creationDate" to { p: HumanBeingProperty -> p.creationDate.value.toString() },
"realHero" to { p: HumanBeingProperty -> p.realHero.value.toString() },
"hasToothpick" to { p: HumanBeingProperty -> p.hasToothpick.value.toString() },
"impactSpeed" to { p: HumanBeingProperty -> p.impactSpeed.value.toString() },
"soundtrackName" to { p: HumanBeingProperty -> p.soundtrackName.value },
"minutesOfWaiting" to { p: HumanBeingProperty -> p.minutesOfWaiting.value.toString() },
"weaponType" to { p: HumanBeingProperty -> (p.weaponType.value?.name ?: "") },
"car.cool" to { p: HumanBeingProperty -> p.carCool.value.toString() },
)
for ((i, col) in columns.withIndex()) {
val (key, extractor) = col
val headerLabel = Label()
AppLocale.bindLabel(headerLabel, "table.$key")
headerLabel.setOnMouseClicked {
toggleSort(key)
}
headerLabel.style = "-fx-font-weight: bold; -fx-cursor: hand;"
filterBar.add(headerLabel, i, 0)
val filterField = TextField()
filterField.promptText = AppLocale.get("main.filter")
filterField.textProperty().addListener { _, _, _ ->
applyFilters()
}
filterFields[key] = filterField
filterBar.add(filterField, i, 1)
val tableCol = TableColumn<HumanBeingProperty, String>()
AppLocale.bind(tableCol.textProperty(), "table.$key")
tableCol.cellValueFactory = javafx.util.Callback { features ->
SimpleObjectProperty(extractor(features.value))
}
tableView.columns.add(tableCol)
}
val tableHolder = VBox(filterBar, tableView)
VBox.setVgrow(tableView, Priority.ALWAYS)
return tableHolder
}
private fun buildCanvasPane(): Node {
AppLocale.bindLabel(canvasPlaceholder, "visualization.title")
canvasPlaceholder.alignment = Pos.CENTER
canvasPlaceholder.style = "-fx-font-size: 16; -fx-text-fill: gray;"
val stackPane = StackPane(canvasPlaceholder, canvas)
stackPane.prefWidth = 400.0
canvas.widthProperty().bind(stackPane.widthProperty())
canvas.heightProperty().bind(stackPane.heightProperty())
return stackPane
}
private fun buildCommandBar(): HBox {
AppLocale.bindLabel(addButton, "button.add")
AppLocale.bindLabel(editButton, "button.edit")
AppLocale.bindLabel(deleteButton, "button.delete")
AppLocale.bindLabel(refreshButton, "button.refresh")
addButton.setOnAction { onAdd() }
editButton.setOnAction { onEdit() }
deleteButton.setOnAction { onDelete() }
refreshButton.setOnAction { controller.requestRefresh() }
val bar = HBox(8.0, addButton, editButton, deleteButton, refreshButton)
bar.padding = Insets(8.0)
bar.alignment = Pos.CENTER_LEFT
return bar
}
private fun toggleSort(key: String) {
if (sortColumn == key) {
sortAscending = !sortAscending
} else {
sortColumn = key
sortAscending = true
}
val comparator: Comparator<HumanBeingProperty>? = sortColumn?.let { col ->
val asc = sortAscending
val comp = Comparator<HumanBeingProperty> { a, b ->
val va = extractSortValue(a, col)
val vb = extractSortValue(b, col)
if (asc) va.compareTo(vb) else vb.compareTo(va)
}
comp
}
controller.updateComparators(if (comparator != null) listOf(comparator) else emptyList())
}
private fun extractSortValue(prop: HumanBeingProperty, key: String): String = when (key) {
"name" -> prop.name.value
"coordinates.x" -> prop.coordinateX.value.toString().padStart(20, '0')
"coordinates.y" -> prop.coordinateY.value.toString().padStart(10, '0')
"creationDate" -> prop.creationDate.value.toString()
"realHero" -> if (prop.realHero.value) "1" else "0"
"hasToothpick" -> if (prop.hasToothpick.value) "1" else "0"
"impactSpeed" -> prop.impactSpeed.value.toString().padStart(20, '0')
"soundtrackName" -> prop.soundtrackName.value
"minutesOfWaiting" -> prop.minutesOfWaiting.value.toString().padStart(20, '0')
"weaponType" -> prop.weaponType.value?.name ?: ""
"car.cool" -> if (prop.carCool.value) "1" else "0"
else -> ""
}
private fun applyFilters() {
val filters = filterFields.mapValues { it.value.text }
controller.updateFilters(filters)
}
init {
tableView.items = controller.filteredList
}
private fun updateUsernameText() {
usernameLabel.text = Session.username.ifEmpty { "—" }
}
private fun updateLocale() {
updateTitle()
updateUsernameText()
filterFields.forEach { (_, field) ->
field.promptText = AppLocale.get("main.filter")
}
}
private fun updateTitle() {
stage.title = AppLocale.get("main.title")
}
private fun onLogout() {
controller.stop()
Session.client?.clearToken()
Session.clear()
stage.close()
AuthView(Stage()).show()
}
private fun onAdd() {
val dialog = EditDialog(stage.scene.window)
val humanBeing = dialog.showAndWait() ?: return
controller.executeCommand(name = "add", humanBeing = humanBeing) { success, message ->
if (success) controller.requestRefresh() else showError(message)
}
}
private fun onEdit() {
val selected = tableView.selectionModel.selectedItem
if (selected == null) {
showError(AppLocale.get("error.no.selection"))
return
}
if (!Session.isOwnObject(selected.ownerId.value)) {
showError(AppLocale.get("error.not.owner"))
return
}
val dialog = EditDialog(stage.scene.window, selected.toHumanBeing())
val humanBeing = dialog.showAndWait() ?: return
val id = selected.id.value ?: return
controller.executeCommand(name = "update", args = listOf(id.toString()), humanBeing = humanBeing) { success, message ->
if (success) controller.requestRefresh() else showError(message)
}
}
private fun onDelete() {
val selected = tableView.selectionModel.selectedItem ?: return
if (!Session.isOwnObject(selected.ownerId.value)) {
showError(AppLocale.get("error.not.owner"))
return
}
val confirm = Alert(Alert.AlertType.CONFIRMATION)
confirm.title = AppLocale.get("delete.confirm.title")
confirm.headerText = AppLocale.get("delete.confirm")
confirm.showAndWait().ifPresent { result ->
if (result == ButtonType.OK) {
controller.deleteObject(selected) { success, message ->
if (success) controller.requestRefresh() else showError(message)
}
}
}
}
private fun showError(message: String) {
val alert = Alert(Alert.AlertType.ERROR, message, ButtonType.OK)
alert.show()
}
private class LocaleDisplay(val displayName: String, val locale: java.util.Locale) {
override fun toString() = displayName
}
}