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 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_APPLICATION_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 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)) } val indicatorView = View(context).apply { background = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dp(1.5f).toFloat() // 高度圆润 setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框 } layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply { gravity = Gravity.CENTER } } bubbleLayout.addView(indicatorView) bubbleView = bubbleLayout // 3. 创建展开菜单 (垂直布局,高紧凑度设计) val menuBg = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dp(10f).toFloat() setColor(0xA611131E.toInt()) // 65% OLED high-transparency black setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border } 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), // ultra-compact width: 96dp LinearLayout.LayoutParams.WRAP_CONTENT ).apply { leftMargin = dp(4f) // spacing with indicator line } } // Vector icons val sizePx = dp(14f) val strokePx = dp(1.4f).toFloat() val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx) val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx) // Button 1: "识别账单" val btnOcr = TextView(context).apply { text = "识别账单" setTextColor(0xFFFFFFFF.toInt()) textSize = 11f paint.isFakeBoldText = true gravity = Gravity.CENTER_VERTICAL setPadding(dp(6f), 0, dp(6f), 0) background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(0x1F5E6AD2.toInt()) // translucent indigo background } 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(0x405E6AD2.toInt()) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { view.background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(0x1F5E6AD2.toInt()) } if (event.action == MotionEvent.ACTION_UP) { view.performClick() } } } true } setOnClickListener { service.triggerManualExtraction() } } // Button 2: "记住此页" val btnRemember = TextView(context).apply { text = "记住此页" setTextColor(0xFFE5E7EB.toInt()) textSize = 11f paint.isFakeBoldText = true gravity = Gravity.CENTER_VERTICAL setPadding(dp(6f), 0, dp(6f), 0) background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(0x15FFFFFF.toInt()) // subtle translucent gray } 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(0x30FFFFFF.toInt()) } } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { view.background = GradientDrawable().apply { cornerRadius = dp(7f).toFloat() setColor(0x15FFFFFF.toInt()) } if (event.action == MotionEvent.ACTION_UP) { view.performClick() } } } true } setOnClickListener { try { service.rememberCurrentPage() FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show() } catch (e: Exception) { FloatingTip(service, "记录失败: ${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 = 0 } else { params.x = service.resources.displayMetrics.widthPixels - dp(24f) } containerView?.let { windowManager.updateViewLayout(it, params) } lastX = params.x lastY = params.y } true } else -> false } } windowManager.addView(containerView, params) Log.i(TAG, "记账助手悬浮窗显示成功") } catch (e: Exception) { Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e) } } 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 }