feat: initialize GaCy Android project foundation (Kotlin, Jetpack Compose, Material 3)
This commit is contained in:
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
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user