268 lines
9.4 KiB
Kotlin
268 lines
9.4 KiB
Kotlin
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.math.BigDecimal
|
|
import java.math.RoundingMode
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
import java.text.DecimalFormat
|
|
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 roundHalfUp(v: Double): BigDecimal =
|
|
BigDecimal.valueOf(v).setScale(0, RoundingMode.HALF_UP)
|
|
|
|
private fun formatTwd(v: Double): String =
|
|
DecimalFormat("#,##0").format(roundHalfUp(v))
|
|
|
|
private fun formatTwdRaw(v: Double): String =
|
|
roundHalfUp(v).toPlainString()
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|