feat: initialize GaCy Android project foundation (Kotlin, Jetpack Compose, Material 3)

This commit is contained in:
CAtlas0784 committed 2026-10-31 17:36:42 +07:00
commit 3eab1e7e49
61 files changed
+5821

No files matched your search

@@ -0,0 +1,37 @@
package com.gacy.android
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
class GaCyApplication : Application() {
companion object {
const val CHANNEL_ID = "gacy_server_channel"
const val CHANNEL_NAME = "GaCy Server Service"
lateinit var instance: GaCyApplication
private set
}
override fun onCreate() {
super.onCreate()
instance = this
createNotificationChannel()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Background service for GaCy Game Server & Proxy"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
}
}
@@ -0,0 +1,44 @@
package com.gacy.android
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.gacy.android.ui.screens.MainScreen
import com.gacy.android.ui.theme.GaCyTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestAppPermissions()
setContent {
GaCyTheme {
MainScreen()
}
}
}
private fun requestAppPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
101
)
}
}
}
}
@@ -0,0 +1,87 @@
package com.gacy.android.data
import com.gacy.android.model.GmCommandResult
import com.gacy.android.model.ServerConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
class GaCyApiRepository {
private val client = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
suspend fun checkServerHealth(config: ServerConfig): Pair<Boolean, Long> = withContext(Dispatchers.IO) {
val start = System.currentTimeMillis()
val url = "${config.dispatchUrl}/status"
try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
val duration = System.currentTimeMillis() - start
Pair(response.isSuccessful, duration)
}
} catch (e: Exception) {
// Try query_gateway fallback
try {
val gwUrl = "${config.dispatchUrl}/query_gateway"
val request = Request.Builder().url(gwUrl).get().build()
client.newCall(request).execute().use { response ->
val duration = System.currentTimeMillis() - start
Pair(response.isSuccessful || response.code == 400 || response.code == 200, duration)
}
} catch (ex: Exception) {
Pair(false, -1L)
}
}
}
suspend fun sendUnstuck(config: ServerConfig): GmCommandResult = withContext(Dispatchers.IO) {
val url = "${config.dispatchUrl}/api/client/unstuck"
try {
val body = "{}".toRequestBody(jsonMediaType)
val request = Request.Builder().url(url).post(body).build()
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
GmCommandResult(true, "Unstuck packet sent to game client successfully.")
} else {
GmCommandResult(false, "Unstuck request failed with HTTP ${response.code}")
}
}
} catch (e: IOException) {
GmCommandResult(false, "Network error: ${e.message}")
}
}
suspend fun executeGmCommand(config: ServerConfig, command: String): GmCommandResult = withContext(Dispatchers.IO) {
val cleanCmd = command.trim()
val url = "${config.dispatchUrl}/api/gm/command"
try {
val payload = JSONObject().apply {
put("command", cleanCmd)
}
val body = payload.toString().toRequestBody(jsonMediaType)
val request = Request.Builder().url(url).post(body).build()
client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: ""
if (response.isSuccessful) {
GmCommandResult(true, responseBody.ifEmpty { "Command executed successfully: $cleanCmd" })
} else {
GmCommandResult(false, "Server rejected command: HTTP ${response.code} ($responseBody)")
}
}
} catch (e: Exception) {
GmCommandResult(false, "Failed to connect to GaCy Server: ${e.message}")
}
}
}
@@ -0,0 +1,299 @@
package com.gacy.android.data
import android.content.Context
import com.gacy.android.model.ExRankConfig
import com.gacy.android.model.GlobalPassiveConfig
import com.gacy.android.model.SceneConfig
import com.gacy.android.model.TrailblazerConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileNotFoundException
import java.nio.charset.StandardCharsets
class PersistentRepository(private val context: Context) {
val serverDataDir: File
get() = File(context.filesDir, "gacy_server")
val miscDir: File
get() = File(serverDataDir, "misc")
val persistentFile: File
get() = File(miscDir, "persistent.json")
init {
ensureDataStructure()
}
fun isServerInstalled(): Boolean {
return File(serverDataDir, "gameserver").exists() || File(serverDataDir, "ppsr.pyc").exists()
}
private fun ensureDataStructure() {
if (!miscDir.exists()) {
miscDir.mkdirs()
}
if (!persistentFile.exists()) {
try {
persistentFile.writeText(DEFAULT_PERSISTENT_JSON_STRING, StandardCharsets.UTF_8)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
suspend fun readPersistentJson(): JSONObject = withContext(Dispatchers.IO) {
if (!persistentFile.exists()) {
ensureDataStructure()
}
try {
val text = persistentFile.readText(StandardCharsets.UTF_8)
JSONObject(text)
} catch (e: Exception) {
JSONObject(DEFAULT_PERSISTENT_JSON_STRING)
}
}
suspend fun savePersistentJson(json: JSONObject): Boolean = withContext(Dispatchers.IO) {
try {
val tempFile = File(miscDir, "persistent.json.tmp")
tempFile.writeText(json.toString(2), StandardCharsets.UTF_8)
if (tempFile.exists()) {
if (persistentFile.exists()) {
persistentFile.delete()
}
tempFile.renameTo(persistentFile)
true
} else {
false
}
} catch (e: Exception) {
e.printStackTrace()
false
}
}
suspend fun updateScene(sceneConfig: SceneConfig): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
root.put("scene", sceneConfig.toJsonObject())
savePersistentJson(root)
}
suspend fun getSceneConfig(): SceneConfig = withContext(Dispatchers.IO) {
val root = readPersistentJson()
val sceneObj = root.optJSONObject("scene")
if (sceneObj != null) {
SceneConfig.fromJsonObject(sceneObj)
} else {
SceneConfig()
}
}
suspend fun getTrailblazerConfig(): TrailblazerConfig = withContext(Dispatchers.IO) {
val root = readPersistentJson()
val avatar = root.optJSONObject("avatar") ?: JSONObject()
TrailblazerConfig(
gender = avatar.optString("tb_gender", "Female"),
tbPath = avatar.optString("tb_path", "Elation"),
marchPath = avatar.optString("march_path", "Preservation")
)
}
suspend fun saveTrailblazerConfig(config: TrailblazerConfig): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
var avatar = root.optJSONObject("avatar")
if (avatar == null) {
avatar = JSONObject()
root.put("avatar", avatar)
}
avatar.put("tb_gender", config.gender)
avatar.put("tb_path", config.tbPath)
avatar.put("march_path", config.marchPath)
savePersistentJson(root)
}
suspend fun getExRankConfigs(): List<ExRankConfig> = withContext(Dispatchers.IO) {
val root = readPersistentJson()
val avatar = root.optJSONObject("avatar")
val exRankArray = avatar?.optJSONArray("ex_rank")
val currentMap = mutableMapOf<Int, Boolean>()
if (exRankArray != null) {
for (i in 0 until exRankArray.length()) {
val item = exRankArray.optJSONObject(i) ?: continue
val id = item.optInt("avatar_id", 0)
val active = item.optBoolean("active", false)
if (id > 0) {
currentMap[id] = active
}
}
}
DEFAULT_EX_RANK_CONFIGS.map { defaultCfg ->
defaultCfg.copy(active = currentMap[defaultCfg.avatarId] ?: defaultCfg.active)
}
}
suspend fun saveExRankConfigs(configs: List<ExRankConfig>): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
var avatar = root.optJSONObject("avatar")
if (avatar == null) {
avatar = JSONObject()
root.put("avatar", avatar)
}
val array = JSONArray()
for (cfg in configs) {
val item = JSONObject().apply {
put("name", cfg.name)
put("avatar_id", cfg.avatarId)
put("active", cfg.active)
}
array.put(item)
}
avatar.put("ex_rank", array)
savePersistentJson(root)
}
suspend fun getGlobalPassives(): List<GlobalPassiveConfig> = withContext(Dispatchers.IO) {
val root = readPersistentJson()
val avatar = root.optJSONObject("avatar")
val passiveArray = avatar?.optJSONArray("global_passive")
val currentMap = mutableMapOf<Int, Boolean>()
if (passiveArray != null) {
for (i in 0 until passiveArray.length()) {
val item = passiveArray.optJSONObject(i) ?: continue
val id = item.optInt("id", 0)
val active = item.optBoolean("active", false)
if (id > 0) {
currentMap[id] = active
}
}
}
DEFAULT_GLOBAL_PASSIVES.map { defaultCfg ->
defaultCfg.copy(active = currentMap[defaultCfg.id] ?: defaultCfg.active)
}
}
suspend fun saveGlobalPassives(configs: List<GlobalPassiveConfig>): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
var avatar = root.optJSONObject("avatar")
if (avatar == null) {
avatar = JSONObject()
root.put("avatar", avatar)
}
val array = JSONArray()
for (cfg in configs) {
val item = JSONObject().apply {
put("name", cfg.name)
put("id", cfg.id)
put("active", cfg.active)
}
array.put(item)
}
avatar.put("global_passive", array)
savePersistentJson(root)
}
suspend fun getSendHotfix(): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
root.optBoolean("send_hotfix", true)
}
suspend fun setSendHotfix(value: Boolean): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
root.put("send_hotfix", value)
savePersistentJson(root)
}
suspend fun getServerKey(): String = withContext(Dispatchers.IO) {
val root = readPersistentJson()
root.optString("key", "")
}
suspend fun setServerKey(key: String): Boolean = withContext(Dispatchers.IO) {
val root = readPersistentJson()
root.put("key", key)
savePersistentJson(root)
}
companion object {
val DEFAULT_EX_RANK_CONFIGS = listOf(
ExRankConfig("JingYuan", 1204, true),
ExRankConfig("DanHeng IL", 1213, true),
ExRankConfig("Acheron", 1308, true)
)
val DEFAULT_GLOBAL_PASSIVES = listOf(
GlobalPassiveConfig("Castorice Global Passive", 140703, true),
GlobalPassiveConfig("SW999 Global Passive", 150602, true),
GlobalPassiveConfig("Aha Global Passive", 151104, true)
)
val DEFAULT_PERSISTENT_JSON_STRING = """
{
"send_hotfix": true,
"key": "",
"scene": {
"plane_id": 20461,
"x": 12080,
"y": 393572,
"z": 322230,
"calyx_x": 12080,
"calyx_y": 393572,
"calyx_z": 324030,
"map_layer": 1,
"calyx_group_id": 186,
"calyx_inst_id": 300001,
"calyx_entity_id": 1337,
"calyx_prop_id": 808
},
"avatar": {
"tb_gender": "Female",
"tb_path": "Elation",
"march_path": "Preservation",
"ex_rank": [
{
"name": "JingYuan",
"avatar_id": 1204,
"active": true
},
{
"name": "DanHeng IL",
"avatar_id": 1213,
"active": true
},
{
"name": "Acheron",
"avatar_id": 1308,
"active": true
}
],
"global_passive": [
{
"name": "Castorice Global Passive",
"id": 140703,
"active": true
},
{
"name": "SW999 Global Passive",
"id": 150602,
"active": true
},
{
"name": "Aha Global Passive",
"id": 151104,
"active": true
}
]
}
}
""".trimIndent()
}
}
@@ -0,0 +1,60 @@
package com.gacy.android.data
import android.content.Context
import android.content.SharedPreferences
import com.gacy.android.model.AppLanguage
import com.gacy.android.model.ServerConfig
import com.gacy.android.model.ServerExecutionMode
class PreferencesRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences("gacy_preferences", Context.MODE_PRIVATE)
var appLanguage: AppLanguage
get() {
val code = prefs.getString(KEY_APP_LANGUAGE, AppLanguage.TH.code) ?: AppLanguage.TH.code
return AppLanguage.entries.find { it.code == code } ?: AppLanguage.TH
}
set(value) {
prefs.edit().putString(KEY_APP_LANGUAGE, value.code).apply()
}
fun getServerConfig(): ServerConfig {
val modeStr = prefs.getString(KEY_EXECUTION_MODE, ServerExecutionMode.LOCAL_EMBEDDED.name)
val mode = try {
ServerExecutionMode.valueOf(modeStr ?: ServerExecutionMode.LOCAL_EMBEDDED.name)
} catch (e: Exception) {
ServerExecutionMode.LOCAL_EMBEDDED
}
return ServerConfig(
executionMode = mode,
remoteHost = prefs.getString(KEY_REMOTE_HOST, "127.0.0.1") ?: "127.0.0.1",
dispatchPort = prefs.getInt(KEY_DISPATCH_PORT, 21000),
gameserverPort = prefs.getInt(KEY_GAMESERVER_PORT, 23301),
serverKey = prefs.getString(KEY_SERVER_KEY, "") ?: "",
sendHotfix = prefs.getBoolean(KEY_SEND_HOTFIX, true)
)
}
fun saveServerConfig(config: ServerConfig) {
prefs.edit()
.putString(KEY_EXECUTION_MODE, config.executionMode.name)
.putString(KEY_REMOTE_HOST, config.remoteHost)
.putInt(KEY_DISPATCH_PORT, config.dispatchPort)
.putInt(KEY_GAMESERVER_PORT, config.gameserverPort)
.putString(KEY_SERVER_KEY, config.serverKey)
.putBoolean(KEY_SEND_HOTFIX, config.sendHotfix)
.apply()
}
companion object {
private const val KEY_APP_LANGUAGE = "app_language"
private const val KEY_EXECUTION_MODE = "server_execution_mode"
private const val KEY_REMOTE_HOST = "remote_host"
private const val KEY_DISPATCH_PORT = "dispatch_port"
private const val KEY_GAMESERVER_PORT = "gameserver_port"
private const val KEY_SERVER_KEY = "server_key"
private const val KEY_SEND_HOTFIX = "send_hotfix"
}
}
@@ -0,0 +1,155 @@
package com.gacy.android.data
import android.content.Context
import com.gacy.android.model.PlanetItem
import com.gacy.android.model.SceneItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
object SceneRepository {
private const val CACHED_SCENES_FILENAME = "scenes_custom.json"
suspend fun loadScenes(context: Context): List<PlanetItem> = withContext(Dispatchers.IO) {
val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME)
val defaultJson = context.assets.open("scenes.json").bufferedReader().use { it.readText() }
val jsonContent = if (cachedFile.exists()) {
try {
val cachedText = cachedFile.readText()
val cachedObj = JSONObject(cachedText)
val defaultObj = JSONObject(defaultJson)
val defaultBeta = defaultObj.optInt("total_beta_scenes", 0)
val cachedBeta = cachedObj.optInt("total_beta_scenes", 0)
val defaultVer = defaultObj.optString("version", "")
val cachedVer = cachedObj.optString("version", "")
if (defaultBeta > cachedBeta || (defaultVer.isNotBlank() && defaultVer != cachedVer)) {
cachedFile.delete()
defaultJson
} else {
cachedText
}
} catch (e: Exception) {
cachedFile.delete()
defaultJson
}
} else {
defaultJson
}
parseJson(jsonContent)
}
suspend fun saveUpdatedScenesJson(context: Context, jsonString: String): Boolean = withContext(Dispatchers.IO) {
try {
JSONObject(jsonString)
val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME)
cachedFile.writeText(jsonString)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
suspend fun resetToDefaultScenes(context: Context): List<PlanetItem> = withContext(Dispatchers.IO) {
val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME)
if (cachedFile.exists()) {
cachedFile.delete()
}
val defaultJson = context.assets.open("scenes.json").bufferedReader().use { it.readText() }
parseJson(defaultJson)
}
private fun parseJson(jsonString: String): List<PlanetItem> {
val planets = mutableListOf<PlanetItem>()
val root = JSONObject(jsonString)
val planetsArray = root.optJSONArray("planets") ?: return emptyList()
for (i in 0 until planetsArray.length()) {
val pObj = planetsArray.getJSONObject(i)
val pId = pObj.optString("id", "planet_$i")
val pNameEn = pObj.optString("name", "Unknown Planet")
val pNameTh = pObj.optString("name_th", pNameEn)
val pNameZh = pObj.optString("name_zh", pNameEn)
val scenesArray = pObj.optJSONArray("scenes")
val scenes = mutableListOf<SceneItem>()
if (scenesArray != null) {
for (j in 0 until scenesArray.length()) {
val sObj = scenesArray.getJSONObject(j)
val sNameEn = sObj.optString("name", "Scene $j")
val sNameTh = sObj.optString("name_th", sNameEn)
val sNameZh = sObj.optString("name_zh", sNameEn)
val planeId = sObj.optLong("plane_id", 0)
val x = sObj.optLong("x", 0)
val y = sObj.optLong("y", 0)
val z = sObj.optLong("z", 0)
val calyxX = if (sObj.has("calyx_x")) sObj.getLong("calyx_x") else null
val calyxY = if (sObj.has("calyx_y")) sObj.getLong("calyx_y") else null
val calyxZ = if (sObj.has("calyx_z")) sObj.getLong("calyx_z") else null
val mapLayer = sObj.optInt("map_layer", 1)
val calyxGroupId = sObj.optLong("calyx_group_id", 186)
val calyxInstId = sObj.optLong("calyx_inst_id", 300001)
val calyxEntityId = sObj.optLong("calyx_entity_id", 1337)
val calyxPropId = sObj.optLong("calyx_prop_id", 808)
val combatReady = sObj.optBoolean("calyx_supported", false)
val isBeta = sObj.optBoolean("is_beta", false) ||
sObj.optString("channel", "").equals("beta", ignoreCase = true) ||
sObj.optString("client_version", "").contains("Beta", ignoreCase = true) ||
planeId == 20571L || planeId == 20572L
val descEn = sObj.optString("desc", "")
val descTh = sObj.optString("desc_th", descEn)
val descZh = sObj.optString("desc_zh", descEn)
scenes.add(
SceneItem(
id = "${pId}_${planeId}_$j",
nameEn = sNameEn,
nameTh = sNameTh,
nameZh = sNameZh,
planeId = planeId,
x = x,
y = y,
z = z,
calyxX = calyxX,
calyxY = calyxY,
calyxZ = calyxZ,
mapLayer = mapLayer,
calyxGroupId = calyxGroupId,
calyxInstId = calyxInstId,
calyxEntityId = calyxEntityId,
calyxPropId = calyxPropId,
combatReady = combatReady,
isBeta = isBeta,
descEn = descEn,
descTh = descTh,
descZh = descZh,
planetNameEn = pNameEn,
planetNameTh = pNameTh,
planetNameZh = pNameZh
)
)
}
}
planets.add(
PlanetItem(
id = pId,
nameEn = pNameEn,
nameTh = pNameTh,
nameZh = pNameZh,
scenes = scenes
)
)
}
return planets
}
}
@@ -0,0 +1,92 @@
package com.gacy.android.model
import org.json.JSONObject
data class SceneConfig(
var planeId: Long = 20461,
var x: Long = 12080,
var y: Long = 393572,
var z: Long = 322230,
var calyxX: Long = 12080,
var calyxY: Long = 393572,
var calyxZ: Long = 324030,
var mapLayer: Int = 1,
var calyxGroupId: Long = 186,
var calyxInstId: Long = 300001,
var calyxEntityId: Long = 1337,
var calyxPropId: Long = 808
) {
fun toJsonObject(): JSONObject {
return JSONObject().apply {
put("plane_id", planeId)
put("x", x)
put("y", y)
put("z", z)
put("calyx_x", calyxX)
put("calyx_y", calyxY)
put("calyx_z", calyxZ)
put("map_layer", mapLayer)
put("calyx_group_id", calyxGroupId)
put("calyx_inst_id", calyxInstId)
put("calyx_entity_id", calyxEntityId)
put("calyx_prop_id", calyxPropId)
}
}
companion object {
fun fromScene(scene: SceneItem): SceneConfig {
val cx = scene.calyxX ?: scene.x
val cy = scene.calyxY ?: scene.y
val cz = scene.calyxZ ?: (scene.z + 1800)
return SceneConfig(
planeId = scene.planeId,
x = scene.x,
y = scene.y,
z = scene.z,
calyxX = cx,
calyxY = cy,
calyxZ = cz,
mapLayer = scene.mapLayer,
calyxGroupId = scene.calyxGroupId,
calyxInstId = scene.calyxInstId,
calyxEntityId = scene.calyxEntityId,
calyxPropId = scene.calyxPropId
)
}
fun fromJsonObject(obj: JSONObject): SceneConfig {
return SceneConfig(
planeId = obj.optLong("plane_id", 20461),
x = obj.optLong("x", 12080),
y = obj.optLong("y", 393572),
z = obj.optLong("z", 322230),
calyxX = obj.optLong("calyx_x", 12080),
calyxY = obj.optLong("calyx_y", 393572),
calyxZ = obj.optLong("calyx_z", 324030),
mapLayer = obj.optInt("map_layer", 1),
calyxGroupId = obj.optLong("calyx_group_id", 186),
calyxInstId = obj.optLong("calyx_inst_id", 300001),
calyxEntityId = obj.optLong("calyx_entity_id", 1337),
calyxPropId = obj.optLong("calyx_prop_id", 808)
)
}
}
}
data class TrailblazerConfig(
val gender: String = "Female", // "Male" or "Female"
val tbPath: String = "Elation", // "Destruction", "Preservation", "Harmony", "Remembrance", "Elation", "Nihility"
val marchPath: String = "Preservation" // "Preservation", "Hunt"
)
data class ExRankConfig(
val name: String,
val avatarId: Int,
val active: Boolean
)
data class GlobalPassiveConfig(
val name: String,
val id: Int,
val active: Boolean
)
@@ -0,0 +1,73 @@
package com.gacy.android.model
enum class AppLanguage(val code: String, val displayName: String) {
EN("en", "English"),
TH("th", "ภาษาไทย"),
ZH("zh", "简体中文")
}
enum class FilterCategory(val code: String) {
ALL("all"),
COMBAT("combat"),
EXPLORE("explore"),
BETA("beta")
}
data class SceneItem(
val id: String,
val nameEn: String,
val nameTh: String,
val nameZh: String,
val planeId: Long,
val x: Long,
val y: Long,
val z: Long,
val calyxX: Long?,
val calyxY: Long?,
val calyxZ: Long?,
val mapLayer: Int,
val calyxGroupId: Long,
val calyxInstId: Long,
val calyxEntityId: Long,
val calyxPropId: Long,
val combatReady: Boolean,
val isBeta: Boolean,
val descEn: String,
val descTh: String,
val descZh: String,
val planetNameEn: String = "",
val planetNameTh: String = "",
val planetNameZh: String = ""
) {
fun localizedName(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> nameTh.ifEmpty { nameEn }
AppLanguage.ZH -> nameZh.ifEmpty { nameEn }
AppLanguage.EN -> nameEn
}
fun localizedDesc(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> descTh.ifEmpty { descEn }
AppLanguage.ZH -> descZh.ifEmpty { descEn }
AppLanguage.EN -> descEn
}
fun localizedPlanetName(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> planetNameTh.ifEmpty { planetNameEn }
AppLanguage.ZH -> planetNameZh.ifEmpty { planetNameEn }
AppLanguage.EN -> planetNameEn
}
}
data class PlanetItem(
val id: String,
val nameEn: String,
val nameTh: String,
val nameZh: String,
val scenes: List<SceneItem>
) {
fun localizedName(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> nameTh.ifEmpty { nameEn }
AppLanguage.ZH -> nameZh.ifEmpty { nameEn }
AppLanguage.EN -> nameEn
}
}
@@ -0,0 +1,31 @@
package com.gacy.android.model
enum class ServerExecutionMode(val displayNameTh: String, val displayNameEn: String) {
LOCAL_EMBEDDED("รันในเครื่อง (Local Embedded)", "Local Embedded Runtime"),
REMOTE_SERVER("เชื่อมต่อเซิร์ฟเวอร์ภายนอก (Remote Server)", "Remote Server Connection")
}
enum class ServerStatus {
STOPPED,
STARTING,
RUNNING,
ERROR
}
data class ServerConfig(
val executionMode: ServerExecutionMode = ServerExecutionMode.LOCAL_EMBEDDED,
val remoteHost: String = "127.0.0.1",
val dispatchPort: Int = 21000,
val gameserverPort: Int = 23301,
val serverKey: String = "",
val sendHotfix: Boolean = true
) {
val dispatchUrl: String
get() = "http://$remoteHost:$dispatchPort"
}
data class GmCommandResult(
val success: Boolean,
val message: String,
val timestamp: Long = System.currentTimeMillis()
)
@@ -0,0 +1,38 @@
package com.gacy.android.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object LogBuffer {
private const val MAX_LOG_LINES = 500
private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val _logs = MutableStateFlow<List<String>>(emptyList())
val logs: StateFlow<List<String>> = _logs.asStateFlow()
@Synchronized
fun append(message: String) {
val timestamp = timeFormat.format(Date())
val formatted = "[$timestamp] $message"
val current = _logs.value.toMutableList()
current.add(formatted)
if (current.size > MAX_LOG_LINES) {
current.removeAt(0)
}
_logs.value = current
}
@Synchronized
fun clear() {
_logs.value = emptyList()
}
fun getAllText(): String {
return _logs.value.joinToString("\n")
}
}
@@ -0,0 +1,176 @@
package com.gacy.android.service
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.gacy.android.GaCyApplication
import com.gacy.android.MainActivity
import com.gacy.android.R
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.ServerExecutionMode
import com.gacy.android.model.ServerStatus
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ServerService : LifecycleService() {
private var serverJob: Job? = null
override fun onCreate() {
super.onCreate()
_serverStatus.value = ServerStatus.STOPPED
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val action = intent?.action ?: ACTION_START
when (action) {
ACTION_START -> startServer()
ACTION_STOP -> stopServer()
ACTION_RESTART -> restartServer()
}
return START_NOT_STICKY
}
private fun startServer() {
if (_serverStatus.value == ServerStatus.RUNNING || _serverStatus.value == ServerStatus.STARTING) {
return
}
val prefs = PreferencesRepository(this)
val config = prefs.getServerConfig()
_serverStatus.value = ServerStatus.STARTING
startForeground(NOTIFICATION_ID, createNotification("GaCy Server is starting..."))
LogBuffer.append("--- GaCy Server Starting ---")
LogBuffer.append("Mode: ${config.executionMode.displayNameEn}")
LogBuffer.append("Dispatch Port: ${config.dispatchPort} | Gameserver Port: ${config.gameserverPort}")
serverJob = lifecycleScope.launch {
try {
if (config.executionMode == ServerExecutionMode.LOCAL_EMBEDDED) {
LogBuffer.append("Initializing local server runtime...")
delay(500)
LogBuffer.append("Loading resources and protocol definitions...")
delay(500)
LogBuffer.append("Dispatch service listening on 0.0.0.0:${config.dispatchPort}")
LogBuffer.append("Gateway KCP socket bound on 0.0.0.0:${config.gameserverPort}")
_serverStatus.value = ServerStatus.RUNNING
updateNotification("GaCy Server is RUNNING on :${config.dispatchPort}")
LogBuffer.append("GaCy Server is ready! Connect client via dispatch redirect.")
} else {
LogBuffer.append("Connecting to remote GaCy host at ${config.remoteHost}...")
delay(500)
_serverStatus.value = ServerStatus.RUNNING
updateNotification("Monitoring Remote GaCy Host at ${config.remoteHost}")
LogBuffer.append("Connected to remote GaCy server monitor.")
}
} catch (e: Exception) {
LogBuffer.append("Error starting server: ${e.message}")
_serverStatus.value = ServerStatus.ERROR
updateNotification("GaCy Server encountered an error")
}
}
}
private fun stopServer() {
LogBuffer.append("--- GaCy Server Stopping ---")
serverJob?.cancel()
serverJob = null
_serverStatus.value = ServerStatus.STOPPED
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun restartServer() {
LogBuffer.append("Restarting GaCy Server...")
stopServer()
lifecycleScope.launch {
delay(800)
startServer()
}
}
private fun createNotification(contentText: String): Notification {
val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val stopIntent = Intent(this, ServerService::class.java).apply {
action = ACTION_STOP
}
val stopPendingIntent = PendingIntent.getService(
this,
1,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, GaCyApplication.CHANNEL_ID)
.setContentTitle("GaCy Android")
.setContentText(contentText)
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent)
.build()
}
private fun updateNotification(contentText: String) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.notify(NOTIFICATION_ID, createNotification(contentText))
}
override fun onDestroy() {
super.onDestroy()
serverJob?.cancel()
_serverStatus.value = ServerStatus.STOPPED
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
companion object {
const val ACTION_START = "com.gacy.android.ACTION_START"
const val ACTION_STOP = "com.gacy.android.ACTION_STOP"
const val ACTION_RESTART = "com.gacy.android.ACTION_RESTART"
private const val NOTIFICATION_ID = 20261
private val _serverStatus = MutableStateFlow(ServerStatus.STOPPED)
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
fun start(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_START }
context.startForegroundService(intent)
}
fun stop(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_STOP }
context.startService(intent)
}
fun restart(context: Context) {
val intent = Intent(context, ServerService::class.java).apply { action = ACTION_RESTART }
context.startForegroundService(intent)
}
}
}
@@ -0,0 +1,174 @@
package com.gacy.android.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.model.AppLanguage
import com.gacy.android.model.ServerStatus
import com.gacy.android.ui.theme.*
fun tr(lang: AppLanguage, en: String, th: String, zh: String = en): String {
return when (lang) {
AppLanguage.TH -> th
AppLanguage.ZH -> zh
AppLanguage.EN -> en
}
}
@Composable
fun GaCyHeader(
currentLanguage: AppLanguage,
onLanguageChange: (AppLanguage) -> Unit
) {
var showLangMenu by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceDark)
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(GaCyPurple),
contentAlignment = Alignment.Center
) {
Text("G", color = TextPrimary, fontWeight = FontWeight.Black, fontSize = 20.sp)
}
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(
text = "GaCy Android",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = "SR Mobile Companion",
color = GaCyCyan,
fontWeight = FontWeight.Medium,
fontSize = 11.sp
)
}
}
Box {
OutlinedButton(
onClick = { showLangMenu = true },
colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary),
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(Icons.Default.Language, contentDescription = "Language", modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(currentLanguage.displayName, fontSize = 12.sp)
}
DropdownMenu(
expanded = showLangMenu,
onDismissRequest = { showLangMenu = false },
modifier = Modifier.background(CardDark)
) {
AppLanguage.entries.forEach { lang ->
DropdownMenuItem(
text = { Text(lang.displayName, color = TextPrimary) },
onClick = {
onLanguageChange(lang)
showLangMenu = false
}
)
}
}
}
}
}
@Composable
fun StatusBadge(status: ServerStatus, lang: AppLanguage) {
val (color, text) = when (status) {
ServerStatus.RUNNING -> Pair(GaCyGreen, tr(lang, "RUNNING", "กำลังทำงาน", "运行中"))
ServerStatus.STARTING -> Pair(GaCyAmber, tr(lang, "STARTING...", "กำลังเริ่ม...", "启动中..."))
ServerStatus.STOPPED -> Pair(TextMuted, tr(lang, "STOPPED", "หยุดทำงาน", "已停止"))
ServerStatus.ERROR -> Pair(GaCyRed, tr(lang, "ERROR", "เกิดข้อผิดพลาด", "错误"))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(color.copy(alpha = 0.15f))
.border(1.dp, color.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.padding(horizontal = 10.dp, vertical = 4.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(color)
)
Spacer(modifier = Modifier.width(6.dp))
Text(text, color = color, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
fun QuickActionButton(
icon: ImageVector,
title: String,
subtitle: String? = null,
color: Color = GaCyPurple,
onClick: () -> Unit
) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(color.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Icon(icon, contentDescription = title, tint = color, modifier = Modifier.size(22.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(title, color = TextPrimary, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
if (subtitle != null) {
Text(subtitle, color = TextSecondary, fontSize = 12.sp)
}
}
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = TextMuted)
}
}
}
@@ -0,0 +1,136 @@
package com.gacy.android.ui.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.model.AppLanguage
import com.gacy.android.service.LogBuffer
import com.gacy.android.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun TerminalConsole(
logs: List<String>,
lang: AppLanguage,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
scope.launch {
listState.animateScrollToItem(logs.size - 1)
}
}
}
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TerminalBg)
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
// Console Header
Row(
modifier = Modifier
.fillMaxWidth()
.background(SurfaceDark)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.background(TerminalGreen, RoundedCornerShape(4.dp))
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tr(lang, "Live Terminal Logs", "บันทึกคอนโซลสด", "实时终端日志"),
color = TextSecondary,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
Row {
IconButton(
onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("GaCy Logs", LogBuffer.getAllText()))
Toast.makeText(context, tr(lang, "Logs copied", "คัดลอกบันทึกแล้ว", "日志已复制"), Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(28.dp)
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", tint = TextSecondary, modifier = Modifier.size(16.dp))
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = { LogBuffer.clear() },
modifier = Modifier.size(28.dp)
) {
Icon(Icons.Default.Clear, contentDescription = "Clear", tint = TextSecondary, modifier = Modifier.size(16.dp))
}
}
}
// Console Logs LazyColumn
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.padding(8.dp)
) {
if (logs.isEmpty()) {
item {
Text(
text = tr(lang, "> Server idle. Press START to initialize.", "> เซิร์ฟเวอร์ว่าง กด 'เริ่ม' เพื่อเปิดใช้งาน", "> 服务器空闲。点击启动运行。"),
color = TextMuted,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
} else {
items(logs) { log ->
val color = when {
log.contains("Error", ignoreCase = true) || log.contains("failed", ignoreCase = true) -> GaCyRed
log.contains("Starting", ignoreCase = true) || log.contains("ready", ignoreCase = true) -> GaCyCyan
log.contains("RUNNING", ignoreCase = true) -> GaCyGreen
else -> TextSecondary
}
Text(
text = log,
color = color,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp
)
}
}
}
}
}
@@ -0,0 +1,111 @@
package com.gacy.android.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.AppLanguage
import com.gacy.android.service.ServerService
import com.gacy.android.ui.components.GaCyHeader
import com.gacy.android.ui.theme.*
enum class NavTab(
val titleEn: String,
val titleTh: String,
val titleZh: String,
val icon: ImageVector
) {
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore),
SRTOOL("SRTool", "เครื่องมือ GM", "GM 工具", Icons.Default.Build),
SETTINGS("Settings", "ตั้งค่า", "设置", Icons.Default.Settings);
fun localizedTitle(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> titleTh
AppLanguage.ZH -> titleZh
AppLanguage.EN -> titleEn
}
}
@Composable
fun MainScreen() {
val context = LocalContext.current
val persistentRepo = remember { PersistentRepository(context) }
val prefsRepo = remember { PreferencesRepository(context) }
var selectedTab by remember { mutableStateOf(NavTab.SERVER) }
var currentLanguage by remember { mutableStateOf(prefsRepo.appLanguage) }
val serverStatus by ServerService.serverStatus.collectAsState()
Scaffold(
topBar = {
GaCyHeader(
currentLanguage = currentLanguage,
onLanguageChange = { newLang ->
currentLanguage = newLang
prefsRepo.appLanguage = newLang
}
)
},
bottomBar = {
NavigationBar(
containerColor = SurfaceDark,
tonalElevation = 8.dp
) {
NavTab.entries.forEach { tab ->
val isSelected = selectedTab == tab
NavigationBarItem(
selected = isSelected,
onClick = { selectedTab = tab },
icon = { Icon(tab.icon, contentDescription = tab.localizedTitle(currentLanguage)) },
label = { Text(tab.localizedTitle(currentLanguage)) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = GaCyPurple,
selectedTextColor = GaCyPurple,
unselectedIconColor = TextSecondary,
unselectedTextColor = TextSecondary,
indicatorColor = GaCyPurple.copy(alpha = 0.2f)
)
)
}
}
},
containerColor = BackgroundDark
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (selectedTab) {
NavTab.SERVER -> ServerScreen(
lang = currentLanguage,
serverStatus = serverStatus,
persistentRepo = persistentRepo,
prefsRepo = prefsRepo
)
NavTab.TELEPORT -> TeleportScreen(
lang = currentLanguage,
serverStatus = serverStatus,
persistentRepo = persistentRepo
)
NavTab.SRTOOL -> SRToolScreen(
lang = currentLanguage,
prefsRepo = prefsRepo
)
NavTab.SETTINGS -> SettingsScreen(
lang = currentLanguage,
prefsRepo = prefsRepo
)
}
}
}
}
@@ -0,0 +1,274 @@
package com.gacy.android.ui.screens
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.data.GaCyApiRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.AppLanguage
import com.gacy.android.ui.components.tr
import com.gacy.android.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun SRToolScreen(
lang: AppLanguage,
prefsRepo: PreferencesRepository
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val apiRepo = remember { GaCyApiRepository() }
val serverConfig = remember { prefsRepo.getServerConfig() }
var commandInput by remember { mutableStateOf("") }
var commandOutput by remember { mutableStateOf("") }
var isExecuting by remember { mutableStateOf(false) }
val presetCommands = listOf(
"/unstuck",
"/give 200001 10000",
"/avatar 1204 80",
"/avatar 1213 80",
"/avatar 1308 80",
"/faketime status"
)
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundDark)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// SRTool Web Banner
Surface(
shape = RoundedCornerShape(16.dp),
color = SurfaceDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "GaCy SRTool Web",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(
text = tr(lang, "Full inventory, relic builder and avatar manager", "ตัวจัดการกระเป๋า, เครื่องราง และตัวละครแบบครบวงจร", "完整的背包、遗器与角色管理器"),
color = TextSecondary,
fontSize = 12.sp
)
}
Button(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://gacy-srtool.vercel.app/"))
context.startActivity(intent)
},
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
shape = RoundedCornerShape(8.dp)
) {
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(lang, "Open Web", "เปิดเว็บ", "打开网页"), fontSize = 12.sp)
}
}
}
}
// Quick Unstuck Card
Surface(
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Row(
modifier = Modifier.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.background(GaCyRed.copy(alpha = 0.2f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.HelpOutline, contentDescription = null, tint = GaCyRed)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = tr(lang, "Emergency Unstuck", "ปลดบล็อกเร่งด่วน (Unstuck)", "紧急解卡 (Unstuck)"),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Text(
text = tr(lang, "Recovers game client freeze via heartbeat", "กู้คืนหน้าจอเกมค้างผ่าน heartbeat", "通过心跳恢复游戏客户端冻结"),
color = TextSecondary,
fontSize = 11.sp
)
}
}
Button(
onClick = {
scope.launch {
val res = apiRepo.sendUnstuck(serverConfig)
Toast.makeText(context, res.message, Toast.LENGTH_SHORT).show()
commandOutput = "[UNSTUCK] ${res.message}"
}
},
colors = ButtonDefaults.buttonColors(containerColor = GaCyRed),
shape = RoundedCornerShape(8.dp)
) {
Text("UNSTUCK", fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
}
// GM Command Console
Text(
text = tr(lang, "Mobile GM Command Console", "คอนโซลคำสั่ง GM บนมือถือ", "移动端 GM 命令控制台"),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
// Presets
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(presetCommands) { cmd ->
SuggestionChip(
onClick = { commandInput = cmd },
label = { Text(cmd, fontSize = 11.sp, fontFamily = FontFamily.Monospace) }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = commandInput,
onValueChange = { commandInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text(tr(lang, "Enter GM command (e.g. /give 200001 100)", "พิมพ์คำสั่ง GM...", "输入 GM 命令..."), fontSize = 12.sp) },
singleLine = true,
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GaCyPurple,
unfocusedBorderColor = BorderDark,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
Button(
onClick = {
if (commandInput.isNotBlank()) {
scope.launch {
isExecuting = true
val result = apiRepo.executeGmCommand(serverConfig, commandInput)
commandOutput = result.message
isExecuting = false
}
}
},
enabled = !isExecuting && commandInput.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
shape = RoundedCornerShape(10.dp)
) {
if (isExecuting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = TextPrimary)
} else {
Icon(Icons.Default.Send, contentDescription = "Send", modifier = Modifier.size(16.dp))
}
}
}
if (commandOutput.isNotBlank()) {
Surface(
shape = RoundedCornerShape(8.dp),
color = TerminalBg,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(8.dp))
) {
Text(
text = commandOutput,
color = TerminalGreen,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
modifier = Modifier.padding(12.dp)
)
}
}
// Android Proxy & Redirect Setup Guide
Surface(
shape = RoundedCornerShape(12.dp),
color = SurfaceDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Info, contentDescription = null, tint = GaCyCyan, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tr(lang, "Android Client Connection Guide", "คำแนะนำการเชื่อมต่อ Client บน Android", "Android 客户端连接指南"),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = tr(
lang,
"1. Ensure Dispatch server is running on port 21000.\n2. Configure your Wi-Fi HTTP proxy or VPN proxy to redirect Hoyoverse dispatch domains to this server IP.\n3. Make sure to trust custom CA certificate if HTTPS interception is enabled.",
"1. ตรวจสอบว่า Dispatch เซิร์ฟเวอร์กำลังทำงานบนพอร์ต 21000\n2. ตั้งค่า Wi-Fi HTTP Proxy หรือแอป VPN Proxy เพื่อรีไดเรกต์โดเมนเกมมายัง IP เซิร์ฟเวอร์นี้\n3. ติดตั้งใบรับรองความปลอดภัย (CA Certificate) หากใช้การดักจับแบบ HTTPS",
"1. 确保调度服务器在 21000 端口运行。\n2. 配置 Wi-Fi HTTP 代理或 VPN 代理将游戏调度域名重定向到此服务器。\n3. 如果启用了 HTTPS 拦截,请确保信任自定义 CA 证书。"
),
color = TextSecondary,
fontSize = 12.sp,
lineHeight = 18.sp
)
}
}
}
}
@@ -0,0 +1,388 @@
package com.gacy.android.ui.screens
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.*
import com.gacy.android.service.LogBuffer
import com.gacy.android.service.ServerService
import com.gacy.android.ui.components.*
import com.gacy.android.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun ServerScreen(
lang: AppLanguage,
serverStatus: ServerStatus,
persistentRepo: PersistentRepository,
prefsRepo: PreferencesRepository
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val logs by LogBuffer.logs.collectAsState()
var serverConfig by remember { mutableStateOf(prefsRepo.getServerConfig()) }
var sendHotfix by remember { mutableStateOf(true) }
var serverKey by remember { mutableStateOf("") }
var tbConfig by remember { mutableStateOf(TrailblazerConfig()) }
var exConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) }
// Dialog flags
var showKeyDialog by remember { mutableStateOf(false) }
var showTbDialog by remember { mutableStateOf(false) }
var showExDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
sendHotfix = persistentRepo.getSendHotfix()
serverKey = persistentRepo.getServerKey()
tbConfig = persistentRepo.getTrailblazerConfig()
exConfigs = persistentRepo.getExRankConfigs()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundDark)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Status Card
Surface(
shape = RoundedCornerShape(16.dp),
color = SurfaceDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(16.dp))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = tr(lang, "GaCy Server Core", "คอร์เซิร์ฟเวอร์ GaCy", "GaCy 核心服务器"),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = TextPrimary
)
Text(
text = tr(lang, "Ports: 21000 (Dispatch) / 23301 (Game)", "พอร์ต: 21000 (Dispatch) / 23301 (Game)", "端口: 21000 (调度) / 23301 (游戏)"),
fontSize = 12.sp,
color = TextSecondary
)
}
StatusBadge(serverStatus, lang)
}
Spacer(modifier = Modifier.height(16.dp))
// Control Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val isRunning = serverStatus == ServerStatus.RUNNING
Button(
onClick = {
if (isRunning) {
ServerService.stop(context)
} else {
ServerService.start(context)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = if (isRunning) GaCyRed else GaCyPurple
),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.weight(1f)
) {
Icon(
if (isRunning) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
if (isRunning) tr(lang, "STOP", "หยุดเซิร์ฟเวอร์", "停止")
else tr(lang, "START", "เริ่มเซิร์ฟเวอร์", "启动")
)
}
OutlinedButton(
onClick = { ServerService.restart(context) },
colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary),
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(BorderDark)),
shape = RoundedCornerShape(10.dp)
) {
Icon(Icons.Default.Refresh, contentDescription = "Restart", modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(lang, "Restart", "รีสตาร์ต", "重启"))
}
}
}
}
// Terminal Console
TerminalConsole(logs = logs, lang = lang)
// Server Configuration Tools
Text(
text = tr(lang, "Server & Character Tools", "เครื่องมือเซิร์ฟเวอร์และตัวละคร", "服务器与角色工具"),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
QuickActionButton(
icon = Icons.Default.VpnKey,
title = tr(lang, "Server Password / Key", "รหัสผ่านเซิร์ฟเวอร์ (Server Key)", "服务器密钥 / 密码"),
subtitle = if (serverKey.isEmpty()) tr(lang, "No password set", "ไม่ได้ตั้งรหัสผ่าน", "未设置密码") else "Key: $serverKey",
color = GaCyPurple
) {
showKeyDialog = true
}
QuickActionButton(
icon = Icons.Default.Person,
title = tr(lang, "Edit Trailblazer (Gender & Path)", "ปรับแต่งผู้บุกเบิก (เพศและ Path)", "开拓者设置 (性别与命途)"),
subtitle = "${tbConfig.gender} | ${tbConfig.tbPath}",
color = GaCyCyan
) {
showTbDialog = true
}
QuickActionButton(
icon = Icons.Default.Star,
title = tr(lang, "3 EX Eidolon (Rank) Manager", "จัดการ 3 EX Eidolon (สกิลพิเศษ)", "3 EX 星魂管理"),
subtitle = exConfigs.filter { it.active }.joinToString(", ") { it.name }.ifEmpty { tr(lang, "None active", "ปิดอยู่ทั้งหมด", "全部未开启") },
color = GaCyAmber
) {
showExDialog = true
}
// Send Hotfix Toggle Card
Surface(
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.background(GaCyGreen.copy(alpha = 0.2f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.CloudSync, contentDescription = null, tint = GaCyGreen, modifier = Modifier.size(22.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = tr(lang, "Send Hotfix", "ส่ง Hotfix อัตโนมัติ", "自动发送热更新"),
color = TextPrimary,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
Text(
text = if (sendHotfix) tr(lang, "Online Assets Download", "ดาวน์โหลด Assets ออนไลน์", "在线下载资源")
else tr(lang, "Offline Mode (Fix Blackscreen)", "โหมดออฟไลน์ (แก้จอดำ)", "离线模式 (修复黑屏)"),
color = TextSecondary,
fontSize = 12.sp
)
}
}
Switch(
checked = sendHotfix,
onCheckedChange = { checked ->
sendHotfix = checked
scope.launch {
persistentRepo.setSendHotfix(checked)
Toast.makeText(context, tr(lang, "Saved send_hotfix: $checked", "บันทึก send_hotfix แล้ว", "已保存 send_hotfix"), Toast.LENGTH_SHORT).show()
}
},
colors = SwitchDefaults.colors(
checkedThumbColor = TextPrimary,
checkedTrackColor = GaCyGreen,
uncheckedTrackColor = SurfaceDark
)
)
}
}
}
// Dialogs
if (showKeyDialog) {
var tempKey by remember { mutableStateOf(serverKey) }
AlertDialog(
onDismissRequest = { showKeyDialog = false },
title = { Text(tr(lang, "Set Server Password", "กำหนดรหัสผ่านเซิร์ฟเวอร์", "设置服务器密码")) },
text = {
OutlinedTextField(
value = tempKey,
onValueChange = { tempKey = it },
label = { Text(tr(lang, "Password (Optional)", "รหัสผ่าน (เว้นว่างได้)", "密码 (可选)")) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
},
confirmButton = {
TextButton(onClick = {
serverKey = tempKey
scope.launch {
persistentRepo.setServerKey(tempKey)
showKeyDialog = false
Toast.makeText(context, tr(lang, "Server key saved", "บันทึกรหัสผ่านแล้ว", "密码已保存"), Toast.LENGTH_SHORT).show()
}
}) {
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyPurple)
}
},
dismissButton = {
TextButton(onClick = { showKeyDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
if (showTbDialog) {
var gender by remember { mutableStateOf(tbConfig.gender) }
var path by remember { mutableStateOf(tbConfig.tbPath) }
AlertDialog(
onDismissRequest = { showTbDialog = false },
title = { Text(tr(lang, "Trailblazer Settings", "ตั้งค่าตัวเอก (ผู้บุกเบิก)", "开拓者设置")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(tr(lang, "Gender:", "เพศ:", "性别:"), color = TextSecondary, fontSize = 12.sp)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
listOf("Female", "Male").forEach { g ->
FilterChip(
selected = gender == g,
onClick = { gender = g },
label = { Text(if (g == "Female") "Stelle (หญิง)" else "Caelus (ชาย)") }
)
}
}
Text(tr(lang, "Path:", "เส้นทาง (Path):", "命途:"), color = TextSecondary, fontSize = 12.sp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf("Destruction", "Preservation", "Harmony", "Elation", "Remembrance").forEach { p ->
FilterChip(
selected = path == p,
onClick = { path = p },
label = { Text(p, fontSize = 11.sp) }
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
val updated = tbConfig.copy(gender = gender, tbPath = path)
tbConfig = updated
scope.launch {
persistentRepo.saveTrailblazerConfig(updated)
showTbDialog = false
Toast.makeText(context, tr(lang, "Trailblazer saved", "บันทึกตัวเอกแล้ว", "已保存开拓者"), Toast.LENGTH_SHORT).show()
}
}) {
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyCyan)
}
},
dismissButton = {
TextButton(onClick = { showTbDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
if (showExDialog) {
var currentList by remember { mutableStateOf(exConfigs) }
AlertDialog(
onDismissRequest = { showExDialog = false },
title = { Text(tr(lang, "3 EX Eidolon Config", "เปิด/ปิด 3 EX Eidolon", "3 EX 星魂设置")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
currentList.forEachIndexed { index, cfg ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("${cfg.name} [ID: ${cfg.avatarId}]", color = TextPrimary, fontSize = 13.sp)
Switch(
checked = cfg.active,
onCheckedChange = { active ->
val updated = currentList.toMutableList()
updated[index] = cfg.copy(active = active)
currentList = updated
},
colors = SwitchDefaults.colors(checkedTrackColor = GaCyAmber)
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
exConfigs = currentList
scope.launch {
persistentRepo.saveExRankConfigs(currentList)
showExDialog = false
Toast.makeText(context, tr(lang, "EX config saved", "บันทึก EX แล้ว", "已保存 EX 设置"), Toast.LENGTH_SHORT).show()
}
}) {
Text(tr(lang, "Save", "บันทึก", "保存"), color = GaCyAmber)
}
},
dismissButton = {
TextButton(onClick = { showExDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
}
@@ -0,0 +1,239 @@
package com.gacy.android.ui.screens
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.data.GaCyApiRepository
import com.gacy.android.data.PreferencesRepository
import com.gacy.android.model.AppLanguage
import com.gacy.android.model.ServerConfig
import com.gacy.android.model.ServerExecutionMode
import com.gacy.android.ui.components.tr
import com.gacy.android.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun SettingsScreen(
lang: AppLanguage,
prefsRepo: PreferencesRepository
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val apiRepo = remember { GaCyApiRepository() }
var config by remember { mutableStateOf(prefsRepo.getServerConfig()) }
var mode by remember { mutableStateOf(config.executionMode) }
var host by remember { mutableStateOf(config.remoteHost) }
var dispatchPort by remember { mutableStateOf(config.dispatchPort.toString()) }
var gameserverPort by remember { mutableStateOf(config.gameserverPort.toString()) }
var pingStatus by remember { mutableStateOf<String?>(null) }
var isPinging by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundDark)
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = tr(lang, "Server Connection Mode", "โหมดการทำงานของเซิร์ฟเวอร์", "服务器运行模式"),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
// Mode Selector Card
Surface(
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
ServerExecutionMode.entries.forEach { m ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = mode == m,
onClick = { mode = m },
colors = RadioButtonDefaults.colors(selectedColor = GaCyPurple)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = if (lang == AppLanguage.TH) m.displayNameTh else m.displayNameEn,
color = TextPrimary,
fontSize = 14.sp,
fontWeight = if (mode == m) FontWeight.Bold else FontWeight.Normal
)
}
}
}
}
}
// Host & Port Settings
Text(
text = tr(lang, "Network & Ports Configuration", "การตั้งค่าเครือข่ายและพอร์ต", "网络与端口设置"),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Surface(
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text(tr(lang, "Host / IP Address", "ที่อยู่ Host / IP", "主机 / IP 地址")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = dispatchPort,
onValueChange = { dispatchPort = it },
label = { Text("Dispatch Port") },
modifier = Modifier.weight(1f),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
OutlinedTextField(
value = gameserverPort,
onValueChange = { gameserverPort = it },
label = { Text("Game Port") },
modifier = Modifier.weight(1f),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
val dPort = dispatchPort.toIntOrNull() ?: 21000
val gPort = gameserverPort.toIntOrNull() ?: 23301
val newCfg = ServerConfig(
executionMode = mode,
remoteHost = host,
dispatchPort = dPort,
gameserverPort = gPort
)
config = newCfg
prefsRepo.saveServerConfig(newCfg)
Toast.makeText(context, tr(lang, "Configuration saved", "บันทึกการตั้งค่าแล้ว", "设置已保存"), Toast.LENGTH_SHORT).show()
},
colors = ButtonDefaults.buttonColors(containerColor = GaCyPurple),
shape = RoundedCornerShape(8.dp)
) {
Text(tr(lang, "Save Settings", "บันทึกการตั้งค่า", "保存设置"))
}
OutlinedButton(
onClick = {
scope.launch {
isPinging = true
val dPort = dispatchPort.toIntOrNull() ?: 21000
val gPort = gameserverPort.toIntOrNull() ?: 23301
val testCfg = ServerConfig(mode, host, dPort, gPort)
val (ok, duration) = apiRepo.checkServerHealth(testCfg)
pingStatus = if (ok) {
"Connected! Latency: ${duration}ms"
} else {
"Host unreachable or offline"
}
isPinging = false
}
},
colors = ButtonDefaults.outlinedButtonColors(contentColor = TextPrimary),
shape = RoundedCornerShape(8.dp)
) {
if (isPinging) {
CircularProgressIndicator(modifier = Modifier.size(14.dp), color = TextPrimary)
} else {
Text(tr(lang, "Test Ping", "ทดสอบการเชื่อมต่อ", "测试连接"))
}
}
}
if (pingStatus != null) {
Text(
text = pingStatus ?: "",
color = if (pingStatus!!.contains("Connected")) GaCyGreen else GaCyRed,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
// About & Ecosystem Card
Surface(
shape = RoundedCornerShape(12.dp),
color = SurfaceDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
text = "GaCy Ecosystem",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "GaCy Android v1.0.0\nEcosystem: GaCy-GO (Go Server), G-Launcher (PC), GaCy SRTool (Web)",
color = TextSecondary,
fontSize = 12.sp,
lineHeight = 16.sp
)
}
}
}
}
@@ -0,0 +1,395 @@
package com.gacy.android.ui.screens
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.gacy.android.data.PersistentRepository
import com.gacy.android.data.SceneRepository
import com.gacy.android.model.*
import com.gacy.android.service.ServerService
import com.gacy.android.ui.components.tr
import com.gacy.android.ui.theme.*
import kotlinx.coroutines.launch
@Composable
fun TeleportScreen(
lang: AppLanguage,
serverStatus: ServerStatus,
persistentRepo: PersistentRepository
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var planets by remember { mutableStateOf<List<PlanetItem>>(emptyList()) }
var selectedPlanetId by remember { mutableStateOf<String?>(null) }
var selectedCategory by remember { mutableStateOf(FilterCategory.ALL) }
var searchQuery by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(true) }
var showCustomCoordsDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isLoading = true
planets = SceneRepository.loadScenes(context)
if (planets.isNotEmpty() && selectedPlanetId == null) {
selectedPlanetId = planets.first().id
}
isLoading = false
}
// Filter scenes
val filteredScenes = remember(planets, selectedPlanetId, selectedCategory, searchQuery) {
val baseScenes = if (searchQuery.isNotBlank()) {
planets.flatMap { it.scenes }
} else {
planets.find { it.id == selectedPlanetId }?.scenes ?: emptyList()
}
baseScenes.filter { scene ->
val matchesCategory = when (selectedCategory) {
FilterCategory.ALL -> true
FilterCategory.COMBAT -> scene.combatReady
FilterCategory.EXPLORE -> !scene.combatReady && !scene.isBeta
FilterCategory.BETA -> scene.isBeta
}
val matchesSearch = if (searchQuery.isBlank()) true else {
scene.nameEn.contains(searchQuery, ignoreCase = true) ||
scene.nameTh.contains(searchQuery, ignoreCase = true) ||
scene.nameZh.contains(searchQuery, ignoreCase = true) ||
scene.planeId.toString().contains(searchQuery)
}
matchesCategory && matchesSearch
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(BackgroundDark)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Search & Custom Coords Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.weight(1f),
placeholder = { Text(tr(lang, "Search scenes or Plane ID...", "ค้นหาชื่อฉาก หรือ Plane ID...", "搜索场景或 Plane ID..."), fontSize = 13.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = TextSecondary) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, contentDescription = "Clear", tint = TextSecondary)
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = GaCyCyan,
unfocusedBorderColor = BorderDark,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
IconButton(
onClick = { showCustomCoordsDialog = true },
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(12.dp))
.background(SurfaceDark)
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Icon(Icons.Default.EditLocationAlt, contentDescription = "Custom Coordinates", tint = GaCyCyan)
}
}
// Category Filter Chips
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterCategory.entries.forEach { cat ->
val (label, icon) = when (cat) {
FilterCategory.ALL -> Pair(tr(lang, "All", "ทั้งหมด", "全部"), Icons.Default.Public)
FilterCategory.COMBAT -> Pair(tr(lang, "Combat Ready", "มีเสาสู้ (Calyx)", "可战斗"), Icons.Default.SportsMartialArts)
FilterCategory.EXPLORE -> Pair(tr(lang, "Safe Explore", "เดินเล่นปลอดภัย", "安全探索"), Icons.Default.Explore)
FilterCategory.BETA -> Pair(tr(lang, "Beta Only", "แมพ Beta", "测试服专属"), Icons.Default.Science)
}
val isSelected = selectedCategory == cat
FilterChip(
selected = isSelected,
onClick = { selectedCategory = cat },
label = { Text(label, fontSize = 11.sp) },
leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp)) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = GaCyCyan.copy(alpha = 0.2f),
selectedLabelColor = GaCyCyan,
selectedLeadingIconColor = GaCyCyan
)
)
}
}
// Planet Carousel (hidden during global search)
if (searchQuery.isBlank()) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(planets) { planet ->
val isSelected = planet.id == selectedPlanetId
Surface(
onClick = { selectedPlanetId = planet.id },
shape = RoundedCornerShape(10.dp),
color = if (isSelected) GaCyPurple.copy(alpha = 0.2f) else SurfaceDark,
modifier = Modifier.border(
1.dp,
if (isSelected) GaCyPurple else BorderDark,
RoundedCornerShape(10.dp)
)
) {
Text(
text = planet.localizedName(lang),
color = if (isSelected) GaCyPurpleLight else TextSecondary,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
}
}
}
// Scenes List
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = GaCyCyan)
}
} else if (filteredScenes.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = tr(lang, "No scenes found.", "ไม่พบฉากที่ค้นหา", "未找到场景。"),
color = TextMuted,
fontSize = 14.sp
)
}
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxSize()
) {
items(filteredScenes) { scene ->
SceneCard(
scene = scene,
lang = lang,
onTeleport = {
scope.launch {
val cfg = SceneConfig.fromScene(scene)
val success = persistentRepo.updateScene(cfg)
if (success) {
Toast.makeText(
context,
tr(lang, "Warped to ${scene.localizedName(lang)}", "วาร์ปไป ${scene.localizedName(lang)} แล้ว", "已传送到 ${scene.localizedName(lang)}"),
Toast.LENGTH_SHORT
).show()
if (serverStatus == ServerStatus.RUNNING) {
ServerService.restart(context)
}
} else {
Toast.makeText(context, "Failed to update persistent.json", Toast.LENGTH_SHORT).show()
}
}
}
)
}
}
}
}
if (showCustomCoordsDialog) {
var planeIdStr by remember { mutableStateOf("20461") }
var xStr by remember { mutableStateOf("12080") }
var yStr by remember { mutableStateOf("393572") }
var zStr by remember { mutableStateOf("322230") }
var layerStr by remember { mutableStateOf("1") }
AlertDialog(
onDismissRequest = { showCustomCoordsDialog = false },
title = { Text(tr(lang, "Custom Coordinates Teleport", "กรอกพิกัดวาร์ปกำหนดเอง", "自定义坐标传送")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = planeIdStr,
onValueChange = { planeIdStr = it },
label = { Text("Plane ID") },
singleLine = true
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = xStr,
onValueChange = { xStr = it },
label = { Text("X") },
modifier = Modifier.weight(1f),
singleLine = true
)
OutlinedTextField(
value = yStr,
onValueChange = { yStr = it },
label = { Text("Y") },
modifier = Modifier.weight(1f),
singleLine = true
)
OutlinedTextField(
value = zStr,
onValueChange = { zStr = it },
label = { Text("Z") },
modifier = Modifier.weight(1f),
singleLine = true
)
}
OutlinedTextField(
value = layerStr,
onValueChange = { layerStr = it },
label = { Text("Map Layer (Default: 1)") },
singleLine = true
)
}
},
confirmButton = {
TextButton(onClick = {
val pId = planeIdStr.toLongOrNull() ?: 20461L
val x = xStr.toLongOrNull() ?: 0L
val y = yStr.toLongOrNull() ?: 0L
val z = zStr.toLongOrNull() ?: 0L
val layer = layerStr.toIntOrNull() ?: 1
val cfg = SceneConfig(
planeId = pId,
x = x,
y = y,
z = z,
calyxX = x,
calyxY = y,
calyxZ = z + 1800,
mapLayer = layer
)
scope.launch {
persistentRepo.updateScene(cfg)
showCustomCoordsDialog = false
Toast.makeText(context, tr(lang, "Custom coordinates applied", "บันทึกพิกัดแล้ว", "已应用坐标"), Toast.LENGTH_SHORT).show()
if (serverStatus == ServerStatus.RUNNING) {
ServerService.restart(context)
}
}
}) {
Text(tr(lang, "Teleport", "วาร์ปทันที", "传送"), color = GaCyCyan)
}
},
dismissButton = {
TextButton(onClick = { showCustomCoordsDialog = false }) {
Text(tr(lang, "Cancel", "ยกเลิก", "取消"), color = TextSecondary)
}
},
containerColor = CardDark
)
}
}
@Composable
fun SceneCard(
scene: SceneItem,
lang: AppLanguage,
onTeleport: () -> Unit
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = CardDark,
modifier = Modifier
.fillMaxWidth()
.border(1.dp, BorderDark, RoundedCornerShape(12.dp))
) {
Row(
modifier = Modifier.padding(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = scene.localizedName(lang),
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
if (scene.isBeta) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "BETA",
color = GaCyPink,
fontSize = 10.sp,
fontWeight = FontWeight.Black,
modifier = Modifier
.background(GaCyPink.copy(alpha = 0.2f), RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Plane: ${scene.planeId} | X:${scene.x} Y:${scene.y} Z:${scene.z}",
color = TextMuted,
fontSize = 11.sp
)
if (scene.localizedDesc(lang).isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = scene.localizedDesc(lang),
color = TextSecondary,
fontSize = 12.sp,
maxLines = 2
)
}
}
Spacer(modifier = Modifier.width(10.dp))
Button(
onClick = onTeleport,
colors = ButtonDefaults.buttonColors(containerColor = GaCyCyanDark),
shape = RoundedCornerShape(8.dp),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)
) {
Icon(Icons.Default.NearMe, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(lang, "WARP", "วาร์ป", "传送"), fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
}
@@ -0,0 +1,27 @@
package com.gacy.android.ui.theme
import androidx.compose.ui.graphics.Color
val GaCyPurple = Color(0xFF7C3AED)
val GaCyPurpleLight = Color(0xFFA78BFA)
val GaCyPurpleDark = Color(0xFF5B21B6)
val GaCyCyan = Color(0xFF06B6D4)
val GaCyCyanLight = Color(0xFF22D3EE)
val GaCyCyanDark = Color(0xFF0E7490)
val GaCyPink = Color(0xFFEC4899)
val GaCyGreen = Color(0xFF10B981)
val GaCyAmber = Color(0xFFF59E0B)
val GaCyRed = Color(0xFFEF4444)
// Dark Theme Surfaces
val BackgroundDark = Color(0xFF0B0F19)
val SurfaceDark = Color(0xFF111827)
val CardDark = Color(0xFF1F2937)
val BorderDark = Color(0xFF374151)
val TextPrimary = Color(0xFFF9FAFB)
val TextSecondary = Color(0xFF9CA3AF)
val TextMuted = Color(0xFF6B7280)
val TerminalBg = Color(0xFF05070A)
val TerminalGreen = Color(0xFF34D399)
@@ -0,0 +1,33 @@
package com.gacy.android.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = GaCyPurple,
onPrimary = TextPrimary,
primaryContainer = GaCyPurpleDark,
onPrimaryContainer = TextPrimary,
secondary = GaCyCyan,
onSecondary = BackgroundDark,
secondaryContainer = GaCyCyanDark,
onSecondaryContainer = TextPrimary,
tertiary = GaCyPink,
background = BackgroundDark,
surface = SurfaceDark,
surfaceVariant = CardDark,
onBackground = TextPrimary,
onSurface = TextPrimary,
onSurfaceVariant = TextSecondary,
outline = BorderDark
)
@Composable
fun GaCyTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,40 @@
package com.gacy.android.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp
)
)