DriftLedger/plugins/accessibility/android/FloatingBillView.kt
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform)
  - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction)
  - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取
  - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦

  ### 微信账单抓取:节点混淆绕过(核心突破)
  - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务
  - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验
  - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动
  - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错)
  - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录)

  ### 新 UI 组件体系
  - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失)
  - ErrorBoundary:React class 错误边界(降级 UI + 重试)
  - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker
  - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让
  - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField)
  - 新增 PeriodSwitcher、RangeStatsCard 独立组件

  ### 新 Hooks & 工具
  - useBottomInset — 统一底部安全区留白
  - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案)
  - sanitize.ts — 日志脱敏工具提取

  ### 账本增删改增强
  - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set
  - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean)
  - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」
  - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误)
  - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致

  ### OCR 原生模块增强
  - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」
  - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload
  - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别)
  - 图片缩放策略重命名(scaleDownForOcr → capLongEdge)

  ### OCR 模型按需下载
  - 移除了启动时自动下载 ~30MB OCR 模型的逻辑
  - 改为首次使用 OCR 时才触发下载

  ### 设置页重设计
  - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头)
  - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片

  ### 首页优化
  - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条)
  - 日期/金额格式化增加 locale 感知(zh/en)

  ### 通知管道增强
  - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除
  - 增加 debug 日志输出(过滤原因/包名)

  ### ESLint
  - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集)
  - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies

  ### 文档
  - accessibility-wechat-guide.md — 微信无障碍伪装完整方案
  - modal-keyboard-guide.md — 弹窗键盘避让方案
  - ocr-pipeline-guide.md — OCR 三层层级管线
  - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

