Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d95379d7bb | ||
|
|
296e254d9d | ||
|
|
1f229da736 | ||
|
|
6b7eab1095 | ||
|
|
a71fb544d3 |
Binary file not shown.
@@ -50,7 +50,8 @@ cd .. && zip -9 bot_rates.zip bot_rates.exe
|
||||
- **輸入狀態**:`App.amounts: HashMap<String, String>`,存字串不是 `f64`,這樣才能保留「`100.`」「空字串」「`1,000`」這類輸入中間態。`parse_amount` 才做清洗與 parse。
|
||||
- **字型**:egui 預設字型沒中文,會變豆腐。`setup_fonts` 在執行期讀 OS 字型(Windows `msjh.ttc` / macOS `PingFang.ttc`,有 fallback 鏈),**刻意不 bundle 字型**以免 binary 胖 5–15 MB。如果在沒有這些字型的環境跑就會回到豆腐。
|
||||
- **時間**:`now_string` / `civil_from_days` 自己實作 UTC→台北時間換算與 Gregorian 日期計算,避免引入 `chrono` / `time` 把 binary 撐大。
|
||||
- **複製**:「複製」按鈕用 `ui.output_mut(|o| o.copied_text = raw)` 寫純數字(`3162.00`,不含 `NT$` / 逗號)到剪貼簿,設計是為了貼到 Excel 會被當數字。TWD label 另外有 `.selectable(true)` 讓使用者自行選取。
|
||||
- **複製**:「複製」按鈕用 `ui.output_mut(|o| o.copied_text = raw)` 寫純整數(`3163`,四捨五入到個位,不含 `NT$` / 逗號 / 小數)到剪貼簿,設計是為了貼到 Excel 會被當數字。TWD label 另外有 `.selectable(true)` 讓使用者自行選取。
|
||||
- **四捨五入**:TWD 一律顯示整數;Rust 用 `f64::round() as i64`(語言內建就是 ties away from zero),Kotlin 改用 `BigDecimal.setScale(0, HALF_UP)`(因為 `kotlin.math.round` 是銀行家捨入、行為不一致)。兩側要同步。
|
||||
|
||||
## 擴充時的常見改點
|
||||
|
||||
@@ -58,6 +59,7 @@ cd .. && zip -9 bot_rates.zip bot_rates.exe
|
||||
|------|------|
|
||||
| 新增幣別 | `TARGETS` 常數,加一行 `(代碼, 中文名)` 即可,其他地方會自動處理 |
|
||||
| 改成即期買入/現金匯率 | `fetch_rates` 裡的 `cols[13]` 換成對應 index(見上方資料抓取段) |
|
||||
| 顯示到小數點 | 改 `format_twd` / `format_twd_raw` 的 `v.round() as i64`(Rust)與 `setScale(0, HALF_UP)`(Kotlin),兩邊都要改 |
|
||||
| 視窗尺寸/不可 resize | `main()` 的 `ViewportBuilder` |
|
||||
| 預設輸入金額 | `App::new` 裡的 `"100".to_string()` |
|
||||
| 配色 | `update` 開頭的 `bg` / `card` / `rate_color` / `twd_color` 等常數 |
|
||||
|
||||
@@ -29,8 +29,8 @@ xattr -cr /path/to/BotRates.app
|
||||
|------|------|
|
||||
| 左側 | 幣別、代碼、即期賣出匯率(例如 `美元 USD 31.6200`) |
|
||||
| 中間 | 輸入外幣金額(預設 100,可自由修改) |
|
||||
| 右側 | 台幣換算結果(例如 `NT$ 3,162.00`) |
|
||||
| 複製 | 按下複製純數字(`3162.00`)到剪貼簿,可直接貼 Excel |
|
||||
| 右側 | 台幣換算結果,四捨五入到個位(例如 `NT$ 3,163`) |
|
||||
| 複製 | 按下複製純整數(`3163`)到剪貼簿,可直接貼 Excel |
|
||||
| 重新整理 | 右下角按鈕,重新抓最新匯率 |
|
||||
|
||||
---
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- **即時匯率**:從台銀官網 CSV(`rate.bot.com.tw/xrt/flcsv/0/day`)抓取,免 API key
|
||||
- **雙向換算**:每個幣別獨立輸入框,輸入外幣金額立即換算出台幣
|
||||
- **一鍵複製**:按「複製」即把純數字(如 `3162.00`)寫入剪貼簿,可直接貼到 Excel
|
||||
- **一鍵複製**:按「複製」即把整數(如 `3163`,四捨五入到個位)寫入剪貼簿,可直接貼到 Excel
|
||||
- **零依賴**:單一執行檔,使用者不需要安裝 Python、Rust 或任何 runtime
|
||||
- **跨平台**:Windows 與 macOS 共用同一份 Rust 原始碼
|
||||
|
||||
|
||||
@@ -96,13 +96,13 @@ BotRates.app/
|
||||
|
||||
輸入解析用 `parse_amount`,忽略逗號和空白再 parse。
|
||||
|
||||
TWD 顯示用 `format_twd`,手刻千分位 separator(沒引入 `num-format` crate 以減少 binary size)。
|
||||
TWD 顯示用 `format_twd`,四捨五入到個位(`v.round() as i64`,Rust 的 `f64::round` 本身就是 ties away from zero,符合中文四捨五入),手刻千分位 separator(沒引入 `num-format` crate 以減少 binary size)。
|
||||
|
||||
## 階段 7:複製功能
|
||||
|
||||
兩種複製方式並存:
|
||||
|
||||
1. **按鈕複製**(主要 UX):按「複製」→ `ui.output_mut(|o| o.copied_text = raw)`,複製純數字 `3162.00`(不含 `NT$`、不含逗號)。原因:這樣貼到 Excel 會被識別成數字而不是文字。
|
||||
1. **按鈕複製**(主要 UX):按「複製」→ `ui.output_mut(|o| o.copied_text = raw)`,複製純整數 `3163`(不含 `NT$`、不含逗號、不含小數)。原因:這樣貼到 Excel 會被識別成數字而不是文字。
|
||||
2. **選取複製**:`egui::Label::new(...).selectable(true)`,使用者可以用滑鼠拉選 TWD 文字後 `Cmd/Ctrl+C`。
|
||||
|
||||
按下按鈕後短暫顯示「已複製」(1.2 秒),用 `Option<(String, Instant)>` 追蹤狀態 + `ctx.request_repaint_after` 觸發回復重繪。
|
||||
|
||||
334
android/DEVLOG.md
Normal file
334
android/DEVLOG.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# Android 版開發過程
|
||||
|
||||
記錄這個專案從 Rust 桌面版延伸到 Android 平板的完整過程、踩到的坑、以及為什麼做出這些選擇。主線需求:**不想裝 Android Studio**(2GB+ 太肥),全程 CLI 完成。
|
||||
|
||||
## 階段 0:決策與選型
|
||||
|
||||
### 候選方案比較
|
||||
|
||||
| 方案 | 工具鏈重量 | 重寫成本 | 體驗 | 結論 |
|
||||
|------|-----------|---------|------|------|
|
||||
| Rust + `cargo-apk`(沿用現有 `main.rs`) | 中(SDK + NDK) | 低 | egui 觸控差、IME 不順 | ✗ |
|
||||
| **Kotlin + Jetpack Compose(CLI)** | 中(SDK 300MB) | 低(核心邏輯 ~50 行) | 原生最佳 | ✓ |
|
||||
| Flutter / Tauri Mobile | 重(再引入一整個生態) | 中 | 不差 | overkill |
|
||||
| PWA / 網頁版 | 零 | 低 | 差;**台銀 CSV 沒開 CORS 直接擋掉** | ✗ |
|
||||
|
||||
選 Kotlin + Compose,因為邏輯極簡(抓 CSV → parse col 13 → 乘法),Compose 的宣告式 UI 跟 egui 心智模型相近,轉換幾乎是 1:1。
|
||||
|
||||
### 「不用 Android Studio」的真正意思
|
||||
|
||||
Android Studio 是 IDE,背後其實只靠三個 CLI 工具:
|
||||
|
||||
1. **JDK 17**(`brew install openjdk@17`)
|
||||
2. **Android SDK**(`cmdline-tools` + `platform-tools` + `platforms;android-34` + `build-tools;34.0.0`,用 `sdkmanager` 裝)
|
||||
3. **Gradle**(`brew install gradle`)
|
||||
|
||||
IDE 提供的 Compose live preview / refactor / debugger 在這個小專案用不上。每次 `./gradlew installDebug` 直接在實機看結果反而更真實。
|
||||
|
||||
## 階段 1:專案骨架
|
||||
|
||||
手工建 Gradle 多模組專案(實際只一個模組 `:app`),關鍵檔案:
|
||||
|
||||
```
|
||||
android/
|
||||
├── settings.gradle.kts # 宣告 :app module + pluginManagement repos
|
||||
├── build.gradle.kts # 宣告 plugins(AGP / Kotlin / Compose compiler)
|
||||
├── gradle.properties # JVM args / AndroidX / nonTransitiveRClass
|
||||
├── app/
|
||||
│ ├── build.gradle.kts # compileSdk 34, minSdk 26, Compose BOM
|
||||
│ └── src/main/
|
||||
│ ├── AndroidManifest.xml
|
||||
│ ├── res/values/strings.xml
|
||||
│ └── java/tw/local/botrates/MainActivity.kt
|
||||
```
|
||||
|
||||
### 版本鎖定
|
||||
|
||||
```
|
||||
AGP 8.5.2 + Kotlin 2.0.20 + Compose BOM 2024.09.02
|
||||
```
|
||||
|
||||
Kotlin 2.0 開始 Compose compiler 改走**獨立 Gradle plugin** `org.jetbrains.kotlin.plugin.compose`,不再需要寫 `composeOptions { kotlinCompilerExtensionVersion = "..." }`。
|
||||
|
||||
### `minSdk = 26`(Android 8.0+)
|
||||
|
||||
26 以下要額外處理 `java.time` 降級 / desugaring,且目前 Android 裝置 8.0 以下普及率已很低,不值得為此複雜化設定。
|
||||
|
||||
## 階段 2:把 Rust 邏輯 port 成 Kotlin
|
||||
|
||||
核心檔案 `MainActivity.kt`(~230 行)。對照 Rust 版的轉換:
|
||||
|
||||
| Rust (egui) | Kotlin (Compose) |
|
||||
|------------|------------------|
|
||||
| `ureq::AgentBuilder` | `HttpURLConnection`(標準函式庫夠用,不引 OkHttp) |
|
||||
| `thread::spawn` + `mpsc::channel` | `rememberCoroutineScope()` + `withContext(Dispatchers.IO)` |
|
||||
| `App` struct + `impl eframe::App` | `@Composable fun App()` + `remember { mutableStateOf(...) }` |
|
||||
| `HashMap<String, String>` 存輸入 | `mutableStateMapOf<String, String>` |
|
||||
| `ui.output_mut(|o| o.copied_text = ...)` | `ClipboardManager.setPrimaryClip` + `Toast` |
|
||||
| `setup_fonts` 執行期讀 OS 字型 | 免;Android 有 CJK 內建 |
|
||||
|
||||
CSV 解析邏輯一模一樣:`cols.size >= 14`、`cols[0]` 幣別代碼、`cols[13]` 即期賣出、過濾 TARGETS。`parseAmount` 和 `formatTwd` 也是直接翻譯。
|
||||
|
||||
### 版面取捨
|
||||
|
||||
egui 版是橫排 `[幣別+匯率] [輸入] → [TWD] [複製]`。平板雖然寬,但 Compose 版改成**兩列**:第一列 `[幣別+匯率] [輸入框 fillMaxWidth]`,第二列 `[TWD 靠左 weight(1)] [複製鈕]`。理由是輸入框能吃滿寬度,手指點選體驗好,且日後也能直接塞進手機直式螢幕不用改版。
|
||||
|
||||
## 階段 3:工具鏈安裝與首次 build
|
||||
|
||||
這台 Mac 已經裝好的:
|
||||
|
||||
- JDK 17(Temurin)
|
||||
- `~/Library/Android/sdk`(含 platforms android-34、build-tools 34.0.0、platform-tools、cmdline-tools)
|
||||
|
||||
**只缺 Gradle**:
|
||||
|
||||
```bash
|
||||
brew install gradle # 裝到 9.4.1
|
||||
```
|
||||
|
||||
### `local.properties` 指向 SDK
|
||||
|
||||
```
|
||||
sdk.dir=/Users/timmy/Library/Android/sdk
|
||||
```
|
||||
|
||||
此檔案 **gitignored**,每個開發者自己的 SDK 路徑不同。
|
||||
|
||||
### 產 Gradle wrapper(只做一次)
|
||||
|
||||
```bash
|
||||
gradle wrapper --gradle-version 8.9 --distribution-type bin
|
||||
```
|
||||
|
||||
之後所有指令用 `./gradlew`,不再依賴系統 gradle。wrapper 檔案進 repo,確保每個 clone 下來的人用同一個 Gradle 版本。
|
||||
|
||||
### 第一次 debug build
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
耗時 **2:13**(下載 AGP / Compose 依賴約 300MB,之後增量建置 < 10 秒)。產物:
|
||||
|
||||
```
|
||||
app/build/outputs/apk/debug/app-debug.apk # 8.7 MB(未簽章)
|
||||
```
|
||||
|
||||
## 階段 4:Samsung Tab A 實機部署(USB 偵錯坑)
|
||||
|
||||
機型 **SM-T510(Galaxy Tab A 10.1)**。
|
||||
|
||||
### 症狀演進
|
||||
|
||||
```bash
|
||||
$ adb devices
|
||||
List of devices attached
|
||||
# 空
|
||||
```
|
||||
|
||||
接著各種症狀:
|
||||
|
||||
1. `system_profiler SPUSBDataType` 看不到 Android 裝置 → 確定是連線層
|
||||
2. 換線後 `adb devices` 出現 `R52N60RZLNW unauthorized`
|
||||
3. 平板螢幕**不跳**授權對話框
|
||||
4. 設定 → 開發人員選項 → 撤銷 USB 偵錯授權 → 拔插 → **還是不跳**
|
||||
5. **關掉 USB 偵錯 → 再開 USB 偵錯** → 對話框終於跳出 → 允許 → `device`
|
||||
|
||||
### 教訓
|
||||
|
||||
Samsung(至少這台的 One UI)的 USB 偵錯授權對話框有時會被卡在某個內部狀態。標準的「撤銷授權 + 拔插」有時沒用,**「關掉 USB 偵錯再重開」**似乎會強制重設授權管理器的狀態。這條排除步驟以後要記住。
|
||||
|
||||
另外需要同時滿足:
|
||||
|
||||
- 螢幕解鎖且保持亮著(鎖屏狀態下對話框不顯示)
|
||||
- USB 模式是「檔案傳輸」而非「僅供電」
|
||||
- 線支援資料傳輸(不是純充電線)
|
||||
|
||||
### 安裝與啟動
|
||||
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk # Streamed Install, Success
|
||||
adb shell monkey -p tw.local.botrates -c android.intent.category.LAUNCHER 1
|
||||
```
|
||||
|
||||
跑起來字型正常(不用 bundle CJK 字型,系統內建 Noto Sans CJK)、匯率正確抓取、複製貼到其他 app 成功。
|
||||
|
||||
## 階段 5:Release 簽章
|
||||
|
||||
Android 的 release APK 必須簽章才能安裝(debug 版系統會自動用 debug keystore 簽,release 必須自備)。
|
||||
|
||||
### 產生 keystore
|
||||
|
||||
```bash
|
||||
keytool -genkey -v \
|
||||
-keystore ~/.android/botrates-release.keystore \
|
||||
-alias botrates \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass botrates1234 -keypass botrates1234 \
|
||||
-dname "CN=Timmy, OU=Personal, O=BotRates, L=Taipei, ST=Taipei, C=TW"
|
||||
```
|
||||
|
||||
**關鍵決策**:keystore 檔案放 **repo 外**(`~/.android/`),密碼寫在 **gitignored 的 `android/keystore.properties`**。這樣 repo 本身永遠不含 signing secrets,也方便未來換 CI 時用 env var 改寫。
|
||||
|
||||
### `build.gradle.kts` 的條件式 signing config
|
||||
|
||||
```kotlin
|
||||
val keystoreProps = Properties().apply {
|
||||
val f = rootProject.file("keystore.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
signingConfigs {
|
||||
if (keystoreProps.isNotEmpty()) {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProps.getProperty("storeFile"))
|
||||
storePassword = keystoreProps.getProperty("storePassword")
|
||||
keyAlias = keystoreProps.getProperty("keyAlias")
|
||||
keyPassword = keystoreProps.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
if (keystoreProps.isNotEmpty()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
沒有 `keystore.properties`(例如別人 clone repo)時**不會 fail**,只是 `assembleRelease` 會產出 unsigned APK 並失敗於安裝階段。這比硬噴 `FileNotFoundException` 友善。
|
||||
|
||||
### 建置結果
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease # 52s
|
||||
```
|
||||
|
||||
| 版本 | 大小 |
|
||||
|------|------|
|
||||
| `app-debug.apk` | 8.7 MB |
|
||||
| `app-release.apk` | **6.5 MB** |
|
||||
|
||||
release 小一截是因為 AGP 對 release build 做了 resource shrinking 和 bytecode optimization(雖然 `isMinifyEnabled = false`,AGP 還是做了一些預設處理)。
|
||||
|
||||
驗證簽章:
|
||||
|
||||
```bash
|
||||
~/Library/Android/sdk/build-tools/34.0.0/apksigner verify --verbose app-release.apk
|
||||
# Verifies
|
||||
# Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
```
|
||||
|
||||
v2 簽章 = 現代 Android(7.0+)官方推薦格式。
|
||||
|
||||
## 階段 6:Gitea Release
|
||||
|
||||
### Tag + push
|
||||
|
||||
```bash
|
||||
git tag -a v1.0 -m "v1.0 首次 release"
|
||||
git push --tags
|
||||
```
|
||||
|
||||
### 用 Gitea API 建 release
|
||||
|
||||
Gitea 的 release API 分兩步:
|
||||
|
||||
1. `POST /api/v1/repos/{owner}/{repo}/releases` 建 release object(帶 tag name)
|
||||
2. `POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets` 上傳附件
|
||||
|
||||
```bash
|
||||
curl -X POST "http://.../api/v1/repos/timmy/bot-rates/releases" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"v1.0","name":"v1.0 — 首版 Android APK","body":"..."}'
|
||||
# → 回傳 JSON 含 release.id
|
||||
|
||||
curl -X POST "http://.../api/v1/repos/timmy/bot-rates/releases/$ID/assets?name=BotRates-v1.0.apk" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@android/app/build/outputs/apk/release/app-release.apk"
|
||||
```
|
||||
|
||||
### 踩到小坑
|
||||
|
||||
第一次用 `python3 -c "import sys,json; print(json.load(sys.stdin)['id'])"` 解析 curl 回傳時 python JSON decoder 對 body 裡的裸 `\n` 字元噴 `Invalid control character`。原因是 release body 裡的 markdown 換行在 HTTP response 是真正的 `0x0A`,而 Python JSON 規範要求字串值裡的 control char 要 escape。
|
||||
|
||||
**解法**:改用 `/releases/tags/{tag}` endpoint 反查 id,body 短所以沒這問題。或 `curl --data-binary` 傳 body 時讓 server 自己處理 escape。
|
||||
|
||||
## 成果總覽
|
||||
|
||||
| 項目 | 數值 |
|
||||
|------|------|
|
||||
| 新增原始碼 | ~230 行 Kotlin(`MainActivity.kt`) |
|
||||
| 新增設定檔 | ~9 個 Gradle / Manifest / XML |
|
||||
| 工具鏈總大小 | JDK 17(~300MB)+ Android SDK(~1GB,已有)+ Gradle(~200MB) |
|
||||
| Debug APK | 8.7 MB |
|
||||
| Release APK | **6.5 MB**(v2 簽章) |
|
||||
| 實機驗證 | Samsung Galaxy Tab A 10.1(SM-T510,Android 11) |
|
||||
| 首次冷啟動 | < 1 秒 |
|
||||
| 抓匯率耗時 | < 500ms |
|
||||
| Gitea release | v1.0 已上傳 APK |
|
||||
|
||||
## 階段 7:調整顯示為整數(v1.0.1)
|
||||
|
||||
需求:TWD 顯示不要小數,直接四捨五入到個位(例如 `3,162.50` → `3,163`)。
|
||||
|
||||
### Rust 端(macOS / Windows)
|
||||
|
||||
Rust 的 `f64::round()` 本身就是 ties away from zero(正數 `.5` 往上、負數 `-.5` 往下),符合中文「四捨五入」定義。改動只是把「×100 round ÷100 + `.xx` 格式化」簡化成 `v.round() as i64`:
|
||||
|
||||
```rust
|
||||
fn format_twd(v: f64) -> String {
|
||||
let rounded = v.round() as i64;
|
||||
// 手刻千分位逗號
|
||||
}
|
||||
|
||||
fn format_twd_raw(v: f64) -> String {
|
||||
format!("{}", v.round() as i64)
|
||||
}
|
||||
```
|
||||
|
||||
### Kotlin 端的陷阱
|
||||
|
||||
`kotlin.math.round(Double)` 是**銀行家捨入**(ties to even),例如 `316.5 → 316`、`317.5 → 318`。這跟 Rust 端行為不一致,而且不是台灣語境下「四捨五入」的常識定義。
|
||||
|
||||
**解法**:改用 `BigDecimal.setScale(0, RoundingMode.HALF_UP)`:
|
||||
|
||||
```kotlin
|
||||
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()
|
||||
```
|
||||
|
||||
**為什麼 `BigDecimal.valueOf(v)` 而不是 `BigDecimal(v)`?**
|
||||
`BigDecimal(double)` 吃 IEEE 754 的尾數誤差,例如 `BigDecimal(0.1)` 會變 `0.1000000000000000055511151...`。`valueOf(double)` 走 `Double.toString` 路線,給你看起來「應該」的值,對使用者輸入的金額比較合理。
|
||||
|
||||
### 版號策略
|
||||
|
||||
行為變了(而且使用者肉眼可見),所以 bump `versionCode = 2 / versionName = "1.0.1"`,tag `v1.0.1`,在 Gitea 另開一個 release `BotRates-v1.0.1.apk`,不覆蓋 v1.0 的 asset。這樣歷史版本可追溯。
|
||||
|
||||
### 實機 install 踩到 INSTALL_FAILED_UPDATE_INCOMPATIBLE
|
||||
|
||||
先前裝過 debug 版(debug keystore 簽),現在要裝 release 版(自己的 keystore 簽),Android 拒絕覆蓋:
|
||||
|
||||
```
|
||||
Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: Package tw.local.botrates
|
||||
signatures do not match previously installed version; ignoring!]
|
||||
```
|
||||
|
||||
**解法**:`adb uninstall tw.local.botrates` 先移除,再 `adb install`。之後都用同一把 release keystore 簽,就能持續覆蓋更新。
|
||||
|
||||
## 如果要擴充
|
||||
|
||||
- **相容 minSdk 21(Android 5.0)**:`compileOptions` 加 `isCoreLibraryDesugaringEnabled = true`,dependencies 加 `coreLibraryDesugaring`,讓 `java.time` 等新 API 降級。
|
||||
- **Play Store 上架**:Play Store 要 **AAB**(Android App Bundle)不是 APK,改 `./gradlew bundleRelease`,輸出 `.aab`。另外 Play 會強制要求 targetSdk 跟上最新(目前 34)。
|
||||
- **CI 簽章**:把 `keystore.properties` 的四個欄位改成讀 env var(`System.getenv()`),keystore 本身用 base64 塞進 GitHub Actions secret 再解碼還原。
|
||||
- **Wear OS / Android TV**:`AndroidManifest.xml` 加 `<uses-feature>` 宣告並支援觸控缺席情境,但這 app 核心互動是輸入金額,TV 遙控器體驗會很差,不建議。
|
||||
@@ -19,8 +19,8 @@ android {
|
||||
applicationId = "tw.local.botrates"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "1.0.1"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
|
||||
@@ -47,8 +47,11 @@ 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
|
||||
@@ -82,13 +85,14 @@ private fun fetchRates(): List<Rate> {
|
||||
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 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 =
|
||||
"%.2f".format(kotlin.math.round(v * 100.0) / 100.0)
|
||||
roundHalfUp(v).toPlainString()
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
||||
BIN
bot_rates.exe
BIN
bot_rates.exe
Binary file not shown.
BIN
bot_rates.zip
BIN
bot_rates.zip
Binary file not shown.
@@ -139,12 +139,9 @@ fn parse_amount(s: &str) -> Option<f64> {
|
||||
}
|
||||
|
||||
fn format_twd(v: f64) -> String {
|
||||
let rounded = (v * 100.0).round() / 100.0;
|
||||
let sign = if rounded < 0.0 { "-" } else { "" };
|
||||
let abs = rounded.abs();
|
||||
let int_part = abs.trunc() as u64;
|
||||
let frac = ((abs - int_part as f64) * 100.0).round() as u64;
|
||||
let int_str = int_part.to_string();
|
||||
let rounded = v.round() as i64;
|
||||
let sign = if rounded < 0 { "-" } else { "" };
|
||||
let int_str = rounded.unsigned_abs().to_string();
|
||||
let mut with_sep = String::new();
|
||||
for (i, ch) in int_str.chars().rev().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
@@ -153,12 +150,11 @@ fn format_twd(v: f64) -> String {
|
||||
with_sep.push(ch);
|
||||
}
|
||||
let int_sep: String = with_sep.chars().rev().collect();
|
||||
format!("{sign}{int_sep}.{frac:02}")
|
||||
format!("{sign}{int_sep}")
|
||||
}
|
||||
|
||||
fn format_twd_raw(v: f64) -> String {
|
||||
let rounded = (v * 100.0).round() / 100.0;
|
||||
format!("{rounded:.2}")
|
||||
format!("{}", v.round() as i64)
|
||||
}
|
||||
|
||||
struct App {
|
||||
|
||||
Reference in New Issue
Block a user