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 { 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() 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>(emptyList()) } val amounts = remember { mutableStateMapOf().apply { TARGETS.forEach { put(it.first, "100") } } } var loading by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var fetchedAt by remember { mutableStateOf(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) } } } }