826 lines
35 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.PixelFormat
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.TextView
import android.widget.EditText
import android.widget.HorizontalScrollView
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,
private val draftId: String,
private val amount: String,
private val merchant: String,
private val time: String,
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 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())
private var selectedCategoryAccount: String = ""
private var selectedSourceAccount: String = ""
private var currentDirection: String = "expense"
private var categoryLabel: TextView? = null
private var categoryContainer: LinearLayout? = null
private var accountLabel: TextView? = null
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()
}
private fun createChipView(text: String): TextView {
return TextView(context).apply {
this.text = text
textSize = 11f
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
rightMargin = dp(6f)
}
}
}
/** 显示悬浮窗。 */
fun show() {
try {
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,
overlayType,
0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(48f) // 留出底部导航栏空间 + 按钮/键盘安全区
}
// 2. 实色卡片背景(不再半透明磨砂),圆角 16dp1dp 描边
val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(16f).toFloat()
setColor(colorCardBg)
setStroke(dp(1f), colorBorder) // 使用 config border 颜色
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = containerBg
setPadding(dp(16f), dp(16f), dp(16f), dp(16f))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 顶部标题与方向选择器布局
val headerLayout = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
val titleText = TextView(context).apply {
text = config.labels.billTitle
textSize = 13f
setTextColor(colorFgPrimary)
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
headerLayout.addView(titleText)
// 三方向分段选择器 (支出 / 收入 / 转账)
val segmentContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
background = GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
setColor((colorFgPrimary and 0x00FFFFFF) or 0x12000000) // 7% fg overlay
}
}
val tabTexts = listOf(config.labels.dirExpense, config.labels.dirIncome, config.labels.dirTransfer)
val tabDirections = listOf("expense", "income", "transfer")
val tabViews = mutableListOf<TextView>()
fun updateTabStyle() {
for (i in tabViews.indices) {
val active = tabDirections[i] == currentDirection
tabViews[i].apply {
setTextColor(if (active) colorAccentFg else colorFgSecondary)
background = if (active) {
GradientDrawable().apply {
cornerRadius = dp(6f).toFloat()
setColor(colorAccent)
}
} else {
null
}
}
}
}
for (i in tabTexts.indices) {
val tab = TextView(context).apply {
text = tabTexts[i]
textSize = 10f
setPadding(dp(6f), dp(3f), dp(6f), dp(3f))
gravity = Gravity.CENTER
setOnClickListener {
if (currentDirection != tabDirections[i]) {
currentDirection = tabDirections[i]
updateTabStyle()
rebuildChips()
}
}
}
segmentContainer.addView(tab)
tabViews.add(tab)
}
updateTabStyle()
headerLayout.addView(segmentContainer)
container.addView(headerLayout)
// 金额行:币种 chip+ 金额输入框(右)
val amountLabel = TextView(context).apply {
text = config.labels.amountLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, dp(8f), 0, dp(1f))
}
container.addView(amountLabel)
val amountRow = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 币种 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 = config.labels.payeeLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(merchantLabel)
val merchantInput = EditText(context).apply {
setText(merchant)
textSize = 12f
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))
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(merchantInput)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
// 叙述备注编辑
val narrationLabel = TextView(context).apply {
text = config.labels.narrationLabel
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(1f))
}
container.addView(narrationLabel)
val narrationInput = EditText(context).apply {
hint = config.labels.narrationHint
setHintTextColor(colorFgSecondary)
textSize = 12f
setTextColor(colorFgPrimary)
background = GradientDrawable().apply {
setColor(colorInputBg)
cornerRadius = dp(10f).toFloat()
setStroke(dp(1f), colorBorder)
}
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(6f)) })
// Row 1 分类/转入选择
categoryLabel = TextView(context).apply {
text = config.labels.categoryExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(categoryLabel)
categoryContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val categoryScroll = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
addView(categoryContainer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(categoryScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(6f)) })
// Row 2 资金出入账户选择
accountLabel = TextView(context).apply {
text = config.labels.accountExpense
textSize = 10f
paint.isFakeBoldText = true
setTextColor(colorFgSecondary)
setPadding(0, 0, 0, dp(2f))
}
container.addView(accountLabel)
accountContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
}
val accountScroll = HorizontalScrollView(context).apply {
isHorizontalScrollBarEnabled = false
addView(accountContainer)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
container.addView(accountScroll)
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(10f)) })
// 底部操作栏
val btnContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
// 1) 打开应用按钮
val openAppBtn = Button(context).apply {
text = config.labels.openApp
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(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
val newNarration = narrationInput.text.toString().trim()
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
SelectToSpeakService.instance?.bringAppToForeground()
}
}
// 2) 忽略按钮
val cancelBtn = Button(context).apply {
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(40f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
sendCancelEvent()
dismiss()
}
}
// 3) 确认入账按钮
saveBtn = Button(context).apply {
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(40f), 1.3f)
setOnClickListener {
val newAmount = amountInput.text.toString().trim()
val newPayee = merchantInput.text.toString().trim()
val newNarration = narrationInput.text.toString().trim()
sendSaveEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
}
}
btnContainer.addView(openAppBtn)
btnContainer.addView(cancelBtn)
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 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 {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(12f).toFloat()
setColor(if (isValid) colorAccent else colorInputBg)
}
saveBtn?.setTextColor(if (isValid) colorAccentFg else colorFgSecondary)
} catch (e: Exception) {
Log.e(TAG, "金额校验异常", e)
saveBtn?.isEnabled = false
}
}
}
amountInput.addTextChangedListener(amountWatcher)
// 填充金额触发 Watcher 校验
amountInput.setText(amount)
// 动态初始构建两行胶囊
rebuildChips()
view = container
windowManager.addView(view, windowParams)
Log.i(TAG, "悬浮修改记账面板已显示: $currentCurrency $amount")
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "已获得输入焦点,取消自动消失定时器")
}
}
amountInput.onFocusChangeListener = cancelTimerListener
merchantInput.onFocusChangeListener = cancelTimerListener
narrationInput.onFocusChangeListener = cancelTimerListener
container.setOnTouchListener { _, _ ->
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "已触摸卡片,取消自动消失定时器")
false
}
// 30 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({
sendCancelEvent()
dismiss()
}, 30000L)
} catch (e: Exception) {
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
}
}
/** 动态根据交易方向重绘第一行与第二行滑动的胶囊列表 */
private fun rebuildChips() {
categoryContainer?.removeAllViews()
accountContainer?.removeAllViews()
val categoryChips = mutableListOf<Pair<String, TextView>>()
val accountChips = mutableListOf<Pair<String, TextView>>()
// === 1. 绘制第一行 (分类 / 转入) ===
if (currentDirection == "expense") {
categoryLabel?.text = config.labels.categoryExpense
val filteredCats = categories.filter { it["type"] == "expense" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
val catName = cat["name"] ?: ""
val chip = createChipView(catName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedCategoryAccount = catAccount
updateStyle(catAccount)
}
categoryContainer?.addView(chip)
categoryChips.add(catAccount to chip)
}
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else if (currentDirection == "income") {
categoryLabel?.text = config.labels.categoryIncome
val filteredCats = categories.filter { it["type"] == "income" }
for (cat in filteredCats) {
val catAccount = cat["account"] ?: ""
val catName = cat["name"] ?: ""
val chip = createChipView(catName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedCategoryAccount = catAccount
updateStyle(catAccount)
}
categoryContainer?.addView(chip)
categoryChips.add(catAccount to chip)
}
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorAccent)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
} else {
// transfer — 第一行 = 转入账户,选中色 = transfer
categoryLabel?.text = config.labels.transferTarget
for (acct in accounts) {
val shortName = acct.split(":").lastOrNull() ?: acct
val chip = createChipView(shortName)
fun updateStyle(selected: String) {
for (pair in categoryChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedCategoryAccount = acct
updateStyle(acct)
}
categoryContainer?.addView(chip)
categoryChips.add(acct to chip)
}
selectedCategoryAccount = if (accounts.size > 1 && accounts[0] == selectedSourceAccount) accounts[1] else (accounts.firstOrNull() ?: "")
categoryChips.forEach { pair ->
val active = pair.first == selectedCategoryAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) setColor(colorTransfer)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
// === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") {
accountLabel?.text = config.labels.accountExpense
} else if (currentDirection == "income") {
accountLabel?.text = config.labels.accountIncome
} else {
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)
fun updateStyle(selected: String) {
for (pair in accountChips) {
val active = pair.first == selected
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(accountSelectedColor)
} else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
chip.setOnClickListener {
selectedSourceAccount = acct
updateStyle(acct)
// 若是转账,且转入转出账户冲突,自动移开转入账户
if (currentDirection == "transfer" && selectedCategoryAccount == selectedSourceAccount) {
val nextAvail = categoryChips.find { it.first != selectedSourceAccount }
if (nextAvail != null) {
selectedCategoryAccount = nextAvail.first
for (p in categoryChips) {
val act = p.first == selectedCategoryAccount
p.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (act) setColor(colorTransfer)
else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
p.second.setTextColor(if (act) colorAccentFg else colorFgSecondary)
}
}
}
}
accountContainer?.addView(chip)
accountChips.add(acct to chip)
}
selectedSourceAccount = accounts.firstOrNull() ?: ""
accountChips.forEach { pair ->
val active = pair.first == selectedSourceAccount
pair.second.background = GradientDrawable().apply {
cornerRadius = dp(14f).toFloat()
if (active) {
setColor(accountSelectedColor)
} else {
setColor(colorInputBg)
setStroke(dp(1f), colorBorder)
}
}
pair.second.setTextColor(if (active) colorAccentFg else colorFgSecondary)
}
}
/** 推送保存事件到 JS 层。 */
private fun sendSaveEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putString("amount", newAmount)
putString("merchant", newPayee)
putString("narration", newNarration)
putString("category", categoryAccount)
putString("account", sourceAccount)
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
putBoolean("confirmed", true)
putBoolean("editRequested", false)
putBoolean("isManualEdit", true)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingConfirmed", map)
} catch (e: Exception) {
Log.e(TAG, "推送修改保存事件失败: ${e.message}")
}
}
/** 推送打开应用事件到 JS 层,以便前台加载详细表单。 */
private fun sendOpenAppEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putString("amount", newAmount)
putString("merchant", newPayee)
putString("narration", newNarration)
putString("category", categoryAccount)
putString("account", sourceAccount)
putString("time", time)
putString("direction", currentDirection)
putString("packageName", packageName)
putString("currency", currentCurrency)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingOpenApp", map)
} catch (e: Exception) {
Log.e(TAG, "推送打开应用事件失败: ${e.message}")
}
}
/** 推送取消/忽略事件到 JS 层。 */
private fun sendCancelEvent() {
val reactContext = ReactContextHolder.context ?: return
try {
val map = WritableNativeMap().apply {
putString("draftId", draftId)
putBoolean("confirmed", false)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingConfirmed", map)
} catch (e: Exception) {
Log.e(TAG, "推送取消事件失败: ${e.message}")
}
}
/** 关闭浮窗。 */
fun dismiss() {
handler.removeCallbacksAndMessages(null)
try {
view?.let { windowManager.removeView(it) }
} catch (_: Exception) {}
view = null
}
/** Kotlin 中缺失的 toBigDecimalOrNull 扩展。 */
private fun String.toBigDecimalOrNull(): BigDecimal? {
return try {
BigDecimal(this)
} catch (_: Exception) {
null
}
}
}