package com.beancount.mobile.accessibility import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.PixelFormat import android.graphics.RectF import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.os.Handler import android.os.Looper import android.util.Log import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView /** * 记账助手悬浮球(边缘竖线胶囊面板)。 * 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。 */ class FloatingHelper( private val service: BillingAccessibilityService ) { companion object { private const val TAG = "FloatingHelper" private const val PREFS_NAME = "billing_accessibility_prefs" private const val KEY_X = "floating_ball_x" private const val KEY_Y = "floating_ball_y" private var lastX = 0 private var lastY = 400 } private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager private var containerView: LinearLayout? = null private var bubbleView: View? = null private var menuView: LinearLayout? = null private var isExpanded = false private val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH, PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START x = lastX y = lastY } @SuppressLint("ClickableViewAccessibility") fun show() { if (containerView != null) return try { val context = service // 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值) val config = FloatingUiConfigStore.load(service) // 预解析颜色 val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2") val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF") val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506") val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF") val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF") val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433") val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A") // 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存 val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) params.x = prefs.getInt(KEY_X, lastX) params.y = prefs.getInt(KEY_Y, lastY) lastX = params.x lastY = params.y val density = service.resources.displayMetrics.density fun dp(value: Float) = (value * density).toInt() // 1. 创建整体包裹容器 (水平排列,当贴在左侧时,菜单向右展开) containerView = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_OUTSIDE) { if (isExpanded) { collapse() } } false } } // 2. 创建高透明度悬浮球/边缘竖线 (仅 3dp 宽的极简胶囊,搭配 24dp 的宽触控区) val bubbleLayout = FrameLayout(context).apply { layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f)) } // 边缘指示竖线:accent 色取 RGB + 固定 0xB0 alpha(保持 70% 透明) val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt() val indicatorView = View(context).apply { background = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dp(1.5f).toFloat() setColor(indicatorColor) } layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply { gravity = Gravity.CENTER_VERTICAL or Gravity.START } } bubbleLayout.addView(indicatorView) bubbleView = bubbleLayout // 3. 创建展开菜单 (垂直布局,高紧凑度设计) // 菜单背景:实色 cardBg(per plan §T4) val menuBgColor = colorCardBg // 菜单描边:border 色 + 半透明 alpha val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt() val menuBg = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dp(10f).toFloat() setColor(menuBgColor) setStroke(dp(0.8f), menuBorderColor) } menuView = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_HORIZONTAL background = menuBg setPadding(dp(4f), dp(4f), dp(4f), dp(4f)) visibility = View.GONE layoutParams = LinearLayout.LayoutParams( dp(96f), LinearLayout.LayoutParams.WRAP_CONTENT ).apply { leftMargin = dp(4f) } } // Vector icons val sizePx = dp(14f) val strokePx = dp(1.4f).toFloat() // ScanIconDrawable:accentFg 色(在 accent 底按钮上可见),laserColor 保留红色语义 val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx) // PinIconDrawable:fgSecondary 色 val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx) // Button 1: 识别账单(主操作:accent 底 accentFg 字) // 背景常时:accent 色取 RGB + 固定 0x1F alpha(约 12%) val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt() // 按下加深:accent 色取 RGB + 固定 0x40 alpha(约 25%) val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt() val btnOcr = TextView(context).apply { text = config.labels.ballOcr setTextColor(colorAccentFg) textSize = 11f paint.isFakeBoldText = true gravity = Gravity.CENTER_VERTICAL setPadding(dp(6f), 0, dp(6f), 0) background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(btnOcrBgNormal) } layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, dp(32f) ).apply { bottomMargin = dp(5f) } setCompoundDrawablesWithIntrinsicBounds(ocrIcon, null, null, null) compoundDrawablePadding = dp(4f) setOnTouchListener { view, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { view.background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(btnOcrBgPressed) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { view.background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(btnOcrBgNormal) } if (event.action == MotionEvent.ACTION_UP) { view.performClick() } } } true } setOnClickListener { service.triggerManualExtraction() } } // Button 2: 记住此页(次要操作:inputBg 底 fgPrimary 字,per plan §T4) // 背景常时:inputBg 色取 RGB + 固定 0x15 alpha val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt() // 按下加深:inputBg 色取 RGB + 固定 0x30 alpha val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt() val btnRemember = TextView(context).apply { text = config.labels.ballRemember setTextColor(colorFgPrimary) textSize = 11f paint.isFakeBoldText = true gravity = Gravity.CENTER_VERTICAL setPadding(dp(6f), 0, dp(6f), 0) background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(btnRememberBgNormal) } layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, dp(32f) ) setCompoundDrawablesWithIntrinsicBounds(pinIcon, null, null, null) compoundDrawablePadding = dp(4f) setOnTouchListener { view, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { view.background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(btnRememberBgPressed) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { view.background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(btnRememberBgNormal) } if (event.action == MotionEvent.ACTION_UP) { view.performClick() } } } true } setOnClickListener { try { service.rememberCurrentPage() FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show() } catch (e: Exception) { FloatingTip(service, "${config.labels.rememberFail}: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show() } collapse() } } menuView?.addView(btnOcr) menuView?.addView(btnRemember) containerView?.addView(bubbleView) containerView?.addView(menuView) // 4. 设置悬浮条拖动事件 (点击即展开,拖动则调整位置) var initialX = 0 var initialY = 0 var initialTouchX = 0f var initialTouchY = 0f var isMoving = false bubbleView?.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { initialX = params.x initialY = params.y initialTouchX = event.rawX initialTouchY = event.rawY isMoving = false true } MotionEvent.ACTION_MOVE -> { val dx = (event.rawX - initialTouchX).toInt() val dy = (event.rawY - initialTouchY).toInt() if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { isMoving = true } params.x = initialX + dx params.y = initialY + dy containerView?.let { windowManager.updateViewLayout(it, params) } lastX = params.x lastY = params.y true } MotionEvent.ACTION_UP -> { if (!isMoving) { toggleMenu() } else { // 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘) if (params.x < service.resources.displayMetrics.widthPixels / 2) { params.x = -dp(1.5f) } else { params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f) } containerView?.let { windowManager.updateViewLayout(it, params) } lastX = params.x lastY = params.y // 位置持久化:吸附后写入 SharedPreferences prefs.edit() .putInt(KEY_X, params.x) .putInt(KEY_Y, params.y) .apply() } true } else -> false } } windowManager.addView(containerView, params) Log.i(TAG, "记账助手悬浮窗显示成功") } catch (e: Exception) { Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e) // 修复:失败时清理状态,允许下次重试 containerView = null bubbleView = null menuView = null } } private fun toggleMenu() { if (isExpanded) { collapse() } else { expand() } } private fun expand() { bubbleView?.visibility = View.GONE menuView?.visibility = View.VISIBLE isExpanded = true } fun collapse() { menuView?.visibility = View.GONE bubbleView?.visibility = View.VISIBLE isExpanded = false } fun hideTemporarily() { containerView?.visibility = View.GONE } fun showTemporarily() { containerView?.visibility = View.VISIBLE } fun dismiss() { try { containerView?.let { windowManager.removeView(it) } } catch (_: Exception) {} containerView = null bubbleView = null menuView = null isExpanded = false } } /** * 扫码/OCR 矢量图标 drawable。 */ class ScanIconDrawable( private val color: Int, private val strokeWidthPx: Float, private val laserColor: Int, private val sizePx: Int ) : Drawable() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = strokeWidthPx strokeCap = Paint.Cap.ROUND } override fun draw(canvas: Canvas) { val w = bounds.width().toFloat() val h = bounds.height().toFloat() paint.color = color paint.style = Paint.Style.STROKE // 绘制 4 个角的扫描框 val len = w * 0.25f val pad = strokeWidthPx // 左上 canvas.drawLine(pad, pad, pad + len, pad, paint) canvas.drawLine(pad, pad, pad, pad + len, paint) // 右上 canvas.drawLine(w - pad, pad, w - pad - len, pad, paint) canvas.drawLine(w - pad, pad, w - pad, pad + len, paint) // 左下 canvas.drawLine(pad, h - pad, pad + len, h - pad, paint) canvas.drawLine(pad, h - pad, pad, h - pad - len, paint) // 右下 canvas.drawLine(w - pad, h - pad, w - pad - len, h - pad, paint) canvas.drawLine(w - pad, h - pad, w - pad, h - pad - len, paint) // 绘制扫描红线 (激光) paint.style = Paint.Style.FILL paint.color = laserColor val laserY = h / 2f canvas.drawRect(pad * 2f, laserY - strokeWidthPx / 2f, w - pad * 2f, laserY + strokeWidthPx / 2f, paint) } override fun getIntrinsicWidth() = sizePx override fun getIntrinsicHeight() = sizePx override fun setAlpha(alpha: Int) {} override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {} override fun getOpacity() = PixelFormat.TRANSLUCENT } /** * 图钉/记住当前页 矢量图标 drawable。 */ class PinIconDrawable( private val color: Int, private val strokeWidthPx: Float, private val sizePx: Int ) : Drawable() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE strokeWidth = strokeWidthPx strokeCap = Paint.Cap.ROUND } override fun draw(canvas: Canvas) { val w = bounds.width().toFloat() val h = bounds.height().toFloat() paint.color = color val cx = w / 2f // 图钉头部 (帽) paint.style = Paint.Style.FILL val hatW = w * 0.35f val hatH = h * 0.12f canvas.drawRoundRect(RectF(cx - hatW, hatH, cx + hatW, hatH * 2.2f), strokeWidthPx, strokeWidthPx, paint) // 图钉身体 (中) val bodyW = w * 0.22f canvas.drawRect(cx - bodyW, hatH * 2.2f, cx + bodyW, h * 0.58f, paint) // 针尖 (底) paint.style = Paint.Style.STROKE canvas.drawLine(cx, h * 0.58f, cx, h - strokeWidthPx, paint) } override fun getIntrinsicWidth() = sizePx override fun getIntrinsicHeight() = sizePx override fun setAlpha(alpha: Int) {} override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {} override fun getOpacity() = PixelFormat.TRANSLUCENT }