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