diff --git a/CLAUDE.md b/CLAUDE.md
index 27d161b..3f477b3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
抓取台灣銀行 CSV 匯率並即時換算成台幣的跨平台 GUI 小工具。目標是「使用者雙擊即跑、零 runtime 依賴」,所以主程式是 Rust(`bot_rates_rs/src/main.rs`);根目錄下的 `bot_rates.py` / `bot_rates_gui.py` / `build.bat` 是已被取代的早期 Python 版本,修改時請以 Rust 版為準。
+Android 版獨立在 `android/`(Kotlin + Jetpack Compose),用純 CLI(`gradle` + `sdkmanager`)建置,不依賴 Android Studio。核心邏輯(CSV col 13、TARGETS)與 Rust 版對齊,改動時兩邊要同步。詳細見 `android/README.md`。
+
## 常用指令
所有 cargo 指令都要在 `bot_rates_rs/` 下執行,並帶 `RUST_MIN_STACK=16777216`(少了這個 LLVM 在 release 優化 `eframe` 依賴時會堆疊溢位 SIGSEGV)。
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..a4dddc0
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,7 @@
+.gradle/
+build/
+local.properties
+*.iml
+.idea/
+.kotlin/
+captures/
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..8d859df
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,93 @@
+# Android 版(Kotlin + Jetpack Compose,不需要 Android Studio)
+
+用 CLI(`sdkmanager` + `gradle`)從零到 APK 全程在 terminal 完成。編輯用任何 editor / Claude Code 即可。
+
+## 一次性環境設定
+
+### 1. JDK 17 與 Gradle
+
+```bash
+brew install openjdk@17 gradle
+
+# 讓系統找得到 openjdk@17
+sudo ln -sfn "$(brew --prefix)/opt/openjdk@17/libexec/openjdk.jdk" \
+ /Library/Java/JavaVirtualMachines/openjdk-17.jdk
+
+# 加到 ~/.zshrc
+export JAVA_HOME="$(/usr/libexec/java_home -v 17)"
+```
+
+### 2. Android SDK(只裝 cmdline-tools / platform-tools / build-tools,不裝 IDE)
+
+```bash
+brew install --cask android-commandlinetools
+
+# 加到 ~/.zshrc
+export ANDROID_HOME="/opt/homebrew/share/android-commandlinetools" # Apple Silicon
+# 若是 Intel Mac:export ANDROID_HOME="/usr/local/share/android-commandlinetools"
+export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools"
+
+# 裝必要元件
+sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0"
+yes | sdkmanager --licenses
+```
+
+確認路徑被 Gradle 看得到,在 `android/` 下建一個 `local.properties`:
+
+```
+sdk.dir=/opt/homebrew/share/android-commandlinetools
+```
+
+(或讓 `ANDROID_HOME` 環境變數生效也行,Gradle 會自己找。)
+
+### 3. 產生 Gradle wrapper(只要做一次,之後就都用 `./gradlew`)
+
+```bash
+cd android
+gradle wrapper --gradle-version 8.9 --distribution-type bin
+```
+
+## 編譯
+
+```bash
+cd android
+./gradlew assembleDebug
+# 產物:app/build/outputs/apk/debug/app-debug.apk
+```
+
+Release build(未簽章,只能自己用):
+
+```bash
+./gradlew assembleRelease
+# 產物:app/build/outputs/apk/release/app-release-unsigned.apk
+```
+
+## 安裝到手機
+
+```bash
+# 1. 手機開「開發人員選項」→「USB 偵錯」
+# 2. USB 接上
+adb devices # 應列出裝置
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+```
+
+## 程式結構
+
+單一 Activity + Compose,所有邏輯在 `app/src/main/java/tw/local/botrates/MainActivity.kt`:
+
+- `fetchRates()`:`HttpURLConnection` 抓台銀 CSV,同樣用欄位索引 13(即期賣出)解析
+- `App()`:Compose UI,每個幣別一列:幣別+匯率 / 金額輸入 / TWD 結果 / 複製鈕
+- 複製用 `ClipboardManager.setPrimaryClip`,Toast 提示「已複製」
+
+### 想改的地方
+
+| 改什麼 | 位置 |
+|--------|------|
+| 新增幣別 | `MainActivity.kt` 頂端 `TARGETS` |
+| 買入/賣出 | `fetchRates()` 內的 `cols[13]`(見根 `QUICKSTART.md` 的欄位表) |
+| 視覺配色 | `RateRow()` 裡的 `Color(0xFF...)` 常數 |
+| applicationId / 版本 | `app/build.gradle.kts` 的 `defaultConfig` |
+
+## 為什麼不用 Android Studio
+
+AS 整包 2GB+,但實際需要的只有 JDK + Android SDK platform + build-tools(加起來 300MB 左右)。Gradle 本身也是 CLI 工具。Compose 的開發體驗在純 editor 下唯一損失的是即時 Preview,但寫完 `./gradlew installDebug` 直接跑在手機上看也沒差多少。
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..4cf6a68
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,58 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "tw.local.botrates"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "tw.local.botrates"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.activity:activity-compose:1.9.2")
+ implementation(platform("androidx.compose:compose-bom:2024.09.02"))
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..3b9b915
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Keep Kotlin metadata for reflection (if any future libs need it)
+-keepattributes *Annotation*
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bfeac55
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/tw/local/botrates/MainActivity.kt b/android/app/src/main/java/tw/local/botrates/MainActivity.kt
new file mode 100644
index 0000000..5b4aed1
--- /dev/null
+++ b/android/app/src/main/java/tw/local/botrates/MainActivity.kt
@@ -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 {
+ 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 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>(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)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..81d9b85
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 台銀即期賣出
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dec584c
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.20" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..3f6f2ce
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..2f52d8d
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "BotRates"
+include(":app")