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> = emptyList(), private val accounts: List = 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 = 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() 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() BillingAccessibilityService.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>() val accountChips = mutableListOf>() // === 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 } } }