### 架构重构:三层分层目录化 - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
826 lines
35 KiB
Kotlin
826 lines
35 KiB
Kotlin
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. 实色卡片背景(不再半透明磨砂),圆角 16dp,1dp 描边
|
||
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
|
||
}
|
||
}
|
||
}
|