feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身): - 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB - 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝 - OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户 - settingsStore 持久化 ocrModelVersion / ocrModelDir - 自动化页集成模型状态检查与一键下载 UI OCR 三层独立控制: - OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关 - 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision) - OCR 处理增加耗时与字符数日志 日志系统升级: - logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log) - 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx - 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理 - _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号) 原生浮层 UI 主题同步(P6): - 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生 - 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取 - FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal - FloatingHelper / FloatingTip 同步适配 - _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送 UI 与组件增强: - FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange - 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗 - 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择 - AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型 - 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗) 去重优化: - 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复 - DedupResult 新增 matchedItem 返回匹配对比项 文档重构: - README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表) - 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图) - 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md - UI 重设计文档(design spec + p1-p8)移入 docs/design/ 其他: - i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键 - ppocr Config Plugin 修复 import 注入去重;size-optimization 增强 - 新增测试:logger.test.ts、floating-ui-config.test.ts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
@@ -7,6 +8,7 @@ import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
/**
|
||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||
@@ -20,6 +22,10 @@ import com.facebook.react.bridge.ReadableArray
|
||||
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AccessibilityBridge"
|
||||
}
|
||||
|
||||
init {
|
||||
ReactContextHolder.context = reactContext
|
||||
}
|
||||
@@ -188,17 +194,18 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
|
||||
@ReactMethod
|
||||
fun showFloatingBill(
|
||||
amount: String,
|
||||
merchant: String,
|
||||
time: String,
|
||||
packageName: String,
|
||||
amount: String,
|
||||
merchant: String,
|
||||
time: String,
|
||||
packageName: String,
|
||||
categories: ReadableArray,
|
||||
accounts: ReadableArray,
|
||||
direction: String,
|
||||
currency: String,
|
||||
draftId: String,
|
||||
promise: Promise
|
||||
) {
|
||||
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
|
||||
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
|
||||
if (context == null) {
|
||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||
return
|
||||
@@ -222,7 +229,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
|
||||
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction, currency)
|
||||
floatingView.show()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
@@ -247,4 +254,71 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
|
||||
@ReactMethod
|
||||
fun copyOcrModelsFromAssets(promise: Promise) {
|
||||
try {
|
||||
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
|
||||
if (!destDir.exists()) destDir.mkdirs()
|
||||
val assetManager = reactContext.assets
|
||||
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
|
||||
for (filename in files) {
|
||||
val destFile = java.io.File(destDir, filename)
|
||||
if (destFile.exists() && destFile.length() > 0) continue
|
||||
assetManager.open(filename).use { input ->
|
||||
java.io.FileOutputStream(destFile).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
promise.resolve(destDir.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("COPY_MODEL_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages,避免 API level lint 错误)。 */
|
||||
@ReactMethod
|
||||
fun isNotificationListenerEnabled(promise: Promise) {
|
||||
try {
|
||||
// 通过 Settings.Secure 读取系统设置(公开 API,无需反射,不受 hidden API 限制)
|
||||
// enabled_notification_listeners 格式:
|
||||
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
|
||||
val flat = android.provider.Settings.Secure.getString(
|
||||
reactContext.contentResolver,
|
||||
"enabled_notification_listeners"
|
||||
) ?: ""
|
||||
val myPkg = reactContext.packageName
|
||||
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查悬浮窗权限(SYSTEM_ALERT_WINDOW)是否已授予。 */
|
||||
@ReactMethod
|
||||
fun canDrawOverlays(promise: Promise) {
|
||||
try {
|
||||
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
|
||||
} catch (e: Exception) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发浮层 UI 配置(颜色 + 文案)。
|
||||
* JS 侧从 theme tokens + i18n 构建 config,
|
||||
* 原生存 SharedPreferences,三个浮层组件读取。
|
||||
*/
|
||||
@ReactMethod
|
||||
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
|
||||
try {
|
||||
FloatingUiConfigStore.save(reactContext, config)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("CONFIG_SAVE_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
handler.post {
|
||||
val shouldShow = floatingBallEnabled
|
||||
&& pkg != null
|
||||
&& !filterPackage(pkg, "")
|
||||
&& !filterPackage(pkg, topActivity ?: "")
|
||||
&& !isLandscape()
|
||||
|
||||
if (shouldShow) {
|
||||
@@ -380,11 +380,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
val pkg = topPackage ?: return
|
||||
val activity = topActivity ?: ""
|
||||
val sig = "$pkg|$activity"
|
||||
val uiLabels = FloatingUiConfigStore.load(this).labels
|
||||
if (pageSignatures.add(sig)) {
|
||||
savePageSignatures()
|
||||
Log.i(TAG, "已记住页面: $sig")
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本
|
||||
@@ -416,7 +417,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
} else {
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
android.widget.Toast.makeText(this, "${uiLabels.pageSignatureExists}:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,6 +508,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
val p = pkg.lowercase()
|
||||
// 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
|
||||
if (pkg == packageName) {
|
||||
if (className.isEmpty()) return false
|
||||
return !className.endsWith(".MainActivity")
|
||||
}
|
||||
// 桌面启动器(悬浮球在桌面无意义)
|
||||
|
||||
@@ -17,9 +17,11 @@ import android.widget.LinearLayout
|
||||
import android.text.InputType
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
||||
* P6 重设计:颜色/文案走 FloatingUiConfigStore,新增币种 chip,金额校验改正则+BigDecimal。
|
||||
*/
|
||||
class FloatingBillView(
|
||||
private val context: Context,
|
||||
@@ -30,12 +32,27 @@ class FloatingBillView(
|
||||
private val packageName: String,
|
||||
private val categories: List<Map<String, String>> = emptyList(),
|
||||
private val accounts: List<String> = emptyList(),
|
||||
private val initialDirection: String = "expense"
|
||||
private val initialDirection: String = "expense",
|
||||
private val initialCurrency: String = "CNY"
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingBillView"
|
||||
private val DEFAULT_CURRENCY_LIST = listOf("CNY", "USD", "HKD", "JPY", "EUR", "GBP")
|
||||
}
|
||||
|
||||
// 从 JS 下发的配置读取颜色与文案(缺失字段回退默认值,保证旧 JS 行为不变)
|
||||
private val config = FloatingUiConfigStore.load(context)
|
||||
|
||||
// 显示币种列表;若 initialCurrency 不在默认列表中则临时插入首位
|
||||
private val currencyList: List<String> = run {
|
||||
val list = DEFAULT_CURRENCY_LIST.toMutableList()
|
||||
if (!list.contains(initialCurrency)) {
|
||||
list.add(0, initialCurrency)
|
||||
}
|
||||
list
|
||||
}
|
||||
private var currentCurrency: String = initialCurrency
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var view: View? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@@ -49,6 +66,20 @@ class FloatingBillView(
|
||||
private var accountContainer: LinearLayout? = null
|
||||
private var saveBtn: Button? = null
|
||||
|
||||
// 预解析常用颜色(parseColorOr 异常时回退默认值)
|
||||
private val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||||
private val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||
private val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||||
private val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||||
private val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||||
private val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||||
private val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||||
private val colorIncome = FloatingUiConfigStore.parseColorOr(config.colors.income, "#FF10B981")
|
||||
private val colorExpense = FloatingUiConfigStore.parseColorOr(config.colors.expense, "#FFE11D48")
|
||||
private val colorTransfer = FloatingUiConfigStore.parseColorOr(config.colors.transfer, "#FF10B981")
|
||||
|
||||
// 容器边框直接使用 config border(不再从 accent 派生 alpha)
|
||||
|
||||
private fun dp(value: Float): Int {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return (value * density).toInt()
|
||||
@@ -74,29 +105,34 @@ class FloatingBillView(
|
||||
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
||||
|
||||
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
||||
val overlayType = if (context is android.accessibilityservice.AccessibilityService) {
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
|
||||
} else {
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
}
|
||||
val windowParams = WindowManager.LayoutParams(
|
||||
dp(310f),
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
overlayType,
|
||||
0, // 0 标志代表可获取焦点
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||
y = dp(12f) // 尽量靠下以留出上方账单对照区
|
||||
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全区
|
||||
}
|
||||
|
||||
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
|
||||
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp,1dp 描边
|
||||
val containerBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
|
||||
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
|
||||
cornerRadius = dp(16f).toFloat()
|
||||
setColor(colorCardBg)
|
||||
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
|
||||
}
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = containerBg
|
||||
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
|
||||
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
@@ -114,9 +150,9 @@ class FloatingBillView(
|
||||
}
|
||||
|
||||
val titleText = TextView(context).apply {
|
||||
text = "调整交易草稿"
|
||||
textSize = 12f
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
text = config.labels.billTitle
|
||||
textSize = 13f
|
||||
setTextColor(colorFgPrimary)
|
||||
paint.isFakeBoldText = true
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
@@ -126,12 +162,12 @@ class FloatingBillView(
|
||||
val segmentContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(4f).toFloat()
|
||||
setColor(0x20FFFFFF.toInt()) // 12% white opacity
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
|
||||
}
|
||||
}
|
||||
|
||||
val tabTexts = listOf("支出", "收入", "转账")
|
||||
val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
|
||||
val tabDirections = listOf("expense", "income", "transfer")
|
||||
val tabViews = mutableListOf<TextView>()
|
||||
|
||||
@@ -139,11 +175,11 @@ class FloatingBillView(
|
||||
for (i in tabViews.indices) {
|
||||
val active = tabDirections[i] == currentDirection
|
||||
tabViews[i].apply {
|
||||
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
background = if (active) {
|
||||
GradientDrawable().apply {
|
||||
cornerRadius = dp(4f).toFloat()
|
||||
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setColor(colorAccent)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
@@ -173,40 +209,75 @@ class FloatingBillView(
|
||||
headerLayout.addView(segmentContainer)
|
||||
container.addView(headerLayout)
|
||||
|
||||
// 金额编辑
|
||||
// 金额行:币种 chip(左)+ 金额输入框(右)
|
||||
val amountLabel = TextView(context).apply {
|
||||
text = "金额"
|
||||
textSize = 9f
|
||||
text = config.labels.amountLabel
|
||||
textSize = 10f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, dp(4f), 0, dp(1f))
|
||||
setTextColor(colorFgSecondary)
|
||||
setPadding(0, dp(8f), 0, dp(1f))
|
||||
}
|
||||
container.addView(amountLabel)
|
||||
|
||||
val amountInput = EditText(context).apply {
|
||||
textSize = 13f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt()) // 25% 半透底色
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
val amountRow = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(amountInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
||||
|
||||
// 币种 chip
|
||||
val currencyChip = TextView(context).apply {
|
||||
text = currentCurrency
|
||||
textSize = 11f
|
||||
setTextColor(colorFgSecondary)
|
||||
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
|
||||
gravity = Gravity.CENTER
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(12f).toFloat()
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
setOnClickListener {
|
||||
val idx = currencyList.indexOf(currentCurrency)
|
||||
currentCurrency = currencyList[(idx + 1) % currencyList.size]
|
||||
text = currentCurrency
|
||||
}
|
||||
}
|
||||
amountRow.addView(currencyChip)
|
||||
|
||||
val amountInput = EditText(context).apply {
|
||||
textSize = 13f
|
||||
setTextColor(colorFgPrimary)
|
||||
setHintTextColor(colorFgSecondary)
|
||||
background = GradientDrawable().apply {
|
||||
setColor(colorInputBg)
|
||||
cornerRadius = dp(10f).toFloat()
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
).apply {
|
||||
leftMargin = dp(8f)
|
||||
}
|
||||
}
|
||||
amountRow.addView(amountInput)
|
||||
container.addView(amountRow)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
|
||||
// 商户编辑
|
||||
val merchantLabel = TextView(context).apply {
|
||||
text = "交易对手"
|
||||
textSize = 9f
|
||||
text = config.labels.payeeLabel
|
||||
textSize = 10f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setTextColor(colorFgSecondary)
|
||||
setPadding(0, 0, 0, dp(1f))
|
||||
}
|
||||
container.addView(merchantLabel)
|
||||
@@ -214,56 +285,57 @@ class FloatingBillView(
|
||||
val merchantInput = EditText(context).apply {
|
||||
setText(merchant)
|
||||
textSize = 12f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
setTextColor(colorFgPrimary)
|
||||
setHintTextColor(colorFgSecondary)
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt())
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
cornerRadius = dp(10f).toFloat()
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(merchantInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
|
||||
// 叙述备注编辑
|
||||
val narrationLabel = TextView(context).apply {
|
||||
text = "描述/备注"
|
||||
textSize = 9f
|
||||
text = config.labels.narrationLabel
|
||||
textSize = 10f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setTextColor(colorFgSecondary)
|
||||
setPadding(0, 0, 0, dp(1f))
|
||||
}
|
||||
container.addView(narrationLabel)
|
||||
|
||||
val narrationInput = EditText(context).apply {
|
||||
hint = "输入交易叙述"
|
||||
setHintTextColor(0xFF6B7280.toInt())
|
||||
hint = config.labels.narrationHint
|
||||
setHintTextColor(colorFgSecondary)
|
||||
textSize = 12f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
setTextColor(colorFgPrimary)
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt())
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
cornerRadius = dp(10f).toFloat()
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
setPadding(dp(10f), dp(6f), dp(10f), dp(6f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(narrationInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
|
||||
|
||||
// Row 1 分类/转入选择
|
||||
categoryLabel = TextView(context).apply {
|
||||
text = "交易分类"
|
||||
textSize = 9f
|
||||
text = config.labels.categoryExpense
|
||||
textSize = 10f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setTextColor(colorFgSecondary)
|
||||
setPadding(0, 0, 0, dp(2f))
|
||||
}
|
||||
container.addView(categoryLabel)
|
||||
@@ -281,14 +353,14 @@ class FloatingBillView(
|
||||
)
|
||||
}
|
||||
container.addView(categoryScroll)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
|
||||
|
||||
// Row 2 资金出入账户选择
|
||||
accountLabel = TextView(context).apply {
|
||||
text = "资金来源账户"
|
||||
textSize = 9f
|
||||
text = config.labels.accountExpense
|
||||
textSize = 10f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setTextColor(colorFgSecondary)
|
||||
setPadding(0, 0, 0, dp(2f))
|
||||
}
|
||||
container.addView(accountLabel)
|
||||
@@ -306,7 +378,7 @@ class FloatingBillView(
|
||||
)
|
||||
}
|
||||
container.addView(accountScroll)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
|
||||
|
||||
// 底部操作栏
|
||||
val btnContainer = LinearLayout(context).apply {
|
||||
@@ -320,16 +392,16 @@ class FloatingBillView(
|
||||
|
||||
// 1) 打开应用按钮
|
||||
val openAppBtn = Button(context).apply {
|
||||
text = "打开应用"
|
||||
setTextColor(0xFFD1D5DB.toInt())
|
||||
text = config.labels.openApp
|
||||
setTextColor(colorFgPrimary)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF1F2937.toInt())
|
||||
setStroke(dp(1f), 0xFF374151.toInt())
|
||||
cornerRadius = dp(12f).toFloat()
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
textSize = 11f
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
|
||||
setOnClickListener {
|
||||
val newAmount = amountInput.text.toString().trim()
|
||||
val newPayee = merchantInput.text.toString().trim()
|
||||
@@ -342,18 +414,17 @@ class FloatingBillView(
|
||||
}
|
||||
|
||||
// 2) 忽略按钮
|
||||
val cancelBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF1F2937.toInt())
|
||||
setStroke(dp(1f), 0xFF374151.toInt())
|
||||
}
|
||||
val cancelBtn = Button(context).apply {
|
||||
text = "忽略"
|
||||
setTextColor(0xFFD1D5DB.toInt())
|
||||
background = cancelBg
|
||||
text = config.labels.dismiss
|
||||
setTextColor(colorFgPrimary)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(12f).toFloat()
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
textSize = 11f
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1f).apply { rightMargin = dp(6f) }
|
||||
setOnClickListener {
|
||||
sendCancelEvent()
|
||||
dismiss()
|
||||
@@ -361,18 +432,17 @@ class FloatingBillView(
|
||||
}
|
||||
|
||||
// 3) 确认入账按钮
|
||||
val saveBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF5E6AD2.toInt())
|
||||
}
|
||||
saveBtn = Button(context).apply {
|
||||
text = "确认入账"
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = saveBg
|
||||
text = config.labels.confirm
|
||||
setTextColor(colorAccentFg)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(12f).toFloat()
|
||||
setColor(colorAccent)
|
||||
}
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(40f), 1.3f)
|
||||
setOnClickListener {
|
||||
val newAmount = amountInput.text.toString().trim()
|
||||
val newPayee = merchantInput.text.toString().trim()
|
||||
@@ -388,21 +458,23 @@ class FloatingBillView(
|
||||
btnContainer.addView(saveBtn)
|
||||
container.addView(btnContainer)
|
||||
|
||||
// 构建金额安全校验
|
||||
// 金额安全校验(BigDecimal + 正则)
|
||||
val amountWatcher = object : android.text.TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: android.text.Editable?) {
|
||||
try {
|
||||
val text = s?.toString()?.trim() ?: ""
|
||||
val value = text.toDoubleOrNull()
|
||||
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
|
||||
val pattern = Regex("^\\d+(\\.\\d{1,2})?$")
|
||||
val value = text.toBigDecimalOrNull()
|
||||
val isValid = pattern.matches(text) && value != null && value > BigDecimal.ZERO
|
||||
saveBtn?.isEnabled = isValid
|
||||
saveBtn?.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(12f).toFloat()
|
||||
setColor(if (isValid) colorAccent else colorInputBg)
|
||||
}
|
||||
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "金额校验异常", e)
|
||||
saveBtn?.isEnabled = false
|
||||
@@ -419,7 +491,7 @@ class FloatingBillView(
|
||||
|
||||
view = container
|
||||
windowManager.addView(view, windowParams)
|
||||
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
|
||||
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
|
||||
|
||||
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
||||
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
@@ -438,11 +510,11 @@ class FloatingBillView(
|
||||
false
|
||||
}
|
||||
|
||||
// 15 秒无操作自动消失(如果用户没有交互的话)
|
||||
// 30 秒无操作自动消失(如果用户没有交互的话)
|
||||
handler.postDelayed({
|
||||
sendCancelEvent()
|
||||
dismiss()
|
||||
}, 15000L)
|
||||
}, 30000L)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
||||
@@ -459,7 +531,7 @@ class FloatingBillView(
|
||||
|
||||
// === 1. 绘制第一行 (分类 / 转入) ===
|
||||
if (currentDirection == "expense") {
|
||||
categoryLabel?.text = "交易分类"
|
||||
categoryLabel?.text = config.labels.categoryExpense
|
||||
val filteredCats = categories.filter { it["type"] == "expense" }
|
||||
for (cat in filteredCats) {
|
||||
val catAccount = cat["account"] ?: ""
|
||||
@@ -470,14 +542,14 @@ class FloatingBillView(
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) setColor(colorAccent)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,18 +564,18 @@ class FloatingBillView(
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) setColor(colorAccent)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
|
||||
} else if (currentDirection == "income") {
|
||||
categoryLabel?.text = "收入分类"
|
||||
categoryLabel?.text = config.labels.categoryIncome
|
||||
val filteredCats = categories.filter { it["type"] == "income" }
|
||||
for (cat in filteredCats) {
|
||||
val catAccount = cat["account"] ?: ""
|
||||
@@ -514,14 +586,14 @@ class FloatingBillView(
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) setColor(colorAccent)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,19 +608,19 @@ class FloatingBillView(
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) setColor(colorAccent)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
|
||||
} else {
|
||||
// transfer
|
||||
categoryLabel?.text = "转入账户"
|
||||
// transfer — 第一行 = 转入账户,选中色 = transfer
|
||||
categoryLabel?.text = config.labels.transferTarget
|
||||
for (acct in accounts) {
|
||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||
val chip = createChipView(shortName)
|
||||
@@ -557,14 +629,14 @@ class FloatingBillView(
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF10B981.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) setColor(colorTransfer)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,26 +651,29 @@ class FloatingBillView(
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF10B981.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) setColor(colorTransfer)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// === 2. 绘制第二行 (资金账户) ===
|
||||
if (currentDirection == "expense") {
|
||||
accountLabel?.text = "资金来源"
|
||||
accountLabel?.text = config.labels.accountExpense
|
||||
} else if (currentDirection == "income") {
|
||||
accountLabel?.text = "存入账户"
|
||||
accountLabel?.text = config.labels.accountIncome
|
||||
} else {
|
||||
accountLabel?.text = "转出账户"
|
||||
accountLabel?.text = config.labels.accountTransfer
|
||||
}
|
||||
|
||||
// 账户行选中色按方向:expense→expense色,income→income色,transfer→expense色
|
||||
val accountSelectedColor = if (currentDirection == "income") colorIncome else colorExpense
|
||||
|
||||
for (acct in accounts) {
|
||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||
val chip = createChipView(shortName)
|
||||
@@ -607,15 +682,15 @@ class FloatingBillView(
|
||||
for (pair in accountChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) {
|
||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
||||
setColor(accountSelectedColor)
|
||||
} else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,14 +706,14 @@ class FloatingBillView(
|
||||
for (p in categoryChips) {
|
||||
val act = p.first == selectedCategoryAccount
|
||||
p.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (act) setColor(0xFF10B981.toInt())
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (act) setColor(colorTransfer)
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,15 +726,15 @@ class FloatingBillView(
|
||||
accountChips.forEach { pair ->
|
||||
val active = pair.first == selectedSourceAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
if (active) {
|
||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
||||
setColor(accountSelectedColor)
|
||||
} else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
setColor(colorInputBg)
|
||||
setStroke(dp(1f), colorBorder)
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,6 +752,7 @@ class FloatingBillView(
|
||||
putString("time", time)
|
||||
putString("direction", currentDirection)
|
||||
putString("packageName", packageName)
|
||||
putString("currency", currentCurrency)
|
||||
putBoolean("confirmed", true)
|
||||
putBoolean("editRequested", false)
|
||||
putBoolean("isManualEdit", true)
|
||||
@@ -703,6 +779,7 @@ class FloatingBillView(
|
||||
putString("time", time)
|
||||
putString("direction", currentDirection)
|
||||
putString("packageName", packageName)
|
||||
putString("currency", currentCurrency)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
@@ -736,4 +813,13 @@ class FloatingBillView(
|
||||
} catch (_: Exception) {}
|
||||
view = null
|
||||
}
|
||||
|
||||
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
|
||||
private fun String.toBigDecimalOrNull(): BigDecimal? {
|
||||
return try {
|
||||
BigDecimal(this)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ class FloatingHelper(
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingHelper"
|
||||
private const val PREFS_NAME = "billing_accessibility_prefs"
|
||||
private const val KEY_X = "floating_ball_x"
|
||||
private const val KEY_Y = "floating_ball_y"
|
||||
private var lastX = 0
|
||||
private var lastY = 400
|
||||
}
|
||||
@@ -41,7 +44,7 @@ class FloatingHelper(
|
||||
private val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
@@ -57,6 +60,25 @@ class FloatingHelper(
|
||||
try {
|
||||
val context = service
|
||||
|
||||
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
|
||||
val config = FloatingUiConfigStore.load(service)
|
||||
|
||||
// 预解析颜色
|
||||
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||||
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||||
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||||
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||||
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||||
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||||
|
||||
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
|
||||
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
params.x = prefs.getInt(KEY_X, lastX)
|
||||
params.y = prefs.getInt(KEY_Y, lastY)
|
||||
lastX = params.x
|
||||
lastY = params.y
|
||||
|
||||
val density = service.resources.displayMetrics.density
|
||||
fun dp(value: Float) = (value * density).toInt()
|
||||
|
||||
@@ -79,25 +101,31 @@ class FloatingHelper(
|
||||
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
|
||||
}
|
||||
|
||||
// 边缘指示竖线:accent 色取 RGB + 固定 0xB0 alpha(保持 70% 透明)
|
||||
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
|
||||
val indicatorView = View(context).apply {
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(1.5f).toFloat() // 高度圆润
|
||||
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
|
||||
cornerRadius = dp(1.5f).toFloat()
|
||||
setColor(indicatorColor)
|
||||
}
|
||||
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
||||
gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘
|
||||
gravity = Gravity.CENTER_VERTICAL or Gravity.START
|
||||
}
|
||||
}
|
||||
bubbleLayout.addView(indicatorView)
|
||||
bubbleView = bubbleLayout
|
||||
|
||||
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
||||
// 菜单背景:实色 cardBg(per plan §T4)
|
||||
val menuBgColor = colorCardBg
|
||||
// 菜单描边:border 色 + 半透明 alpha
|
||||
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
|
||||
val menuBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(10f).toFloat()
|
||||
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
|
||||
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
|
||||
setColor(menuBgColor)
|
||||
setStroke(dp(0.8f), menuBorderColor)
|
||||
}
|
||||
|
||||
menuView = LinearLayout(context).apply {
|
||||
@@ -107,30 +135,37 @@ class FloatingHelper(
|
||||
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
|
||||
visibility = View.GONE
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
dp(96f), // ultra-compact width: 96dp
|
||||
dp(96f),
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
leftMargin = dp(4f) // spacing with indicator line
|
||||
leftMargin = dp(4f)
|
||||
}
|
||||
}
|
||||
|
||||
// Vector icons
|
||||
val sizePx = dp(14f)
|
||||
val strokePx = dp(1.4f).toFloat()
|
||||
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
|
||||
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
|
||||
// ScanIconDrawable:accentFg 色(在 accent 底按钮上可见),laserColor 保留红色语义
|
||||
val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
|
||||
// PinIconDrawable:fgSecondary 色
|
||||
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
|
||||
|
||||
// Button 1: 识别账单(主操作:accent 底 accentFg 字)
|
||||
// 背景常时:accent 色取 RGB + 固定 0x1F alpha(约 12%)
|
||||
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
|
||||
// 按下加深:accent 色取 RGB + 固定 0x40 alpha(约 25%)
|
||||
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
|
||||
|
||||
// Button 1: "识别账单"
|
||||
val btnOcr = TextView(context).apply {
|
||||
text = "识别账单"
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
text = config.labels.ballOcr
|
||||
setTextColor(colorAccentFg)
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dp(6f), 0, dp(6f), 0)
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
|
||||
setColor(btnOcrBgNormal)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
@@ -145,13 +180,13 @@ class FloatingHelper(
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x405E6AD2.toInt())
|
||||
setColor(btnOcrBgPressed)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x1F5E6AD2.toInt())
|
||||
setColor(btnOcrBgNormal)
|
||||
}
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
view.performClick()
|
||||
@@ -165,17 +200,22 @@ class FloatingHelper(
|
||||
}
|
||||
}
|
||||
|
||||
// Button 2: "记住此页"
|
||||
// Button 2: 记住此页(次要操作:inputBg 底 fgPrimary 字,per plan §T4)
|
||||
// 背景常时:inputBg 色取 RGB + 固定 0x15 alpha
|
||||
val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt()
|
||||
// 按下加深:inputBg 色取 RGB + 固定 0x30 alpha
|
||||
val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt()
|
||||
|
||||
val btnRemember = TextView(context).apply {
|
||||
text = "记住此页"
|
||||
setTextColor(0xFFE5E7EB.toInt())
|
||||
text = config.labels.ballRemember
|
||||
setTextColor(colorFgPrimary)
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(dp(6f), 0, dp(6f), 0)
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
|
||||
setColor(btnRememberBgNormal)
|
||||
}
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
@@ -188,13 +228,13 @@ class FloatingHelper(
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x30FFFFFF.toInt())
|
||||
setColor(btnRememberBgPressed)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
view.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(7f).toFloat()
|
||||
setColor(0x15FFFFFF.toInt())
|
||||
setColor(btnRememberBgNormal)
|
||||
}
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
view.performClick()
|
||||
@@ -206,9 +246,9 @@ class FloatingHelper(
|
||||
setOnClickListener {
|
||||
try {
|
||||
service.rememberCurrentPage()
|
||||
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
|
||||
FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
|
||||
} catch (e: Exception) {
|
||||
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
|
||||
FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
|
||||
}
|
||||
collapse()
|
||||
}
|
||||
@@ -246,7 +286,7 @@ class FloatingHelper(
|
||||
params.x = initialX + dx
|
||||
params.y = initialY + dy
|
||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||
|
||||
|
||||
lastX = params.x
|
||||
lastY = params.y
|
||||
true
|
||||
@@ -257,14 +297,20 @@ class FloatingHelper(
|
||||
} else {
|
||||
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
|
||||
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
||||
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘
|
||||
params.x = -dp(1.5f)
|
||||
} else {
|
||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
|
||||
}
|
||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||
|
||||
|
||||
lastX = params.x
|
||||
lastY = params.y
|
||||
|
||||
// 位置持久化:吸附后写入 SharedPreferences
|
||||
prefs.edit()
|
||||
.putInt(KEY_X, params.x)
|
||||
.putInt(KEY_Y, params.y)
|
||||
.apply()
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -6,14 +6,10 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 浮窗提示(plan.md「3.8 浮窗账单提示」)。
|
||||
@@ -47,6 +43,10 @@ class FloatingTip(
|
||||
/** 显示浮窗提示。 */
|
||||
fun show() {
|
||||
try {
|
||||
val config = FloatingUiConfigStore.load(context)
|
||||
val bgColor = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF000000") or 0xFF000000.toInt() // 强制不透明化
|
||||
val textColor = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||||
|
||||
val layoutParams = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
@@ -65,42 +65,20 @@ class FloatingTip(
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setBackgroundColor(0xF0333333.toInt())
|
||||
setBackgroundColor(bgColor)
|
||||
setPadding(32, 16, 32, 16)
|
||||
}
|
||||
val text = TextView(context).apply {
|
||||
text = message
|
||||
textSize = 13f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
}
|
||||
// 倒计时进度条
|
||||
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
|
||||
progress.max = 100
|
||||
progress.progress = 100
|
||||
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
|
||||
setMargins(16, 0, 0, 0)
|
||||
setTextColor(textColor)
|
||||
}
|
||||
container.addView(text)
|
||||
container.addView(progress)
|
||||
view = container
|
||||
|
||||
windowManager.addView(view, layoutParams)
|
||||
Log.d(TAG, "FloatingTip 显示: $message")
|
||||
|
||||
// 倒计时动画(300ms 内 progress 从 100 到 0)
|
||||
val steps = 30
|
||||
val stepMs = durationMs / steps
|
||||
var currentStep = 0
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
currentStep++
|
||||
progress.progress = 100 - (currentStep * 100 / steps)
|
||||
if (currentStep < steps) {
|
||||
handler.postDelayed(this, stepMs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
handler.postDelayed({ dismiss() }, durationMs)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
|
||||
@@ -121,7 +99,7 @@ class FloatingTip(
|
||||
*/
|
||||
class RepeatToast(private val context: Context, private val message: String) {
|
||||
fun show() {
|
||||
val tip = FloatingTip(context, "⚠ $message", FloatingTip.TipPosition.TOP, 2000L)
|
||||
val tip = FloatingTip(context, message, FloatingTip.TipPosition.TOP, 2000L)
|
||||
tip.show()
|
||||
Log.d("RepeatToast", "重复提示: $message")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 浮层 UI 颜色契约(docs/ui-redesign-p6-plan.md「FloatingUiConfig 契约」)。
|
||||
* 字段名(camelCase)与 src/services/floatingUiConfig.ts 的 FloatingUiConfig.colors 一一对应。
|
||||
* 默认值 = 现状硬编码颜色(JS 未推送时行为不变),注释标明出处。
|
||||
*/
|
||||
data class FloatingUiConfigColors(
|
||||
/** 按钮/选中态背景。 */ val accent: String = "#FF5E6AD2", // 原 FloatingBillView saveBg / 分段选中态
|
||||
/** accent 上的文字色。 */ val accentFg: String = "#FFFFFFFF", // 原 FloatingBillView 按钮文字色
|
||||
/** 卡片底色(原 0x8C050506 半透明,默认实色化到 F0 保证可读)。 */
|
||||
val cardBg: String = "#F0050506", // 原 FloatingBillView containerBg 0x8C050506
|
||||
/** 输入框/未选中 chip 底色。 */ val inputBg: String = "#4012131A", // 原 FloatingBillView 输入框底 0x4012131A
|
||||
val fgPrimary: String = "#FFFFFFFF", // 原 FloatingBillView 输入框文字色
|
||||
val fgSecondary: String = "#FF9CA3AF", // 原 FloatingBillView 未选中 tab/chip 文字色
|
||||
val border: String = "#80222433", // 原 FloatingBillView 输入框/chip 描边 0x80222433
|
||||
/** financial.income。 */ val income: String = "#FF10B981", // 原 FloatingBillView 收入/转账 chip 选中色
|
||||
/** financial.expense。 */ val expense: String = "#FFE11D48", // 原 FloatingBillView 支出账户 chip 选中色
|
||||
/** financial.transfer。 */ val transfer: String = "#FF10B981", // 原 FloatingBillView 转账转入 chip 选中色
|
||||
)
|
||||
|
||||
/**
|
||||
* 浮层 UI 文案契约。
|
||||
* 字段名(camelCase)与 src/services/floatingUiConfig.ts 的 FloatingUiConfig.labels 一一对应。
|
||||
* 默认值 = 现状中文硬编码文案(去 emoji)。
|
||||
*/
|
||||
data class FloatingUiConfigLabels(
|
||||
/** 浮窗标题。 */ val billTitle: String = "调整交易草稿",
|
||||
val dirExpense: String = "支出",
|
||||
val dirIncome: String = "收入",
|
||||
val dirTransfer: String = "转账",
|
||||
val amountLabel: String = "金额",
|
||||
val payeeLabel: String = "交易对手",
|
||||
val narrationLabel: String = "描述/备注",
|
||||
val narrationHint: String = "输入交易叙述",
|
||||
/** 支出方向分类行标签。 */ val categoryExpense: String = "交易分类",
|
||||
/** 收入方向分类行标签。 */ val categoryIncome: String = "收入分类",
|
||||
/** 转账方向第一行标签。 */ val transferTarget: String = "转入账户",
|
||||
/** 支出方向账户行标签。 */ val accountExpense: String = "资金来源",
|
||||
/** 收入方向账户行标签。 */ val accountIncome: String = "存入账户",
|
||||
/** 转账方向账户行标签。 */ val accountTransfer: String = "转出账户",
|
||||
val openApp: String = "打开应用",
|
||||
val dismiss: String = "忽略",
|
||||
val confirm: String = "确认入账",
|
||||
/** 悬浮球「识别账单」。 */ val ballOcr: String = "识别账单",
|
||||
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
|
||||
/** 记住页面成功提示(不含 emoji)。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
|
||||
/** 失败提示前缀(原生拼接 ': ' + e.message)。 */ val rememberFail: String = "记录失败",
|
||||
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
|
||||
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
|
||||
)
|
||||
|
||||
/** 原生浮层 UI 配置(JS 经 AccessibilityBridge.setFloatingUiConfig 下发)。 */
|
||||
data class FloatingUiConfig(
|
||||
val colors: FloatingUiConfigColors = FloatingUiConfigColors(),
|
||||
val labels: FloatingUiConfigLabels = FloatingUiConfigLabels(),
|
||||
)
|
||||
|
||||
/**
|
||||
* FloatingUiConfig 的 SharedPreferences 持久化(plan「原生持久化」节)。
|
||||
*
|
||||
* - 文件名 floating_ui_config,单键 config_json 存整个 JSON。
|
||||
* - save 采用合并策略:先 load 出现存 JSON,再覆盖传入键,避免部分更新丢字段。
|
||||
* - load 逐字段 optString 缺省回退 data class 默认值;任何异常返回全默认。
|
||||
*/
|
||||
object FloatingUiConfigStore {
|
||||
private const val TAG = "FloatingUiConfigStore"
|
||||
private const val PREFS = "floating_ui_config"
|
||||
private const val KEY = "config_json"
|
||||
|
||||
/** 契约内已知 colors 键(save 时只写这些键)。 */
|
||||
private val COLOR_KEYS = listOf(
|
||||
"accent", "accentFg", "cardBg", "inputBg", "fgPrimary",
|
||||
"fgSecondary", "border", "income", "expense", "transfer",
|
||||
)
|
||||
|
||||
/** 契约内已知 labels 键(save 时只写这些键)。 */
|
||||
private val LABEL_KEYS = listOf(
|
||||
"billTitle", "dirExpense", "dirIncome", "dirTransfer", "amountLabel",
|
||||
"payeeLabel", "narrationLabel", "narrationHint", "categoryExpense",
|
||||
"categoryIncome", "transferTarget", "accountExpense", "accountIncome",
|
||||
"accountTransfer", "openApp", "dismiss", "confirm", "ballOcr",
|
||||
"ballRemember", "rememberSuccess", "rememberFail", "pageRemembered",
|
||||
"pageSignatureExists",
|
||||
)
|
||||
|
||||
/** 保存(合并):先读出现存 JSON,再覆盖传入的已知键。 */
|
||||
fun save(context: Context, map: ReadableMap) {
|
||||
val root = readRawJson(context)
|
||||
|
||||
if (map.hasKey("colors")) {
|
||||
val colorsMap = map.getMap("colors")
|
||||
val colorsJson = root.optJSONObject("colors") ?: JSONObject().also { root.put("colors", it) }
|
||||
if (colorsMap != null) {
|
||||
for (key in COLOR_KEYS) {
|
||||
if (colorsMap.hasKey(key) && !colorsMap.isNull(key)) {
|
||||
colorsJson.put(key, colorsMap.getString(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (map.hasKey("labels")) {
|
||||
val labelsMap = map.getMap("labels")
|
||||
val labelsJson = root.optJSONObject("labels") ?: JSONObject().also { root.put("labels", it) }
|
||||
if (labelsMap != null) {
|
||||
for (key in LABEL_KEYS) {
|
||||
if (labelsMap.hasKey(key) && !labelsMap.isNull(key)) {
|
||||
labelsJson.put(key, labelsMap.getString(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY, root.toString())
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 读取配置;缺失字段回退默认值,任何异常返回全默认。 */
|
||||
fun load(context: Context): FloatingUiConfig {
|
||||
return try {
|
||||
val root = readRawJson(context)
|
||||
val defaultColors = FloatingUiConfigColors()
|
||||
val defaultLabels = FloatingUiConfigLabels()
|
||||
val colorsJson = root.optJSONObject("colors")
|
||||
val labelsJson = root.optJSONObject("labels")
|
||||
|
||||
val colors = FloatingUiConfigColors(
|
||||
accent = colorsJson.optStringOr("accent", defaultColors.accent),
|
||||
accentFg = colorsJson.optStringOr("accentFg", defaultColors.accentFg),
|
||||
cardBg = colorsJson.optStringOr("cardBg", defaultColors.cardBg),
|
||||
inputBg = colorsJson.optStringOr("inputBg", defaultColors.inputBg),
|
||||
fgPrimary = colorsJson.optStringOr("fgPrimary", defaultColors.fgPrimary),
|
||||
fgSecondary = colorsJson.optStringOr("fgSecondary", defaultColors.fgSecondary),
|
||||
border = colorsJson.optStringOr("border", defaultColors.border),
|
||||
income = colorsJson.optStringOr("income", defaultColors.income),
|
||||
expense = colorsJson.optStringOr("expense", defaultColors.expense),
|
||||
transfer = colorsJson.optStringOr("transfer", defaultColors.transfer),
|
||||
)
|
||||
val labels = FloatingUiConfigLabels(
|
||||
billTitle = labelsJson.optStringOr("billTitle", defaultLabels.billTitle),
|
||||
dirExpense = labelsJson.optStringOr("dirExpense", defaultLabels.dirExpense),
|
||||
dirIncome = labelsJson.optStringOr("dirIncome", defaultLabels.dirIncome),
|
||||
dirTransfer = labelsJson.optStringOr("dirTransfer", defaultLabels.dirTransfer),
|
||||
amountLabel = labelsJson.optStringOr("amountLabel", defaultLabels.amountLabel),
|
||||
payeeLabel = labelsJson.optStringOr("payeeLabel", defaultLabels.payeeLabel),
|
||||
narrationLabel = labelsJson.optStringOr("narrationLabel", defaultLabels.narrationLabel),
|
||||
narrationHint = labelsJson.optStringOr("narrationHint", defaultLabels.narrationHint),
|
||||
categoryExpense = labelsJson.optStringOr("categoryExpense", defaultLabels.categoryExpense),
|
||||
categoryIncome = labelsJson.optStringOr("categoryIncome", defaultLabels.categoryIncome),
|
||||
transferTarget = labelsJson.optStringOr("transferTarget", defaultLabels.transferTarget),
|
||||
accountExpense = labelsJson.optStringOr("accountExpense", defaultLabels.accountExpense),
|
||||
accountIncome = labelsJson.optStringOr("accountIncome", defaultLabels.accountIncome),
|
||||
accountTransfer = labelsJson.optStringOr("accountTransfer", defaultLabels.accountTransfer),
|
||||
openApp = labelsJson.optStringOr("openApp", defaultLabels.openApp),
|
||||
dismiss = labelsJson.optStringOr("dismiss", defaultLabels.dismiss),
|
||||
confirm = labelsJson.optStringOr("confirm", defaultLabels.confirm),
|
||||
ballOcr = labelsJson.optStringOr("ballOcr", defaultLabels.ballOcr),
|
||||
ballRemember = labelsJson.optStringOr("ballRemember", defaultLabels.ballRemember),
|
||||
rememberSuccess = labelsJson.optStringOr("rememberSuccess", defaultLabels.rememberSuccess),
|
||||
rememberFail = labelsJson.optStringOr("rememberFail", defaultLabels.rememberFail),
|
||||
pageRemembered = labelsJson.optStringOr("pageRemembered", defaultLabels.pageRemembered),
|
||||
pageSignatureExists = labelsJson.optStringOr("pageSignatureExists", defaultLabels.pageSignatureExists),
|
||||
)
|
||||
FloatingUiConfig(colors, labels)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "读取浮层 UI 配置失败,回退默认值: ${e.message}")
|
||||
FloatingUiConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析颜色字符串;失败/为空时解析 fallbackHex(fallback 也不合法则返回 Color.BLACK)。 */
|
||||
fun parseColorOr(value: String, fallbackHex: String): Int {
|
||||
if (value.isNotBlank()) {
|
||||
try {
|
||||
return Color.parseColor(value)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return try {
|
||||
Color.parseColor(fallbackHex)
|
||||
} catch (_: Exception) {
|
||||
Color.BLACK
|
||||
}
|
||||
}
|
||||
|
||||
/** 读出现存 JSON;无数据或解析失败返回空 JSONObject。 */
|
||||
private fun readRawJson(context: Context): JSONObject {
|
||||
return try {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY, null)
|
||||
if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw)
|
||||
} catch (_: Exception) {
|
||||
JSONObject()
|
||||
}
|
||||
}
|
||||
|
||||
/** optString 包装:JSON 为 null / 键缺失 / 空串时回退 default。 */
|
||||
private fun JSONObject?.optStringOr(key: String, default: String): String {
|
||||
if (this == null) return default
|
||||
val v = optString(key, default)
|
||||
return if (v.isBlank()) default else v
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,11 @@ function withAccessibilityService(config) {
|
||||
let content = modConfig.modResults.contents;
|
||||
|
||||
// 2a. 注入 import(AccessibilityBridgePackage)
|
||||
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
|
||||
content = content.replace(
|
||||
/^(package\s+[\w.]+;?\s*)$/m,
|
||||
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||
);
|
||||
}
|
||||
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
|
||||
content = content.replace(
|
||||
/^(package\s+[\w.]+;?\s*)$/m,
|
||||
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||
);
|
||||
|
||||
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
||||
if (!content.includes('add(AccessibilityBridgePackage())')) {
|
||||
@@ -166,7 +165,7 @@ function withAccessibilityService(config) {
|
||||
$: {
|
||||
'android:name': `${PACKAGE}.BillingAccessibilityService`,
|
||||
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
||||
'android:label': '账单识别',
|
||||
'android:label': '浮记-账单识别',
|
||||
'android:exported': 'false',
|
||||
},
|
||||
'intent-filter': [{
|
||||
|
||||
Reference in New Issue
Block a user