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