新增 Android 版(Kotlin + Compose,CLI-only 工具鏈)

This commit is contained in:
Timmy
2026-04-17 12:45:13 +08:00
parent 1091f049ba
commit 0888c81007
11 changed files with 479 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
package tw.local.botrates
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private const val URL_CSV = "https://rate.bot.com.tw/xrt/flcsv/0/day"
private val TARGETS = listOf("USD" to "美元", "JPY" to "日幣", "EUR" to "歐元")
data class Rate(val code: String, val name: String, val rate: Double)
private fun fetchRates(): List<Rate> {
val conn = (URL(URL_CSV).openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 10_000
setRequestProperty("User-Agent", "Mozilla/5.0 bot_rates")
}
val body = conn.inputStream.bufferedReader().use { it.readText() }
.trimStart('\uFEFF')
val out = mutableListOf<Rate>()
body.lines().drop(1).forEach { line ->
val cols = line.split(",")
if (cols.size < 14) return@forEach
val code = cols[0].trim()
val pair = TARGETS.firstOrNull { it.first == code } ?: return@forEach
val v = cols[13].trim().toDoubleOrNull() ?: return@forEach
out.add(Rate(code, pair.second, v))
}
return out.sortedBy { r -> TARGETS.indexOfFirst { it.first == r.code } }
}
private fun parseAmount(s: String): Double? =
s.filter { it != ',' && !it.isWhitespace() }.takeIf { it.isNotEmpty() }?.toDoubleOrNull()
private fun formatTwd(v: Double): String {
val rounded = kotlin.math.round(v * 100.0) / 100.0
return "%,.2f".format(rounded)
}
private fun formatTwdRaw(v: Double): String =
"%.2f".format(kotlin.math.round(v * 100.0) / 100.0)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
App()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun App() {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var rates by remember { mutableStateOf<List<Rate>>(emptyList()) }
val amounts = remember {
mutableStateMapOf<String, String>().apply {
TARGETS.forEach { put(it.first, "100") }
}
}
var loading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var fetchedAt by remember { mutableStateOf<String?>(null) }
fun load() {
if (loading) return
loading = true
error = null
scope.launch {
try {
val r = withContext(Dispatchers.IO) { fetchRates() }
if (r.isEmpty()) {
error = "解析結果為空"
} else {
rates = r
fetchedAt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
.format(Date())
}
} catch (e: Exception) {
error = e.message ?: "載入失敗"
} finally {
loading = false
}
}
}
LaunchedEffect(Unit) { load() }
Scaffold(
topBar = { TopAppBar(title = { Text("台銀 即期賣出") }) }
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize()
) {
Text(
"輸入外幣金額,下方顯示台幣換算",
fontSize = 12.sp,
color = Color.Gray
)
Spacer(Modifier.height(12.dp))
val display = rates.ifEmpty {
TARGETS.map { Rate(it.first, it.second, Double.NaN) }
}
display.forEachIndexed { idx, r ->
if (idx > 0) {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
RateRow(
rate = r,
amount = amounts[r.code] ?: "",
onAmountChange = { amounts[r.code] = it },
onCopy = { raw ->
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE)
as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("TWD", raw))
Toast.makeText(context, "已複製 $raw", Toast.LENGTH_SHORT).show()
}
)
}
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
val status = when {
error != null -> "載入失敗:$error"
loading -> "載入中…"
fetchedAt != null -> "更新時間:$fetchedAt"
else -> ""
}
Text(
status,
fontSize = 11.sp,
color = if (error != null) Color(0xFFB91C1C) else Color.Gray,
modifier = Modifier.weight(1f)
)
Button(onClick = { load() }, enabled = !loading) {
Text("重新整理")
}
}
}
}
}
@Composable
private fun RateRow(
rate: Rate,
amount: String,
onAmountChange: (String) -> Unit,
onCopy: (String) -> Unit
) {
val rateText = if (rate.rate.isNaN()) "--" else "%.4f".format(rate.rate)
val parsed = parseAmount(amount)
val twd = if (parsed != null && !rate.rate.isNaN()) parsed * rate.rate else null
val twdText = twd?.let { "NT$ ${formatTwd(it)}" } ?: "NT$ --"
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.width(110.dp)) {
Text(
"${rate.name} ${rate.code}",
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Text(
rateText,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = Color(0xFF0F766E)
)
}
Spacer(Modifier.width(8.dp))
OutlinedTextField(
value = amount,
onValueChange = onAmountChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
textStyle = TextStyle(fontFamily = FontFamily.Monospace)
)
}
Spacer(Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
Text(
twdText,
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color(0xFFB45309),
fontFamily = FontFamily.Monospace
)
Button(
onClick = { twd?.let { onCopy(formatTwdRaw(it)) } },
enabled = twd != null,
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)
) {
Text("複製", fontSize = 12.sp)
}
}
}
}