新增 Android 版(Kotlin + Compose,CLI-only 工具鏈)
This commit is contained in:
@@ -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
7
android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.kotlin/
|
||||||
|
captures/
|
||||||
93
android/README.md
Normal file
93
android/README.md
Normal 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 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` 直接跑在手機上看也沒差多少。
|
||||||
58
android/app/build.gradle.kts
Normal file
58
android/app/build.gradle.kts
Normal 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
2
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep Kotlin metadata for reflection (if any future libs need it)
|
||||||
|
-keepattributes *Annotation*
|
||||||
21
android/app/src/main/AndroidManifest.xml
Normal file
21
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
263
android/app/src/main/java/tw/local/botrates/MainActivity.kt
Normal file
263
android/app/src/main/java/tw/local/botrates/MainActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal 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
5
android/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
6
android/gradle.properties
Normal file
6
android/gradle.properties
Normal 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
|
||||||
18
android/settings.gradle.kts
Normal file
18
android/settings.gradle.kts
Normal 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")
|
||||||
Reference in New Issue
Block a user