5 Commits
v1.0 ... main

11 changed files with 358 additions and 22 deletions

Binary file not shown.

View File

@@ -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。 - **輸入狀態**`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 胖 515 MB。如果在沒有這些字型的環境跑就會回到豆腐。 - **字型**egui 預設字型沒中文,會變豆腐。`setup_fonts` 在執行期讀 OS 字型Windows `msjh.ttc` / macOS `PingFang.ttc`,有 fallback 鏈),**刻意不 bundle 字型**以免 binary 胖 515 MB。如果在沒有這些字型的環境跑就會回到豆腐。
- **時間**`now_string` / `civil_from_days` 自己實作 UTC→台北時間換算與 Gregorian 日期計算,避免引入 `chrono` / `time` 把 binary 撐大。 - **時間**`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 zeroKotlin 改用 `BigDecimal.setScale(0, HALF_UP)`(因為 `kotlin.math.round` 是銀行家捨入、行為不一致)。兩側要同步。
## 擴充時的常見改點 ## 擴充時的常見改點
@@ -58,6 +59,7 @@ cd .. && zip -9 bot_rates.zip bot_rates.exe
|------|------| |------|------|
| 新增幣別 | `TARGETS` 常數,加一行 `(代碼, 中文名)` 即可,其他地方會自動處理 | | 新增幣別 | `TARGETS` 常數,加一行 `(代碼, 中文名)` 即可,其他地方會自動處理 |
| 改成即期買入/現金匯率 | `fetch_rates` 裡的 `cols[13]` 換成對應 index見上方資料抓取段 | | 改成即期買入/現金匯率 | `fetch_rates` 裡的 `cols[13]` 換成對應 index見上方資料抓取段 |
| 顯示到小數點 | 改 `format_twd` / `format_twd_raw``v.round() as i64`Rust`setScale(0, HALF_UP)`Kotlin兩邊都要改 |
| 視窗尺寸/不可 resize | `main()``ViewportBuilder` | | 視窗尺寸/不可 resize | `main()``ViewportBuilder` |
| 預設輸入金額 | `App::new` 裡的 `"100".to_string()` | | 預設輸入金額 | `App::new` 裡的 `"100".to_string()` |
| 配色 | `update` 開頭的 `bg` / `card` / `rate_color` / `twd_color` 等常數 | | 配色 | `update` 開頭的 `bg` / `card` / `rate_color` / `twd_color` 等常數 |

View File

@@ -29,8 +29,8 @@ xattr -cr /path/to/BotRates.app
|------|------| |------|------|
| 左側 | 幣別、代碼、即期賣出匯率(例如 `美元 USD 31.6200` | | 左側 | 幣別、代碼、即期賣出匯率(例如 `美元 USD 31.6200` |
| 中間 | 輸入外幣金額(預設 100可自由修改 | | 中間 | 輸入外幣金額(預設 100可自由修改 |
| 右側 | 台幣換算結果(例如 `NT$ 3,162.00` | | 右側 | 台幣換算結果,四捨五入到個位(例如 `NT$ 3,163` |
| 複製 | 按下複製純數`3162.00`)到剪貼簿,可直接貼 Excel | | 複製 | 按下複製純數(`3163`)到剪貼簿,可直接貼 Excel |
| 重新整理 | 右下角按鈕,重新抓最新匯率 | | 重新整理 | 右下角按鈕,重新抓最新匯率 |
--- ---

View File

@@ -6,7 +6,7 @@
- **即時匯率**:從台銀官網 CSV`rate.bot.com.tw/xrt/flcsv/0/day`)抓取,免 API key - **即時匯率**:從台銀官網 CSV`rate.bot.com.tw/xrt/flcsv/0/day`)抓取,免 API key
- **雙向換算**:每個幣別獨立輸入框,輸入外幣金額立即換算出台幣 - **雙向換算**:每個幣別獨立輸入框,輸入外幣金額立即換算出台幣
- **一鍵複製**:按「複製」即把純數字(如 `3162.00`)寫入剪貼簿,可直接貼到 Excel - **一鍵複製**:按「複製」即把整數(如 `3163`,四捨五入到個位)寫入剪貼簿,可直接貼到 Excel
- **零依賴**:單一執行檔,使用者不需要安裝 Python、Rust 或任何 runtime - **零依賴**:單一執行檔,使用者不需要安裝 Python、Rust 或任何 runtime
- **跨平台**Windows 與 macOS 共用同一份 Rust 原始碼 - **跨平台**Windows 與 macOS 共用同一份 Rust 原始碼

View File

@@ -96,13 +96,13 @@ BotRates.app/
輸入解析用 `parse_amount`,忽略逗號和空白再 parse。 輸入解析用 `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複製功能 ## 階段 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` 2. **選取複製**`egui::Label::new(...).selectable(true)`,使用者可以用滑鼠拉選 TWD 文字後 `Cmd/Ctrl+C`
按下按鈕後短暫顯示「已複製」1.2 秒),用 `Option<(String, Instant)>` 追蹤狀態 + `ctx.request_repaint_after` 觸發回復重繪。 按下按鈕後短暫顯示「已複製」1.2 秒),用 `Option<(String, Instant)>` 追蹤狀態 + `ctx.request_repaint_after` 觸發回復重繪。

334
android/DEVLOG.md Normal file
View File

@@ -0,0 +1,334 @@
# Android 版開發過程
記錄這個專案從 Rust 桌面版延伸到 Android 平板的完整過程、踩到的坑、以及為什麼做出這些選擇。主線需求:**不想裝 Android Studio**2GB+ 太肥),全程 CLI 完成。
## 階段 0決策與選型
### 候選方案比較
| 方案 | 工具鏈重量 | 重寫成本 | 體驗 | 結論 |
|------|-----------|---------|------|------|
| Rust + `cargo-apk`(沿用現有 `main.rs` | 中SDK + NDK | 低 | egui 觸控差、IME 不順 | ✗ |
| **Kotlin + Jetpack ComposeCLI** | 中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 # 宣告 pluginsAGP / 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 17Temurin
- `~/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未簽章
```
## 階段 4Samsung Tab A 實機部署USB 偵錯坑)
機型 **SM-T510Galaxy 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 成功。
## 階段 5Release 簽章
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 簽章 = 現代 Android7.0+)官方推薦格式。
## 階段 6Gitea 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 反查 idbody 短所以沒這問題。或 `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.1SM-T510Android 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 21Android 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 遙控器體驗會很差,不建議。

