Files
Escape-penacony-standalone/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt
T

2070 lines
104 KiB
Kotlin

package com.catlas.escapepenacony.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import com.catlas.escapepenacony.BuildConfig
import androidx.compose.foundation.background
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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.automirrored.filled.List
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.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.catlas.escapepenacony.data.*
import com.catlas.escapepenacony.model.*
import com.catlas.escapepenacony.service.ServerService
import com.catlas.escapepenacony.service.ServerStatus
import com.catlas.escapepenacony.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
fun tr(lang: AppLanguage, en: String, th: String, zh: String = en): String {
return when (lang) {
AppLanguage.TH -> th
AppLanguage.ZH -> zh
AppLanguage.EN -> en
}
}
enum class ScreenTab(
val titleEn: String,
val titleTh: String,
val titleZh: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector
) {
SERVER("Server", "เซิร์ฟเวอร์", "服务器", Icons.Default.Computer),
TELEPORT("Teleport", "วาร์ปแผนที่", "传送地图", Icons.Default.Explore),
UPDATER("Updater", "อัปเดต", "更新", Icons.Default.CloudDownload);
fun localizedTitle(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> titleTh
AppLanguage.ZH -> titleZh
AppLanguage.EN -> titleEn
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val persistentRepo = remember { PersistentRepository(context) }
val updateRepo = remember { UpdateRepository(context) }
var selectedTab by remember { mutableStateOf(ScreenTab.SERVER) }
var currentLanguage by remember { mutableStateOf(AppLanguage.TH) }
// Server Service States
val serverStatus by ServerService.serverStatus.collectAsState()
val serverLogs by ServerService.logs.collectAsState()
// Persistent States
var isServerInstalled by remember { mutableStateOf(persistentRepo.isServerInstalled()) }
var sendHotfix by remember { mutableStateOf(true) }
var serverKey by remember { mutableStateOf("") }
var trailblazerConfig by remember { mutableStateOf(TrailblazerConfig("Female", "Destruction", "Preservation")) }
var exRankConfigs by remember { mutableStateOf(PersistentRepository.DEFAULT_EX_RANK_CONFIGS) }
var currentSceneConfig by remember { mutableStateOf<SceneConfig?>(null) }
// Dialog states
var showPasswordDialog by remember { mutableStateOf(false) }
var showTrailblazerDialog by remember { mutableStateOf(false) }
var showExRankDialog by remember { mutableStateOf(false) }
var showMiscExplorerDialog by remember { mutableStateOf(false) }
var showCustomCoordsDialog by remember { mutableStateOf(false) }
var showVersionSelectorDialog by remember { mutableStateOf(false) }
// Misc file editor
var miscFiles by remember { mutableStateOf<List<File>>(emptyList()) }
var selectedMiscFile by remember { mutableStateOf<File?>(null) }
var miscFileContent by remember { mutableStateOf("") }
// Teleport States
var planets by remember { mutableStateOf<List<PlanetItem>>(emptyList()) }
var selectedPlanetId by remember { mutableStateOf<String?>(null) }
var searchQuery by remember { mutableStateOf("") }
var selectedCategory by remember { mutableStateOf(FilterCategory.ALL) }
// Update States
var installedVersion by remember { mutableStateOf(updateRepo.getInstalledVersion()) }
var updateInfo by remember { mutableStateOf<UpdateInfo?>(null) }
var isCheckingUpdate by remember { mutableStateOf(false) }
var isInstalling by remember { mutableStateOf(false) }
var installProgress by remember { mutableStateOf(0f) }
var installStatusMessage by remember { mutableStateOf("") }
// App Update States (APK)
var includePrerelease by remember { mutableStateOf(AppUpdateManager.isIncludePrerelease(context)) }
var appReleaseInfo by remember { mutableStateOf<AppReleaseInfo?>(null) }
var showAppUpdateDialog by remember { mutableStateOf(false) }
var isCheckingAppUpdate by remember { mutableStateOf(false) }
var isDownloadingApk by remember { mutableStateOf(false) }
var apkDownloadProgress by remember { mutableStateOf(0) }
// Load initial data
LaunchedEffect(Unit) {
planets = SceneRepository.loadScenes(context)
if (planets.isNotEmpty()) {
selectedPlanetId = planets.first().id
}
if (isServerInstalled) {
persistentRepo.ensureValidPersistentJsonSync()
sendHotfix = persistentRepo.getSendHotfix()
serverKey = persistentRepo.getServerKey()
trailblazerConfig = persistentRepo.getTrailblazerConfig()
exRankConfigs = persistentRepo.getExRankConfigs()
val pJson = persistentRepo.readPersistentJson().getOrNull()
pJson?.optJSONObject("scene")?.let {
currentSceneConfig = SceneConfig.fromJsonObject(it)
}
}
// Silent background check for APK updates on launch
scope.launch {
val res = AppUpdateManager.checkForAppUpdate(includePrerelease)
res.onSuccess { info ->
if (info != null) {
appReleaseInfo = info
if (info.isNewer) {
showAppUpdateDialog = true
}
}
}
}
}
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "ppSR & Escape Penacony",
fontWeight = FontWeight.Bold,
color = Color.White,
fontSize = 17.sp
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = RoundedCornerShape(6.dp),
color = when (serverStatus) {
ServerStatus.RUNNING -> CombatGreen.copy(alpha = 0.2f)
ServerStatus.STARTING, ServerStatus.STOPPING -> ExploreAmber.copy(alpha = 0.2f)
ServerStatus.ERROR -> Color.Red.copy(alpha = 0.2f)
ServerStatus.STOPPED -> Color.Gray.copy(alpha = 0.2f)
}
) {
val statusLabel = when (serverStatus) {
ServerStatus.RUNNING -> tr(currentLanguage, "RUNNING", "กำลังทำงาน", "运行中")
ServerStatus.STARTING -> tr(currentLanguage, "STARTING", "กำลังเริ่ม", "启动中")
ServerStatus.STOPPING -> tr(currentLanguage, "STOPPING", "กำลังหยุด", "停止中")
ServerStatus.ERROR -> tr(currentLanguage, "ERROR", "ข้อผิดพลาด", "错误")
ServerStatus.STOPPED -> tr(currentLanguage, "STOPPED", "หยุดทำงาน", "已停止")
}
Text(
text = statusLabel,
color = when (serverStatus) {
ServerStatus.RUNNING -> CombatGreen
ServerStatus.STARTING, ServerStatus.STOPPING -> ExploreAmber
ServerStatus.ERROR -> Color.Red
ServerStatus.STOPPED -> Color.Gray
},
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
},
actions = {
// Language Switcher Pill
TextButton(onClick = {
currentLanguage = when (currentLanguage) {
AppLanguage.TH -> AppLanguage.EN
AppLanguage.EN -> AppLanguage.ZH
AppLanguage.ZH -> AppLanguage.TH
}
}) {
Text(
text = currentLanguage.displayName,
color = PurplePrimary,
fontWeight = FontWeight.SemiBold
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = BgCard)
)
},
bottomBar = {
NavigationBar(containerColor = BgCard) {
ScreenTab.values().forEach { tab ->
NavigationBarItem(
selected = selectedTab == tab,
onClick = { selectedTab = tab },
icon = { Icon(tab.icon, contentDescription = tab.titleEn) },
label = {
Text(tab.localizedTitle(currentLanguage))
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = PurplePrimary,
selectedTextColor = PurplePrimary,
indicatorColor = PurplePrimary.copy(alpha = 0.15f)
)
)
}
}
},
containerColor = BgDark
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (selectedTab) {
ScreenTab.SERVER -> {
ServerControlTab(
context = context,
currentLanguage = currentLanguage,
serverStatus = serverStatus,
isServerInstalled = isServerInstalled,
sendHotfix = sendHotfix,
serverKey = serverKey,
trailblazerConfig = trailblazerConfig,
currentSceneConfig = currentSceneConfig,
serverLogs = serverLogs,
onStartServer = { ServerService.start(context) },
onStopServer = { ServerService.stop(context) },
onRestartServer = { ServerService.restart(context) },
onToggleHotfix = { enabled ->
scope.launch {
persistentRepo.setSendHotfix(enabled)
sendHotfix = enabled
snackbarHostState.showSnackbar("send_hotfix: $enabled")
}
},
onOpenSetPassword = { showPasswordDialog = true },
onOpenEditTrailblazer = { showTrailblazerDialog = true },
onOpenExRank = { showExRankDialog = true },
onOpenMiscExplorer = {
scope.launch {
miscFiles = persistentRepo.listMiscFiles()
showMiscExplorerDialog = true
}
},
onClearLogs = { ServerService.clearLogs() },
onCopyLogs = {
val clip = ClipData.newPlainText("ppSR Logs", serverLogs.joinToString("\n"))
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(clip)
Toast.makeText(context, tr(currentLanguage, "Logs copied to clipboard", "คัดลอกบันทึกแล้ว", "日志已复制"), Toast.LENGTH_SHORT).show()
}
)
}
ScreenTab.TELEPORT -> {
TeleportTab(
context = context,
planets = planets,
selectedPlanetId = selectedPlanetId,
currentLanguage = currentLanguage,
searchQuery = searchQuery,
selectedCategory = selectedCategory,
currentSceneConfig = currentSceneConfig,
onSelectPlanet = { selectedPlanetId = it },
onSearchChange = { searchQuery = it },
onCategoryChange = { selectedCategory = it },
onTeleport = { scene, planetName ->
scope.launch {
val wasRunning = serverStatus == ServerStatus.RUNNING
if (wasRunning) {
snackbarHostState.showSnackbar(tr(currentLanguage, "Pausing server to update scene...", "กำลังพักเซิร์ฟเวอร์เพื่อเปลี่ยนฉาก...", "正在暂停服务器更新场景..."))
ServerService.stop(context)
delay(1000)
}
val cx = scene.calyxX?.toInt() ?: scene.x.toInt()
val cy = scene.calyxY?.toInt() ?: scene.y.toInt()
val cz = scene.calyxZ?.toInt() ?: (scene.z.toInt() + 1800)
val payload = SceneTeleportPayload(
planeId = scene.planeId.toInt(),
x = scene.x.toInt(),
y = scene.y.toInt(),
z = scene.z.toInt(),
calyxX = cx,
calyxY = cy,
calyxZ = cz,
mapLayer = scene.mapLayer,
calyxGroupId = scene.calyxGroupId.toInt(),
calyxInstId = scene.calyxInstId.toInt(),
calyxEntityId = scene.calyxEntityId.toInt(),
calyxPropId = scene.calyxPropId.toInt()
)
val res = persistentRepo.applySceneTeleport(payload)
if (res.isSuccess) {
currentSceneConfig = SceneConfig.fromScene(scene)
snackbarHostState.showSnackbar(tr(currentLanguage, "Teleported to ${scene.localizedName(currentLanguage)}!", "วาร์ปไป ${scene.localizedName(currentLanguage)} สำเร็จ!", "已传送至 ${scene.localizedName(currentLanguage)}!"))
if (wasRunning) {
delay(500)
ServerService.start(context)
}
} else {
snackbarHostState.showSnackbar(tr(currentLanguage, "Failed: ${res.exceptionOrNull()?.message}", "ล้มเหลว: ${res.exceptionOrNull()?.message}", "失败: ${res.exceptionOrNull()?.message}"))
}
}
},
onOpenCustomCoords = { showCustomCoordsDialog = true }
)
}
ScreenTab.UPDATER -> {
UpdaterTab(
context = context,
currentLanguage = currentLanguage,
installedVersion = installedVersion,
updateInfo = updateInfo,
isChecking = isCheckingUpdate,
isInstalling = isInstalling,
installProgress = installProgress,
installMessage = installStatusMessage,
appReleaseInfo = appReleaseInfo,
isCheckingApp = isCheckingAppUpdate,
isDownloadingApk = isDownloadingApk,
apkProgress = apkDownloadProgress,
onCheckUpdate = {
scope.launch {
isCheckingUpdate = true
val res = updateRepo.checkUpdate()
isCheckingUpdate = false
if (res.isSuccess) {
updateInfo = res.getOrNull()
snackbarHostState.showSnackbar(tr(currentLanguage, "Remote latest: ${updateInfo?.latestVersion}", "เวอร์ชันล่าสุดบนเซิร์ฟเวอร์: ${updateInfo?.latestVersion}", "远程最新版本: ${updateInfo?.latestVersion}"))
} else {
snackbarHostState.showSnackbar(tr(currentLanguage, "Check failed: ${res.exceptionOrNull()?.message}", "ตรวจหาไม่สำเร็จ: ${res.exceptionOrNull()?.message}", "检查失败: ${res.exceptionOrNull()?.message}"))
}
}
},
onOpenVersionSelector = {
scope.launch {
isCheckingUpdate = true
val res = updateRepo.fetchAvailableVersions()
isCheckingUpdate = false
if (res.isSuccess) {
val versions = res.getOrNull() ?: emptyList()
val latest = versions.lastOrNull() ?: ""
updateInfo = UpdateInfo(
currentVersion = installedVersion,
latestVersion = latest,
availableVersions = versions,
hasUpdate = installedVersion.isBlank() || installedVersion != latest
)
showVersionSelectorDialog = true
} else {
snackbarHostState.showSnackbar(tr(currentLanguage, "Failed to fetch versions", "ไม่สามารถดึงรายการเวอร์ชันได้", "获取版本列表失败"))
}
}
},
onInstall = { versionToInstall ->
scope.launch {
if (serverStatus == ServerStatus.RUNNING) {
ServerService.stop(context)
delay(1000)
}
isInstalling = true
val res = updateRepo.downloadAndInstall(versionToInstall) { p, msg ->
installProgress = p
installStatusMessage = msg
}
isInstalling = false
if (res.isSuccess) {
installedVersion = versionToInstall
isServerInstalled = true
// Refresh updateInfo state
updateInfo?.let { prev ->
updateInfo = prev.copy(
currentVersion = versionToInstall,
hasUpdate = versionToInstall != prev.latestVersion
)
}
snackbarHostState.showSnackbar(tr(currentLanguage, "Server v$versionToInstall installed successfully!", "ติดตั้งเซิร์ฟเวอร์ v$versionToInstall สำเร็จแล้ว!", "服务器 v$versionToInstall 安装成功!"))
} else {
snackbarHostState.showSnackbar(tr(currentLanguage, "Install error: ${res.exceptionOrNull()?.message}", "ติดตั้งล้มเหลว: ${res.exceptionOrNull()?.message}", "安装错误: ${res.exceptionOrNull()?.message}"))
}
}
},
includePrerelease = includePrerelease,
onTogglePrerelease = { newVal ->
includePrerelease = newVal
AppUpdateManager.setIncludePrerelease(context, newVal)
scope.launch {
isCheckingAppUpdate = true
val res = AppUpdateManager.checkForAppUpdate(newVal)
isCheckingAppUpdate = false
res.onSuccess { info ->
appReleaseInfo = info
}
}
},
onCheckAppUpdate = {
scope.launch {
isCheckingAppUpdate = true
val res = AppUpdateManager.checkForAppUpdate(includePrerelease)
isCheckingAppUpdate = false
res.onSuccess { info ->
appReleaseInfo = info
if (info != null && info.isNewer) {
showAppUpdateDialog = true
} else {
snackbarHostState.showSnackbar(
tr(currentLanguage, "App is already up to date (${BuildConfig.VERSION_NAME})", "แอปพลิเคชันเป็นเวอร์ชันล่าสุดแล้ว (${BuildConfig.VERSION_NAME})", "应用程序已是最新版本 (${BuildConfig.VERSION_NAME})")
)
}
}.onFailure {
snackbarHostState.showSnackbar(
tr(currentLanguage, "Check failed: ${it.message}", "ตรวจหาไม่สำเร็จ: ${it.message}", "检查失败: ${it.message}")
)
}
}
},
onDownloadApk = {
appReleaseInfo?.apkDownloadUrl?.let { url ->
scope.launch {
isDownloadingApk = true
val res = AppUpdateManager.downloadApk(context, url) { p ->
apkDownloadProgress = p
}
isDownloadingApk = false
res.onSuccess { apkFile ->
AppUpdateManager.promptInstallApk(context, apkFile)
}.onFailure {
snackbarHostState.showSnackbar(
tr(currentLanguage, "Download failed: ${it.message}", "ดาวน์โหลดไม่สำเร็จ: ${it.message}", "下载失败: ${it.message}")
)
}
}
}
}
)
}
}
}
}
// --- Dialogs with Full Localization ---
// Password Dialog
if (showPasswordDialog) {
var newKey by remember { mutableStateOf(serverKey) }
AlertDialog(
onDismissRequest = { showPasswordDialog = false },
title = { Text(tr(currentLanguage, "Set Server Password / Key", "ตั้งรหัสผ่าน / คีย์เซิร์ฟเวอร์", "设置服务器密码/密钥")) },
text = {
Column {
Text(
tr(currentLanguage, "Leave blank if no password is required by your client.", "ปล่อยว่างไว้หากตัวเกมไคลเอนต์ไม่ต้องการรหัสผ่าน", "如果客户端不需要密码,请留空。"),
fontSize = 13.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = newKey,
onValueChange = { newKey = it },
label = { Text(tr(currentLanguage, "Key / Password", "คีย์ / รหัสผ่าน", "密钥 / 密码")) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(onClick = {
scope.launch {
persistentRepo.setServerKey(newKey)
serverKey = newKey
showPasswordDialog = false
snackbarHostState.showSnackbar(tr(currentLanguage, "Password updated", "บันทึกรหัสผ่านแล้ว", "密码已更新"))
}
}) {
Text(tr(currentLanguage, "Save", "บันทึก", "保存"))
}
},
dismissButton = {
TextButton(onClick = { showPasswordDialog = false }) {
Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消"))
}
}
)
}
// Trailblazer Dialog
if (showTrailblazerDialog) {
var gender by remember { mutableStateOf(trailblazerConfig.gender) }
var tbPath by remember { mutableStateOf(trailblazerConfig.tbPath) }
var marchPath by remember { mutableStateOf(trailblazerConfig.marchPath) }
val paths = listOf("Destruction", "Preservation", "Harmony", "Remembrance", "Elation")
val marchPaths = listOf("Preservation", "Hunt")
AlertDialog(
onDismissRequest = { showTrailblazerDialog = false },
title = { Text(tr(currentLanguage, "Edit Trailblazer (ตัวละคร)", "แก้ไขตัวละครผู้บุกเบิก (Trailblazer)", "编辑开拓者角色")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(tr(currentLanguage, "Trailblazer Gender:", "เพศตัวเอก:", "开拓者性别:"), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = gender == "Female",
onClick = { gender = "Female" },
label = { Text(tr(currentLanguage, "Female (Stelle)", "หญิง (Stelle)", "女性 (星)")) }
)
FilterChip(
selected = gender == "Male",
onClick = { gender = "Male" },
label = { Text(tr(currentLanguage, "Male (Caelus)", "ชาย (Caelus)", "男性 (穹)")) }
)
}
Text(tr(currentLanguage, "Trailblazer Path:", "Path ของตัวเอก:", "开拓者命途:"), fontWeight = FontWeight.Bold, fontSize = 13.sp)
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(paths) { p ->
FilterChip(
selected = tbPath == p,
onClick = { tbPath = p },
label = { Text(p) }
)
}
}
Text(tr(currentLanguage, "March 7th Path:", "Path ของ March 7th:", "三月七命途:"), fontWeight = FontWeight.Bold, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
marchPaths.forEach { mp ->
FilterChip(
selected = marchPath == mp,
onClick = { marchPath = mp },
label = { Text(mp) }
)
}
}
}
},
confirmButton = {
Button(onClick = {
scope.launch {
val newCfg = TrailblazerConfig(gender, tbPath, marchPath)
persistentRepo.setTrailblazerConfig(newCfg)
trailblazerConfig = newCfg
showTrailblazerDialog = false
snackbarHostState.showSnackbar(tr(currentLanguage, "Trailblazer profile saved", "บันทึกข้อมูลตัวละครแล้ว", "主角配置已保存"))
}
}) {
Text(tr(currentLanguage, "Save", "บันทึก", "保存"))
}
},
dismissButton = {
TextButton(onClick = { showTrailblazerDialog = false }) {
Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消"))
}
}
)
}
// EX Eidolon (3 Ex Rank) Dialog
if (showExRankDialog) {
var currentConfigs by remember { mutableStateOf(exRankConfigs) }
AlertDialog(
onDismissRequest = { showExRankDialog = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = ExploreAmber,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
tr(
currentLanguage,
"EX Eidolon (3 EX Rank)",
"ปรับแต่ง EX Eidolon (3 EX Rank)",
"EX星魂强化设置 (3 EX Rank)"
),
fontWeight = FontWeight.Bold,
fontSize = 17.sp
)
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
tr(
currentLanguage,
"Toggle 3 EX Eidolon skill upgrades for special characters in ppSR:",
"เปิด/ปิดระบบ 3 EX Eidolon ปลดล็อกสกิลพิเศษสำหรับตัวละครใน ppSR:",
"为ppSR特殊角色开启/关闭3个EX星魂技能强化:"
),
fontSize = 12.sp,
color = Color.LightGray
)
currentConfigs.forEachIndexed { index, cfg ->
Card(
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
val displayName = when (cfg.avatarId) {
1204 -> tr(currentLanguage, "JingYuan (General)", "JingYuan (จิ่งหยวน)", "景元")
1213 -> tr(currentLanguage, "DanHeng IL (Imbibitor Lunae)", "DanHeng IL (จ้าวยลจันทรา)", "丹恒·饮月")
1308 -> tr(currentLanguage, "Acheron (Galaxy Ranger)", "Acheron (อาเคอรอน)", "黄泉")
else -> cfg.name
}
Text(displayName, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Text(
"Avatar ID: ${cfg.avatarId} • ${if (cfg.active) tr(currentLanguage, "Active", "เปิดใช้งาน", "已启用") else tr(currentLanguage, "Disabled", "ปิดใช้งาน", "已禁用")}",
fontSize = 11.sp,
color = if (cfg.active) CombatGreen else Color.Gray
)
}
Switch(
checked = cfg.active,
onCheckedChange = { isActive ->
currentConfigs = currentConfigs.toMutableList().also {
it[index] = it[index].copy(active = isActive)
}
}
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = {
currentConfigs = currentConfigs.map { it.copy(active = true) }
},
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 4.dp)
) {
Text(tr(currentLanguage, "Enable All", "เปิดทั้งหมด", "全部开启"), fontSize = 11.sp)
}
OutlinedButton(
onClick = {
currentConfigs = currentConfigs.map { it.copy(active = false) }
},
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 4.dp)
) {
Text(tr(currentLanguage, "Disable All", "ปิดทั้งหมด", "全部关闭"), fontSize = 11.sp)
}
}
}
},
confirmButton = {
Button(onClick = {
scope.launch {
persistentRepo.setExRankConfigs(currentConfigs)
exRankConfigs = currentConfigs
showExRankDialog = false
snackbarHostState.showSnackbar(
tr(
currentLanguage,
"EX Eidolon configurations saved",
"บันทึกการตั้งค่า EX Eidolon สำเร็จ",
"EX星魂配置已保存"
)
)
}
}) {
Text(tr(currentLanguage, "Save", "บันทึก", "保存"))
}
},
dismissButton = {
TextButton(onClick = { showExRankDialog = false }) {
Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消"))
}
}
)
}
// Misc Explorer & Editor Dialog
if (showMiscExplorerDialog) {
AlertDialog(
onDismissRequest = {
showMiscExplorerDialog = false
selectedMiscFile = null
},
title = {
Text(
if (selectedMiscFile == null)
tr(currentLanguage, "Misc Files Explorer", "จัดการไฟล์ในโฟลเดอร์ Misc", "杂项文件管理器")
else
"${tr(currentLanguage, "Edit", "แก้ไข", "编辑")}: ${selectedMiscFile?.name}"
)
},
text = {
if (selectedMiscFile == null) {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 350.dp)) {
items(miscFiles) { file ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable {
scope.launch {
selectedMiscFile = file
miscFileContent = persistentRepo.readMiscFile(file.name).getOrDefault("")
}
},
colors = CardDefaults.cardColors(containerColor = BgCardLight)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.AutoMirrored.Filled.InsertDriveFile, contentDescription = null, tint = PurplePrimary)
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(file.name, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Text("${file.length()} bytes", fontSize = 11.sp, color = Color.Gray)
}
}
}
}
}
} else {
OutlinedTextField(
value = miscFileContent,
onValueChange = { miscFileContent = it },
modifier = Modifier
.fillMaxWidth()
.height(350.dp),
textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp)
)
}
},
confirmButton = {
if (selectedMiscFile != null) {
Button(onClick = {
scope.launch {
selectedMiscFile?.let { file ->
persistentRepo.writeMiscFile(file.name, miscFileContent)
snackbarHostState.showSnackbar("${file.name} ${tr(currentLanguage, "saved successfully", "บันทึกสำเร็จ", "保存成功")}")
selectedMiscFile = null
miscFiles = persistentRepo.listMiscFiles()
}
}
}) {
Text(tr(currentLanguage, "Save File", "บันทึกไฟล์", "保存文件"))
}
} else {
TextButton(onClick = { showMiscExplorerDialog = false }) {
Text(tr(currentLanguage, "Close", "ปิด", "关闭"))
}
}
},
dismissButton = {
if (selectedMiscFile != null) {
TextButton(onClick = { selectedMiscFile = null }) {
Text(tr(currentLanguage, "Back to list", "ย้อนกลับ", "返回列表"))
}
}
}
)
}
// Version Selector Dialog
if (showVersionSelectorDialog) {
val versions = updateInfo?.availableVersions ?: emptyList()
AlertDialog(
onDismissRequest = { showVersionSelectorDialog = false },
title = { Text(tr(currentLanguage, "Select ppSR Server Version", "เลือกเวอร์ชันเซิร์ฟเวอร์ ppSR", "选择 ppSR 服务器版本")) },
text = {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp)) {
items(versions.reversed()) { ver ->
val isInstalled = ver == installedVersion
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showVersionSelectorDialog = false
scope.launch {
if (serverStatus == ServerStatus.RUNNING) {
ServerService.stop(context)
delay(1000)
}
isInstalling = true
val res = updateRepo.downloadAndInstall(ver) { p, msg ->
installProgress = p
installStatusMessage = msg
}
isInstalling = false
if (res.isSuccess) {
installedVersion = ver
isServerInstalled = true
updateInfo?.let { prev ->
updateInfo = prev.copy(
currentVersion = ver,
hasUpdate = ver != prev.latestVersion
)
}
snackbarHostState.showSnackbar("v$ver ${tr(currentLanguage, "installed successfully!", "ติดตั้งสำเร็จแล้ว!", "安装成功!")}")
} else {
snackbarHostState.showSnackbar("${tr(currentLanguage, "Failed", "ล้มเหลว", "失败")}: ${res.exceptionOrNull()?.message}")
}
}
}
.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = ver,
fontWeight = if (isInstalled) FontWeight.Bold else FontWeight.Normal,
color = if (isInstalled) PurplePrimary else Color.White,
modifier = Modifier.weight(1f)
)
if (isInstalled) {
Text(
"(${tr(currentLanguage, "Active", "กำลังใช้งาน", "当前使用")})",
fontSize = 12.sp,
color = CombatGreen
)
}
}
HorizontalDivider(color = Color.DarkGray)
}
}
},
confirmButton = {
TextButton(onClick = { showVersionSelectorDialog = false }) {
Text(tr(currentLanguage, "Close", "ปิด", "关闭"))
}
}
)
}
// App Update Dialog (APK)
if (showAppUpdateDialog && appReleaseInfo != null) {
val release = appReleaseInfo!!
AlertDialog(
onDismissRequest = { if (!isDownloadingApk) showAppUpdateDialog = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
tr(currentLanguage, "New App Update", "พบอัปเดตแอปใหม่", "发现新版本应用"),
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
if (release.isPrerelease) {
Surface(
shape = RoundedCornerShape(4.dp),
color = ExploreAmber.copy(alpha = 0.2f)
) {
Text(
text = "BETA",
color = ExploreAmber,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
} else {
Surface(
shape = RoundedCornerShape(4.dp),
color = CombatGreen.copy(alpha = 0.2f)
) {
Text(
text = "STABLE",
color = CombatGreen,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
},
text = {
Column {
Text(
tr(currentLanguage, "Current: ${BuildConfig.VERSION_NAME} -> Latest: ${release.tagName}", "เวอร์ชันปัจจุบัน: ${BuildConfig.VERSION_NAME} -> ล่าสุด: ${release.tagName}", "当前版本: ${BuildConfig.VERSION_NAME} -> 最新: ${release.tagName}"),
fontSize = 13.sp,
color = Color.LightGray
)
if (release.changelog.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
release.changelog,
fontSize = 12.sp,
color = Color.Gray,
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
if (isDownloadingApk) {
Spacer(modifier = Modifier.height(12.dp))
LinearProgressIndicator(
progress = { apkDownloadProgress / 100f },
modifier = Modifier.fillMaxWidth(),
color = PurplePrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
tr(currentLanguage, "Downloading APK: $apkDownloadProgress%", "กำลังดาวน์โหลดไฟล์ APK: $apkDownloadProgress%", "正在下载安装包: $apkDownloadProgress%"),
fontSize = 11.sp,
color = Color.Gray
)
}
}
},
confirmButton = {
Button(
onClick = {
if (!isDownloadingApk && release.apkDownloadUrl.isNotBlank()) {
scope.launch {
isDownloadingApk = true
val res = AppUpdateManager.downloadApk(context, release.apkDownloadUrl) { p ->
apkDownloadProgress = p
}
isDownloadingApk = false
res.onSuccess { apkFile ->
showAppUpdateDialog = false
AppUpdateManager.promptInstallApk(context, apkFile)
}.onFailure {
snackbarHostState.showSnackbar(
tr(currentLanguage, "Download failed: ${it.message}", "ดาวน์โหลดไม่สำเร็จ: ${it.message}", "下载失败: ${it.message}")
)
}
}
}
},
enabled = !isDownloadingApk && release.apkDownloadUrl.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = CombatGreen)
) {
Text(tr(currentLanguage, "Update Now", "อัปเดตทันที", "立即更新"))
}
},
dismissButton = {
if (!isDownloadingApk) {
TextButton(onClick = { showAppUpdateDialog = false }) {
Text(tr(currentLanguage, "Later", "ไว้ภายหลัง", "稍后"))
}
}
}
)
}
// Custom Coordinates Dialog
if (showCustomCoordsDialog) {
var planeIdStr by remember { mutableStateOf(currentSceneConfig?.planeId?.toString() ?: "20101") }
var xStr by remember { mutableStateOf(currentSceneConfig?.x?.toString() ?: "0") }
var yStr by remember { mutableStateOf(currentSceneConfig?.y?.toString() ?: "0") }
var zStr by remember { mutableStateOf(currentSceneConfig?.z?.toString() ?: "0") }
var layerStr by remember { mutableStateOf(currentSceneConfig?.mapLayer?.toString() ?: "1") }
AlertDialog(
onDismissRequest = { showCustomCoordsDialog = false },
title = { Text(tr(currentLanguage, "Custom Coordinates", "กำหนดพิกัดเอง (Custom Coordinates)", "自定义坐标")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = planeIdStr,
onValueChange = { planeIdStr = it },
label = { Text("Plane ID") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedTextField(
value = xStr,
onValueChange = { xStr = it },
label = { Text("X") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = yStr,
onValueChange = { yStr = it },
label = { Text("Y") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = zStr,
onValueChange = { zStr = it },
label = { Text("Z") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = layerStr,
onValueChange = { layerStr = it },
label = { Text(tr(currentLanguage, "Map Layer (1, 2, 3...)", "ชั้นของแผนที่ (Map Layer)", "地图层级 (1, 2, 3...)")) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(onClick = {
val pId = planeIdStr.toIntOrNull() ?: 20101
val x = xStr.toIntOrNull() ?: 0
val y = yStr.toIntOrNull() ?: 0
val z = zStr.toIntOrNull() ?: 0
val layer = layerStr.toIntOrNull() ?: 1
scope.launch {
val wasRunning = serverStatus == ServerStatus.RUNNING
if (wasRunning) {
ServerService.stop(context)
delay(1000)
}
val payload = SceneTeleportPayload(
planeId = pId,
x = x,
y = y,
z = z,
calyxX = x,
calyxY = y,
calyxZ = z + 1800,
mapLayer = layer
)
val res = persistentRepo.applySceneTeleport(payload)
showCustomCoordsDialog = false
if (res.isSuccess) {
currentSceneConfig = SceneConfig(
planeId = pId.toLong(),
x = x.toLong(),
y = y.toLong(),
z = z.toLong(),
mapLayer = layer
)
snackbarHostState.showSnackbar(tr(currentLanguage, "Custom scene coordinates applied!", "บันทึกพิกัดฉากเรียบร้อยแล้ว!", "已应用自定义场景坐标!"))
if (wasRunning) {
delay(500)
ServerService.start(context)
}
}
}
}) {
Text(tr(currentLanguage, "Apply", "นำไปใช้", "应用"))
}
},
dismissButton = {
TextButton(onClick = { showCustomCoordsDialog = false }) {
Text(tr(currentLanguage, "Cancel", "ยกเลิก", "取消"))
}
}
)
}
}
@Composable
fun ServerControlTab(
context: Context,
currentLanguage: AppLanguage,
serverStatus: ServerStatus,
isServerInstalled: Boolean,
sendHotfix: Boolean,
serverKey: String,
trailblazerConfig: TrailblazerConfig,
currentSceneConfig: SceneConfig?,
serverLogs: List<String>,
onStartServer: () -> Unit,
onStopServer: () -> Unit,
onRestartServer: () -> Unit,
onToggleHotfix: (Boolean) -> Unit,
onOpenSetPassword: () -> Unit,
onOpenEditTrailblazer: () -> Unit,
onOpenExRank: () -> Unit,
onOpenMiscExplorer: () -> Unit,
onClearLogs: () -> Unit,
onCopyLogs: () -> Unit
) {
val listState = rememberLazyListState()
// Auto-scroll logs
LaunchedEffect(serverLogs.size) {
if (serverLogs.isNotEmpty()) {
listState.animateScrollToItem(serverLogs.size - 1)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Status & Control Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
tr(currentLanguage, "Server Status", "สถานะเซิร์ฟเวอร์", "服务器状态"),
fontSize = 12.sp,
color = Color.Gray
)
Text(
text = when (serverStatus) {
ServerStatus.RUNNING -> tr(currentLanguage, "🟢 Running (Online)", "🟢 กำลังทำงาน (ออนไลน์)", "🟢 运行中 (在线)")
ServerStatus.STARTING -> tr(currentLanguage, "🟡 Starting...", "🟡 กำลังเริ่มเซิร์ฟเวอร์...", "🟡 正在启动...")
ServerStatus.STOPPING -> tr(currentLanguage, "🟠 Stopping...", "🟠 กำลังหยุดเซิร์ฟเวอร์...", "🟠 正在停止...")
ServerStatus.ERROR -> tr(currentLanguage, "🔴 Error", "🔴 ข้อผิดพลาด", "🔴 错误")
ServerStatus.STOPPED -> tr(currentLanguage, "⚪ Stopped (Offline)", "⚪ ปิดอยู่ (ออฟไลน์)", "⚪ 已停止 (离线)")
},
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
color = Color.White
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (serverStatus == ServerStatus.RUNNING) {
Button(
onClick = onStopServer,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC62828))
) {
Icon(Icons.Default.Stop, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Stop", "หยุด", "停止"))
}
IconButton(onClick = onRestartServer) {
Icon(Icons.Default.Refresh, contentDescription = "Restart", tint = PurplePrimary)
}
} else {
Button(
onClick = onStartServer,
enabled = isServerInstalled && serverStatus != ServerStatus.STARTING,
colors = ButtonDefaults.buttonColors(containerColor = CombatGreen)
) {
Icon(Icons.Default.PlayArrow, contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Start", "เริ่ม", "启动"))
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
HorizontalDivider(color = Color.DarkGray.copy(alpha = 0.5f))
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Dispatch: 127.0.0.1:21000", fontSize = 12.sp, color = Color.LightGray)
Text("Forward: 127.0.0.1:23301", fontSize = 12.sp, color = Color.LightGray)
}
}
}
// Quick Tools Row (Buttons from server)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(14.dp)) {
Text(
tr(currentLanguage, "Server Controls & Configuration", "การควบคุม & ตั้งค่าเซิร์ฟเวอร์", "服务器控制与设置"),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.White
)
Spacer(modifier = Modifier.height(10.dp))
// Tools Grid Row 1: Password & Trailblazer
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onOpenSetPassword,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp)
) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(
if (serverKey.isBlank()) tr(currentLanguage, "Password", "รหัสผ่าน", "密码")
else tr(currentLanguage, "Key Set", "มีรหัสแล้ว", "已设密码"),
fontSize = 12.sp,
maxLines = 1
)
}
OutlinedButton(
onClick = onOpenEditTrailblazer,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp)
) {
Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Trailblazer", "ตัวละคร", "主角"), fontSize = 12.sp, maxLines = 1)
}
}
Spacer(modifier = Modifier.height(6.dp))
// Tools Grid Row 2: EX Eidolon & Misc Files
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onOpenExRank,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp)
) {
Icon(Icons.Default.Star, contentDescription = null, tint = ExploreAmber, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "EX Eidolon", "EX Eidolon", "EX星魂"), fontSize = 12.sp, maxLines = 1)
}
OutlinedButton(
onClick = onOpenMiscExplorer,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 6.dp)
) {
Icon(Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Misc Files", "ไฟล์ Misc", "杂项文件"), fontSize = 12.sp, maxLines = 1)
}
}
Spacer(modifier = Modifier.height(10.dp))
// Hotfix toggle row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
tr(currentLanguage, "Send Hotfix (Asset Patching)", "Send Hotfix (โหมดดาวน์โหลดแพตช์)", "Send Hotfix (补丁模式)"),
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold
)
Text(
if (sendHotfix)
tr(currentLanguage, "Enabled: Online hoyoverse assets", "เปิด: ต่อเน็ตโหลด Asset จาก Hoyoverse", "开启: 在线获取官方资源")
else
tr(currentLanguage, "Disabled: 100% Offline (fixes black screen)", "ปิด: ออฟไลน์ 100% (แก้ปัญหาจอดำ)", "关闭: 100% 离线 (修复黑屏)"),
fontSize = 11.sp,
color = if (sendHotfix) ExploreAmber else CombatGreen
)
}
Switch(
checked = sendHotfix,
onCheckedChange = onToggleHotfix
)
}
}
}
// Live Console Card
Card(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
colors = CardDefaults.cardColors(containerColor = Color(0xFF0F1117)),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (serverStatus == ServerStatus.RUNNING) CombatGreen else Color.Gray)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
tr(currentLanguage, "Console Logs", "บันทึกคอนโซล (Console Logs)", "控制台日志"),
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = Color.LightGray
)
}
Row {
IconButton(onClick = onCopyLogs) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", tint = Color.Gray, modifier = Modifier.size(18.dp))
}
IconButton(onClick = onClearLogs) {
Icon(Icons.Default.Delete, contentDescription = "Clear", tint = Color.Gray, modifier = Modifier.size(18.dp))
}
}
}
HorizontalDivider(color = Color.DarkGray.copy(alpha = 0.5f))
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(top = 8.dp)
) {
if (serverLogs.isEmpty()) {
item {
Text(
tr(currentLanguage, "Server logs will appear here when started...", "บันทึกการทำงานของเซิร์ฟเวอร์จะแสดงที่นี่เมื่อเริ่มทำงาน...", "服务器启动后日志将显示在此处..."),
color = Color.DarkGray,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
} else {
items(serverLogs) { line ->
Text(
text = line,
color = when {
line.contains("[Error]") || line.contains("Exception") -> Color(0xFFFF5252)
line.contains("[Warning]") -> ExploreAmber
line.contains("[System]") || line.contains("[Patch]") -> Color(0xFF81D4FA)
line.contains("Successfully") || line.contains("Ready") -> CombatGreen
else -> Color(0xFFCFD8DC)
},
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
}
}
@Composable
fun TeleportTab(
context: Context,
planets: List<PlanetItem>,
selectedPlanetId: String?,
currentLanguage: AppLanguage,
searchQuery: String,
selectedCategory: FilterCategory,
currentSceneConfig: SceneConfig?,
onSelectPlanet: (String) -> Unit,
onSearchChange: (String) -> Unit,
onCategoryChange: (FilterCategory) -> Unit,
onTeleport: (SceneItem, String) -> Unit,
onOpenCustomCoords: () -> Unit
) {
val activePlanet = planets.find { it.id == selectedPlanetId }
val filteredScenes = remember(activePlanet, searchQuery, selectedCategory, currentLanguage) {
val baseScenes = activePlanet?.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 = searchQuery.isBlank() ||
scene.localizedName(currentLanguage).contains(searchQuery, ignoreCase = true) ||
scene.planeId.toString().contains(searchQuery)
matchesCategory && matchesSearch
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp)
) {
// Active Map Banner
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
tr(currentLanguage, "Current Teleport Destination", "พิกัดปลายทางปัจจุบัน", "当前传送目的地"),
fontSize = 11.sp,
color = Color.Gray
)
Text(
text = "Plane: ${currentSceneConfig?.planeId ?: tr(currentLanguage, "Unknown", "ไม่ระบุ", "未知")} (Layer ${currentSceneConfig?.mapLayer ?: 1})",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = PurplePrimary
)
Text(
text = "X: ${currentSceneConfig?.x ?: 0} | Y: ${currentSceneConfig?.y ?: 0} | Z: ${currentSceneConfig?.z ?: 0}",
fontSize = 11.sp,
color = Color.LightGray
)
}
Button(
onClick = onOpenCustomCoords,
colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary)
) {
Icon(Icons.Default.EditLocation, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Custom", "พิกัดเอง", "自定义"), fontSize = 12.sp)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
// Search Bar
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChange,
placeholder = { Text(tr(currentLanguage, "Search map name or plane ID...", "ค้นหาชื่อฉากหรือ Plane ID...", "搜索场景名称或Plane ID...")) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(10.dp))
// Planet Chips Row
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(planets) { planet ->
FilterChip(
selected = planet.id == selectedPlanetId,
onClick = { onSelectPlanet(planet.id) },
label = { Text(planet.localizedName(currentLanguage)) }
)
}
}
Spacer(modifier = Modifier.height(6.dp))
// Category Filter Row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterCategory.values().forEach { cat ->
val label = when (cat) {
FilterCategory.ALL -> tr(currentLanguage, "All", "ทั้งหมด", "全部")
FilterCategory.COMBAT -> tr(currentLanguage, "Combat", "ต่อสู้", "战斗")
FilterCategory.EXPLORE -> tr(currentLanguage, "Explore", "สำรวจ", "探索")
FilterCategory.BETA -> tr(currentLanguage, "Beta", "เบต้า", "测试")
}
FilterChip(
selected = selectedCategory == cat,
onClick = { onCategoryChange(cat) },
label = { Text(label) }
)
}
}
Spacer(modifier = Modifier.height(10.dp))
// Scene List
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredScenes) { scene ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = scene.localizedName(currentLanguage),
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
if (scene.combatReady) {
Surface(
shape = RoundedCornerShape(4.dp),
color = CombatGreen.copy(alpha = 0.2f)
) {
Text(
tr(currentLanguage, "Combat", "ต่อสู้", "战斗"),
color = CombatGreen,
fontSize = 10.sp,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
)
}
}
if (scene.isBeta) {
Spacer(modifier = Modifier.width(4.dp))
Surface(
shape = RoundedCornerShape(4.dp),
color = BetaPurple.copy(alpha = 0.2f)
) {
Text("Beta", color = BetaPurple, fontSize = 10.sp, modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp))
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Plane: ${scene.planeId} | Layer: ${scene.mapLayer} | Coords: (${scene.x}, ${scene.y}, ${scene.z})",
fontSize = 11.sp,
color = Color.Gray
)
if (scene.localizedDesc(currentLanguage).isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = scene.localizedDesc(currentLanguage),
fontSize = 12.sp,
color = Color.LightGray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { onTeleport(scene, activePlanet?.localizedName(currentLanguage) ?: "") },
colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary)
) {
Text(tr(currentLanguage, "Warp", "วาร์ป", "传送"))
}
}
}
}
}
}
}
@Composable
fun UpdaterTab(
context: Context,
currentLanguage: AppLanguage,
installedVersion: String,
updateInfo: UpdateInfo?,
isChecking: Boolean,
isInstalling: Boolean,
installProgress: Float,
installMessage: String,
appReleaseInfo: AppReleaseInfo?,
isCheckingApp: Boolean,
isDownloadingApk: Boolean,
apkProgress: Int,
includePrerelease: Boolean,
onTogglePrerelease: (Boolean) -> Unit,
onCheckUpdate: () -> Unit,
onOpenVersionSelector: () -> Unit,
onInstall: (String) -> Unit,
onCheckAppUpdate: () -> Unit,
onDownloadApk: () -> Unit
) {
val latestVersion = updateInfo?.latestVersion ?: ""
val isInstalled = installedVersion.isNotBlank()
val isUpToDate = isInstalled && latestVersion.isNotBlank() && installedVersion == latestVersion
val hasNewVersion = isInstalled && latestVersion.isNotBlank() && installedVersion != latestVersion
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
// --- 1. App Updater Card (APK from GitHub) ---
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
tr(currentLanguage, "Escape Penacony Standalone", "แอปพลิเคชัน Escape Penacony Standalone", "Escape Penacony Standalone 客户端"),
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
tr(currentLanguage, "Application APK updater via GitHub Releases", "ตรวจหาและอัปเดตไฟล์ APK จาก GitHub Releases", "通过 GitHub Releases 更新 APK 应用程序"),
fontSize = 12.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.height(12.dp))
// Update Channel Selector (Stable vs Pre-release / Beta)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
tr(currentLanguage, "Channel:", "ช่องทาง:", "渠道:"),
fontSize = 12.sp,
color = Color.Gray
)
FilterChip(
selected = !includePrerelease,
onClick = {
if (includePrerelease) {
onTogglePrerelease(false)
}
},
label = {
Text(
tr(currentLanguage, "Latest (Stable)", "เสถียร (ล่าสุด)", "稳定版 (最新)"),
fontSize = 11.sp
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = PurplePrimary,
selectedLabelColor = Color.White
)
)
FilterChip(
selected = includePrerelease,
onClick = {
if (!includePrerelease) {
onTogglePrerelease(true)
}
},
label = {
Text(
tr(currentLanguage, "Pre-release (Beta)", "ทดสอบ (เบต้า)", "预发布 (测试版)"),
fontSize = 11.sp
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = ExploreAmber,
selectedLabelColor = Color.Black
)
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
tr(currentLanguage, "Current App Version", "เวอร์ชันแอปปัจจุบัน", "当前应用版本"),
fontSize = 12.sp,
color = Color.Gray
)
Text(
text = BuildConfig.VERSION_NAME,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color.White
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
tr(currentLanguage, "Latest on GitHub", "เวอร์ชันล่าสุดบน GitHub", "GitHub 最新版本"),
fontSize = 12.sp,
color = Color.Gray
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (appReleaseInfo != null) {
if (appReleaseInfo.isPrerelease) {
Surface(
shape = RoundedCornerShape(4.dp),
color = ExploreAmber.copy(alpha = 0.2f),
modifier = Modifier.padding(end = 4.dp)
) {
Text(
text = "BETA",
color = ExploreAmber,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
} else {
Surface(
shape = RoundedCornerShape(4.dp),
color = CombatGreen.copy(alpha = 0.2f),
modifier = Modifier.padding(end = 4.dp)
) {
Text(
text = "STABLE",
color = CombatGreen,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp)
)
}
}
}
Text(
text = appReleaseInfo?.tagName ?: tr(currentLanguage, "Tap check to query", "แตะตรวจหาเพื่อดูเวอร์ชัน", "点击检查获取"),
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = PurplePrimary
)
}
}
}
Spacer(modifier = Modifier.height(14.dp))
if (isDownloadingApk) {
Column(modifier = Modifier.fillMaxWidth()) {
LinearProgressIndicator(
progress = { apkProgress / 100f },
modifier = Modifier.fillMaxWidth(),
color = PurplePrimary
)
Spacer(modifier = Modifier.height(6.dp))
Text(
tr(currentLanguage, "Downloading APK: $apkProgress%", "กำลังดาวน์โหลดไฟล์ APK: $apkProgress%", "正在下载安装包: $apkProgress%"),
fontSize = 12.sp,
color = Color.LightGray
)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = onCheckAppUpdate,
enabled = !isCheckingApp,
modifier = Modifier.weight(1f)
) {
if (isCheckingApp) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
} else {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Check App Update", "ตรวจหาอัปเดตแอป", "检查应用更新"))
}
}
if (appReleaseInfo != null && appReleaseInfo.isNewer) {
Button(
onClick = onDownloadApk,
colors = ButtonDefaults.buttonColors(containerColor = CombatGreen),
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Update App", "อัปเดตแอป", "更新应用"))
}
}
}
if (appReleaseInfo != null && !appReleaseInfo.isNewer) {
Spacer(modifier = Modifier.height(10.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = CombatGreen.copy(alpha = 0.15f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = CombatGreen, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tr(currentLanguage, "Application is up to date (${BuildConfig.VERSION_NAME})", "แอปพลิเคชันเป็นเวอร์ชันล่าสุดแล้ว (${BuildConfig.VERSION_NAME})", "应用程序已是最新版本 (${BuildConfig.VERSION_NAME})"),
color = CombatGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
// --- 2. ppSR Server Core Card ---
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
tr(currentLanguage, "ppSR Direct Updater", "ตัวอัปเดตเซิร์ฟเวอร์ ppSR โดยตรง", "ppSR 直接更新器"),
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color.White
)
Text(
tr(currentLanguage, "Direct synchronization from official ppSR endpoint", "ซิงค์ข้อมูลจากเซิร์ฟเวอร์ ppSR (File Garden) โดยตรง", "直接从 ppSR 官方端点同步数据"),
fontSize = 12.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
tr(currentLanguage, "Installed Version", "เวอร์ชันที่ติดตั้งในเครื่อง", "已安装版本"),
fontSize = 12.sp,
color = Color.Gray
)
Text(
text = if (installedVersion.isBlank()) tr(currentLanguage, "None (Not Installed)", "ยังไม่ได้ติดตั้ง", "未安装") else installedVersion,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = if (installedVersion.isBlank()) Color.Red else Color.White
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
tr(currentLanguage, "Remote Latest", "เวอร์ชันล่าสุดบนเซิร์ฟเวอร์", "最新远程版本"),
fontSize = 12.sp,
color = Color.Gray
)
Text(
text = if (latestVersion.isBlank()) tr(currentLanguage, "Tap check to fetch", "แตะปุ่มเพื่อตรวจหา", "点击检查获取") else latestVersion,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = PurplePrimary
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (isInstalling) {
Column(modifier = Modifier.fillMaxWidth()) {
LinearProgressIndicator(
progress = { installProgress },
modifier = Modifier.fillMaxWidth(),
color = PurplePrimary
)
Spacer(modifier = Modifier.height(6.dp))
Text(installMessage, fontSize = 12.sp, color = Color.LightGray)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Check Updates Button
Button(
onClick = onCheckUpdate,
enabled = !isChecking,
modifier = Modifier.weight(1f)
) {
if (isChecking) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
} else {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Check Updates", "ตรวจหาอัปเดต", "检查更新"))
}
}
// Action Button based on accurate version comparison
if (latestVersion.isNotBlank()) {
if (!isInstalled) {
// Not installed -> Show Install Button
Button(
onClick = { onInstall(latestVersion) },
colors = ButtonDefaults.buttonColors(containerColor = CombatGreen),
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Install", "ติดตั้ง", "安装"))
}
} else if (hasNewVersion) {
// Has New Version -> Show Update Button
Button(
onClick = { onInstall(latestVersion) },
colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary),
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Upgrade, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Update", "อัปเดต", "更新"))
}
} else if (isUpToDate) {
// Up to date -> Show Reinstall Button
OutlinedButton(
onClick = { onInstall(latestVersion) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(tr(currentLanguage, "Reinstall", "ติดตั้งใหม่", "重新安装"))
}
}
}
}
// Up-to-date Confirmation Badge
if (isUpToDate) {
Spacer(modifier = Modifier.height(10.dp))
Surface(
shape = RoundedCornerShape(8.dp),
color = CombatGreen.copy(alpha = 0.15f),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = CombatGreen, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tr(currentLanguage, "You are using the latest version ($installedVersion)", "คุณกำลังใช้เวอร์ชันล่าสุดแล้ว ($installedVersion)", "您已是最新版本 ($installedVersion)"),
color = CombatGreen,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onOpenVersionSelector,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(tr(currentLanguage, "Select Version to Install (Archive)", "เลือกเวอร์ชันที่ต้องการติดตั้ง (คลังเวอร์ชัน)", "选择安装版本 (归档)"))
}
}
}
}
// Diagnostics Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = BgCard),
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
tr(currentLanguage, "System Diagnostics", "ข้อมูลวินิจฉัยระบบ", "系统诊断"),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = Color.White
)
Spacer(modifier = Modifier.height(10.dp))
DiagnosticItem(
title = tr(currentLanguage, "Python Runtime", "สภาพแวดล้อม Python", "Python 运行环境"),
value = "Embedded Chaquopy Python 3.14 (arm64-v8a)"
)
DiagnosticItem(
title = tr(currentLanguage, "Root Requirement", "ความต้องการสิทธิ์ Root", "Root 权限需求"),
value = tr(currentLanguage, "None (Fully Standalone)", "ไม่ต้องใช้ (รันในตัวแอป 100%)", "无需 (完全独立运行)")
)
DiagnosticItem(
title = tr(currentLanguage, "Storage Sandbox", "พื้นที่จัดเก็บข้อมูลแซนด์บ็อกซ์", "存储沙盒"),
value = "${context.filesDir.absolutePath}/server"
)
DiagnosticItem(
title = tr(currentLanguage, "Update Endpoint", "ลิงก์ตรวจสอบอัปเดต", "更新端点"),
value = UpdateRepository.VERSIONS_URL
)
}
}
}
}
@Composable
fun DiagnosticItem(title: String, value: String) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Text(title, fontSize = 11.sp, color = Color.Gray)
Text(value, fontSize = 13.sp, color = Color.LightGray)
}
}