新增 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

@@ -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 版為準。 抓取台灣銀行 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 所有 cargo 指令都要在 `bot_rates_rs/` 下執行,並帶 `RUST_MIN_STACK=16777216`(少了這個 LLVM 在 release 優化 `eframe` 依賴時會堆疊溢位 SIGSEGV

7
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.gradle/
build/
local.properties
*.iml
.idea/
.kotlin/
captures/

93
android/README.md Normal file
View File

@@ -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 Macexport 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` 直接跑在手機上看也沒差多少。

View File

@@ -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")
}

2
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Keep Kotlin metadata for reflection (if any future libs need it)
-keepattributes *Annotation*

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

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)
}
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">台銀即期賣出</string>
</resources>

5
android/build.gradle.kts Normal file
View File

@@ -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
}

View File

@@ -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

View File

@@ -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")