DriftLedger/plugins/accessibility/android/FloatingBillView.kt
fengmengqi 76a5853ab6 feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能
核心重构 — 去除 channel 字段,引入 sourceAccount 模型:
- 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段
- rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念
- dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断
- transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户
- 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试
新增基础设施:
- domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义
- domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找
- domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题
- domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听
OCR 增强:
- 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取
- checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额)
- 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配
- 时间解析支持中文格式(年月日)和跨年推断
- OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配
UI 全面升级:
- 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体
- 新增 commonStyles.ts 统一 chip/input/modal 等通用样式
- 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示
- 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细)
- TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线
- CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标
- Button/Card 增加 press 缩放动画
管道与自动化改进:
- automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认)
- 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR
- rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理
- metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则
存储与同步:
- storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制
- 备份升级到 v2 格式,包含 settings 和 metadata
- 同步路径统一从 mobile.bean 改为 main.bean
- _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean
其他:
- 删除独立日历页面,功能合并到报表月报标签
- i18n 清理:移除渠道相关翻译,新增 50+ 翻译键
- docs/android-build-guide.md 重写为 APK 体积优化指南
- 新增 design-system/beancount-mobile/MASTER.md 设计系统文档
- 测试全面更新覆盖以上所有变更
2026-07-18 18:02:45 +08:00

740 lines
31 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
/**
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
*/
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"
) {
companion object {
private const val TAG = "FloatingBillView"
}
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
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 windowParams = WindowManager.LayoutParams(
dp(310f),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
0, // 0 标志代表可获取焦点
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
y = dp(12f) // 尽量靠下以留出上方账单对照区
}
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
val containerBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(14f).toFloat()
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
}
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = containerBg
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
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 = "调整交易草稿"
textSize = 12f
setTextColor(0xFF98A2FF.toInt())
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(4f).toFloat()
setColor(0x20FFFFFF.toInt()) // 12% white opacity
}
}
val tabTexts = listOf("支出", "收入", "转账")
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) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
background = if (active) {
GradientDrawable().apply {
cornerRadius = dp(4f).toFloat()
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
}
} 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)
// 金额编辑
val amountLabel = TextView(context).apply {
text = "金额"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, dp(4f), 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
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)) })
// 商户编辑
val merchantLabel = TextView(context).apply {
text = "交易对手"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, 0, 0, dp(1f))
}
container.addView(merchantLabel)
val merchantInput = EditText(context).apply {
setText(merchant)
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
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)) })
// 叙述备注编辑
val narrationLabel = TextView(context).apply {
text = "描述/备注"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
setPadding(0, 0, 0, dp(1f))
}
container.addView(narrationLabel)
val narrationInput = EditText(context).apply {
hint = "输入交易叙述"
setHintTextColor(0xFF6B7280.toInt())
textSize = 12f
setTextColor(0xFFFFFFFF.toInt())
background = GradientDrawable().apply {
setColor(0x4012131A.toInt())
cornerRadius = dp(6f).toFloat()
setStroke(dp(1f), 0x80222433.toInt())
}
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
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)) })
// Row 1 分类/转入选择
categoryLabel = TextView(context).apply {
text = "交易分类"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
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(4f)) })
// Row 2 资金出入账户选择
accountLabel = TextView(context).apply {
text = "资金来源账户"
textSize = 9f
paint.isFakeBoldText = true
setTextColor(0xFF98A2FF.toInt())
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(8f)) })
// 底部操作栏
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 = "打开应用"
setTextColor(0xFFD1D5DB.toInt())
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(8f).toFloat()
setColor(0xFF1F2937.toInt())
setStroke(dp(1f), 0xFF374151.toInt())
}
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 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()
BillingAccessibilityService.instance?.bringAppToForeground()
}
}
// 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
textSize = 11f
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
setOnClickListener {
sendCancelEvent()
dismiss()
}
}
// 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
textSize = 11f
paint.isFakeBoldText = true
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 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)
// 构建金额安全校验
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
saveBtn?.isEnabled = isValid
saveBtn?.background = GradientDrawable().apply {
cornerRadius = dp(8f).toFloat()
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
}
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
} 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, "悬浮修改记账面板已显示: ¥$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
}
// 15 秒无操作自动消失(如果用户没有交互的话)
handler.postDelayed({
sendCancelEvent()
dismiss()
}, 15000L)
} 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 = "交易分类"
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(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
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(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
} else if (currentDirection == "income") {
categoryLabel?.text = "收入分类"
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(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
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(6f).toFloat()
if (active) setColor(0xFF5E6AD2.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
} else {
// transfer
categoryLabel?.text = "转入账户"
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(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
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(6f).toFloat()
if (active) setColor(0xFF10B981.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
// === 2. 绘制第二行 (资金账户) ===
if (currentDirection == "expense") {
accountLabel?.text = "资金来源"
} else if (currentDirection == "income") {
accountLabel?.text = "存入账户"
} else {
accountLabel?.text = "转出账户"
}
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(6f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
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(6f).toFloat()
if (act) setColor(0xFF10B981.toInt())
else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
}
}
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(6f).toFloat()
if (active) {
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
} else {
setColor(0x4012131A.toInt())
setStroke(dp(1f), 0x80222433.toInt())
}
}
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
}
}
/** 推送保存事件到 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)
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)
}
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
}
}