View File

@@ -19,8 +19,8 @@ android {
applicationId = "tw.local.botrates" applicationId = "tw.local.botrates"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.0.1"
} }
compileOptions { compileOptions {

View File

@@ -47,8 +47,11 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.DecimalFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -82,13 +85,14 @@ private fun fetchRates(): List<Rate> {
private fun parseAmount(s: String): Double? = private fun parseAmount(s: String): Double? =
s.filter { it != ',' && !it.isWhitespace() }.takeIf { it.isNotEmpty() }?.toDoubleOrNull() s.filter { it != ',' && !it.isWhitespace() }.takeIf { it.isNotEmpty() }?.toDoubleOrNull()
private fun formatTwd(v: Double): String { private fun roundHalfUp(v: Double): BigDecimal =
val rounded = kotlin.math.round(v * 100.0) / 100.0 BigDecimal.valueOf(v).setScale(0, RoundingMode.HALF_UP)
return "%,.2f".format(rounded)
} private fun formatTwd(v: Double): String =
DecimalFormat("#,##0").format(roundHalfUp(v))
private fun formatTwdRaw(v: Double): String = private fun formatTwdRaw(v: Double): String =
"%.2f".format(kotlin.math.round(v * 100.0) / 100.0) roundHalfUp(v).toPlainString()
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {

Binary file not shown.

Binary file not shown.

View File

@@ -139,12 +139,9 @@ fn parse_amount(s: &str) -> Option<f64> {
} }
fn format_twd(v: f64) -> String { fn format_twd(v: f64) -> String {
let rounded = (v * 100.0).round() / 100.0; let rounded = v.round() as i64;
let sign = if rounded < 0.0 { "-" } else { "" }; let sign = if rounded < 0 { "-" } else { "" };
let abs = rounded.abs(); let int_str = rounded.unsigned_abs().to_string();
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 mut with_sep = String::new(); let mut with_sep = String::new();
for (i, ch) in int_str.chars().rev().enumerate() { for (i, ch) in int_str.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 { if i > 0 && i % 3 == 0 {
@@ -153,12 +150,11 @@ fn format_twd(v: f64) -> String {
with_sep.push(ch); with_sep.push(ch);
} }
let int_sep: String = with_sep.chars().rev().collect(); 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 { fn format_twd_raw(v: f64) -> String {
let rounded = (v * 100.0).round() / 100.0; format!("{}", v.round() as i64)
format!("{rounded:.2}")
} }
struct App { struct App {