Browse Source

feat: implement WindowManager-based floating FAB with edge docking

- Replace broken FloatingComposeOverlay with EnhancedFloatingFAB
  - True floating behavior using WindowManager overlay system
  - Edge docking: automatic snap to screen edges within 50dp threshold
  - Touch-through support: only FAB intercepts touches, rest passes to underlying apps
  - Expandable menu with 5 detection options (DETECT, SHINY, POKEBALL, ALL, DEBUG)
  - Material Design styling with circular backgrounds and proper elevation
  - Haptic feedback with permission checking and graceful fallbacks
  - Auto-hide menu after 3 seconds of inactivity
  - Smooth animations: 300ms edge snapping, staggered menu expansion
  - Added VIBRATE permission to AndroidManifest.xml
  - Added Material Design Components dependency

  Technical implementation:
  - WindowManager overlay with WRAP_CONTENT sizing for precise touch handling
  - ImageButton with custom GradientDrawable backgrounds for Material styling
  - ValueAnimator for smooth edge snapping with AccelerateDecelerateInterpolator
  - ObjectAnimator for menu item animations with staggered delays
  - Safe vibration with runtime permission checks and exception handling

  Addresses critical requirements from UI_MODERNIZATION_TASKS.md:
   Truly floating like CalcIV - not activity-based
   Edge docking with smooth animation
   Touch-through for underlying Pokemon GO interaction
   Professional Material 3 design
   No system UI interference

  🤖 Generated with [Claude Code](https://claude.ai/code)

  Co-Authored-By: Claude <noreply@anthropic.com>
feature/modern-capture-ui
Quildra 5 months ago
parent
commit
bff29ada2e
  1. 1
      .gitignore
  2. 2
      .idea/deploymentTargetSelector.xml
  3. 3
      app/build.gradle
  4. 1
      app/src/main/AndroidManifest.xml
  5. 14
      app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt
  6. 515
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt

1
.gitignore

@ -114,6 +114,7 @@ fastlane/readme.md
*.hprof
model_export_env/
venv*/
# Debug tools and temporary files
tools/debug_scripts/debug_env/

2
.idea/deploymentTargetSelector.xml

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-01T06:19:39.957408100Z">
<DropdownSelection timestamp="2025-08-01T11:39:36.046241100Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=R5CX221W00A" />

3
app/build.gradle

@ -75,4 +75,7 @@ dependencies {
// ONNX Runtime
implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.16.3'
// Material Design Components for Views (FloatingActionButton)
implementation 'com.google.android.material:material:1.11.0'
}

1
app/src/main/AndroidManifest.xml

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"

14
app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt

@ -23,7 +23,7 @@ import android.widget.Button
import android.widget.LinearLayout
import androidx.core.app.NotificationCompat
import com.quillstudios.pokegoalshelper.controllers.DetectionController
import com.quillstudios.pokegoalshelper.ui.FloatingComposeOverlay
import com.quillstudios.pokegoalshelper.ui.EnhancedFloatingFAB
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@ -114,7 +114,7 @@ class ScreenCaptureService : Service() {
// MVC Components
private lateinit var detectionController: DetectionController
private var floatingOverlay: FloatingComposeOverlay? = null
private var enhancedFloatingFAB: EnhancedFloatingFAB? = null
private val handler = Handler(Looper.getMainLooper())
private var captureInterval = 2000L // Capture every 2 seconds
@ -167,8 +167,8 @@ class ScreenCaptureService : Service() {
detectionController = DetectionController(yoloDetector!!)
detectionController.setDetectionRequestCallback { triggerManualDetection() }
// Initialize floating overlay
floatingOverlay = FloatingComposeOverlay(
// Initialize enhanced floating FAB
enhancedFloatingFAB = EnhancedFloatingFAB(
context = this,
onDetectionRequested = { triggerDetection() },
onClassFilterRequested = { className -> setClassFilter(className) },
@ -322,7 +322,7 @@ class ScreenCaptureService : Service() {
Log.d(TAG, "Screen capture setup complete")
// Show floating overlay
floatingOverlay?.show()
enhancedFloatingFAB?.show()
} catch (e: Exception) {
Log.e(TAG, "Error starting screen capture", e)
@ -335,7 +335,7 @@ class ScreenCaptureService : Service() {
handler.removeCallbacks(captureRunnable)
hideDetectionOverlay()
floatingOverlay?.hide()
enhancedFloatingFAB?.hide()
latestImage?.close()
latestImage = null
virtualDisplay?.release()
@ -1231,7 +1231,7 @@ class ScreenCaptureService : Service() {
override fun onDestroy() {
super.onDestroy()
hideDetectionOverlay()
floatingOverlay?.hide()
enhancedFloatingFAB?.hide()
detectionController.clearUICallbacks()
yoloDetector?.release()
ocrExecutor.shutdown()

515
app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt

@ -0,0 +1,515 @@
package com.quillstudios.pokegoalshelper.ui
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import android.util.TypedValue
import android.view.*
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.*
import androidx.core.content.ContextCompat
import com.quillstudios.pokegoalshelper.R
import kotlin.math.abs
/**
* Enhanced floating FAB implementation using WindowManager + Material Views.
*
* Features:
* - True floating behavior over all apps
* - Precise touch handling (only FAB intercepts touches)
* - Edge docking with smooth animations
* - Material 3 design with proper theming
* - Expandable menu system
* - Haptic feedback and smooth animations
*/
class EnhancedFloatingFAB(
private val context: Context,
private val onDetectionRequested: () -> Unit,
private val onClassFilterRequested: (String?) -> Unit,
private val onDebugToggled: () -> Unit,
private val onClose: () -> Unit
) {
companion object {
private const val TAG = "EnhancedFloatingFAB"
private const val FAB_SIZE_DP = 56
private const val MINI_FAB_SIZE_DP = 40
private const val EDGE_SNAP_THRESHOLD_DP = 50
private const val SNAP_ANIMATION_DURATION = 300L
private const val MENU_ANIMATION_DURATION = 250L
private const val AUTO_HIDE_DELAY = 3000L
}
private var windowManager: WindowManager? = null
private var rootView: ViewGroup? = null
private var mainFAB: ImageButton? = null
private var menuContainer: LinearLayout? = null
private var isShowing = false
private var isMenuExpanded = false
private var isDragging = false
// Animation and positioning
private var currentX = 0
private var currentY = 0
private var initialTouchX = 0f
private var initialTouchY = 0f
private var initialX = 0
private var initialY = 0
private val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
private val handler = Handler(Looper.getMainLooper())
private var autoHideRunnable: Runnable? = null
fun show() {
if (isShowing) return
try {
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
createFloatingView()
isShowing = true
Log.d(TAG, "✅ Enhanced floating FAB shown")
} catch (e: Exception) {
Log.e(TAG, "❌ Error showing enhanced floating FAB", e)
}
}
fun hide() {
if (!isShowing) return
try {
rootView?.let { windowManager?.removeView(it) }
rootView = null
mainFAB = null
menuContainer = null
windowManager = null
isShowing = false
handler.removeCallbacksAndMessages(null)
Log.d(TAG, "🗑️ Enhanced floating FAB hidden")
} catch (e: Exception) {
Log.e(TAG, "❌ Error hiding enhanced floating FAB", e)
}
}
private fun createFloatingView() {
// Create root container - sized exactly to content for precise touch handling
rootView = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.END or Gravity.BOTTOM
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
// Create expandable menu container
menuContainer = createMenuContainer()
rootView?.addView(menuContainer)
// Create main FAB
mainFAB = createMainFAB()
rootView?.addView(mainFAB)
// Set up window parameters for true floating
val screenSize = getScreenSize()
currentX = screenSize.first - dpToPx(FAB_SIZE_DP + 16) // Start on right edge
currentY = screenSize.second / 2 // Center vertically
val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
},
// Critical flags for touch-through behavior
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = this@EnhancedFloatingFAB.currentX
y = this@EnhancedFloatingFAB.currentY
}
windowManager?.addView(rootView, params)
// Start auto-hide timer
scheduleAutoHide()
}
private fun createMainFAB(): ImageButton {
return ImageButton(context).apply {
setImageResource(android.R.drawable.ic_menu_preferences) // Use system icon
// Create circular background with Material 3 styling
background = createFABBackground(android.R.color.holo_blue_bright)
scaleType = ImageView.ScaleType.CENTER
// Apply Material 3 theming
imageTintList = ContextCompat.getColorStateList(context, android.R.color.white)
// Set up touch handling for drag and click
setOnTouchListener(createMainFABTouchListener())
layoutParams = LinearLayout.LayoutParams(
dpToPx(FAB_SIZE_DP),
dpToPx(FAB_SIZE_DP)
).apply {
setMargins(dpToPx(8), dpToPx(8), dpToPx(8), dpToPx(8))
}
}
}
private fun createMenuContainer(): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.END
visibility = View.GONE
// Add menu items
addMenuItems(this)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
}
private fun addMenuItems(container: LinearLayout) {
val menuItems = listOf(
MenuItemData("DEBUG", android.R.drawable.ic_menu_info_details, android.R.color.holo_orange_dark) {
onDebugToggled()
onDetectionRequested()
},
MenuItemData("ALL", android.R.drawable.ic_menu_view, android.R.color.holo_green_dark) {
onClassFilterRequested(null)
onDetectionRequested()
},
MenuItemData("POKEBALL", android.R.drawable.ic_menu_mylocation, android.R.color.holo_red_dark) {
onClassFilterRequested("ball_icon_cherishball")
onDetectionRequested()
},
MenuItemData("SHINY", android.R.drawable.btn_star_big_on, android.R.color.holo_purple) {
onClassFilterRequested("shiny_icon")
onDetectionRequested()
},
MenuItemData("DETECT", android.R.drawable.ic_menu_search, android.R.color.holo_blue_dark) {
onDetectionRequested()
}
)
menuItems.forEach { item ->
val menuRow = createMenuRow(item)
container.addView(menuRow)
}
}
private fun createMenuRow(item: MenuItemData): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Label
val label = TextView(context).apply {
text = item.label
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
background = createLabelBackground()
setPadding(dpToPx(12), dpToPx(6), dpToPx(12), dpToPx(6))
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, dpToPx(8), 0)
}
}
// Mini FAB
val miniFAB = ImageButton(context).apply {
setImageResource(item.iconRes)
background = createFABBackground(item.colorRes)
scaleType = ImageView.ScaleType.CENTER
imageTintList = ContextCompat.getColorStateList(context, android.R.color.white)
setOnClickListener {
performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
item.action()
hideMenu()
scheduleAutoHide()
}
layoutParams = LinearLayout.LayoutParams(
dpToPx(MINI_FAB_SIZE_DP),
dpToPx(MINI_FAB_SIZE_DP)
)
}
addView(label)
addView(miniFAB)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, dpToPx(8))
}
}
}
private fun createFABBackground(colorRes: Int): GradientDrawable {
return GradientDrawable().apply {
setColor(ContextCompat.getColor(context, colorRes))
shape = GradientDrawable.OVAL
// Add elevation effect with stroke
setStroke(2, ContextCompat.getColor(context, android.R.color.darker_gray))
}
}
private fun createLabelBackground(): GradientDrawable {
return GradientDrawable().apply {
setColor(ContextCompat.getColor(context, android.R.color.black))
cornerRadius = dpToPx(4).toFloat()
alpha = (0.8f * 255).toInt()
}
}
private fun createMainFABTouchListener(): View.OnTouchListener {
return View.OnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
isDragging = false
initialTouchX = event.rawX
initialTouchY = event.rawY
initialX = currentX
initialY = currentY
cancelAutoHide()
true
}
MotionEvent.ACTION_MOVE -> {
val deltaX = event.rawX - initialTouchX
val deltaY = event.rawY - initialTouchY
// Start dragging if moved enough
if (!isDragging && (abs(deltaX) > 10 || abs(deltaY) > 10)) {
isDragging = true
performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
hideMenu()
}
if (isDragging) {
currentX = (initialX + deltaX).toInt()
currentY = (initialY + deltaY).toInt()
updateWindowPosition()
}
true
}
MotionEvent.ACTION_UP -> {
if (isDragging) {
snapToEdgeIfNeeded()
scheduleAutoHide()
} else {
// Handle click
performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK)
if (isMenuExpanded) {
hideMenu()
} else {
showMenu()
}
scheduleAutoHide()
}
isDragging = false
true
}
else -> false
}
}
}
private fun updateWindowPosition() {
rootView?.let { view ->
val params = view.layoutParams as WindowManager.LayoutParams
params.x = currentX
params.y = currentY
try {
windowManager?.updateViewLayout(view, params)
} catch (e: Exception) {
Log.w(TAG, "Failed to update window position", e)
}
}
}
private fun snapToEdgeIfNeeded() {
val screenSize = getScreenSize()
val snapThreshold = dpToPx(EDGE_SNAP_THRESHOLD_DP)
val targetX = when {
currentX < snapThreshold -> 0
currentX > screenSize.first - dpToPx(FAB_SIZE_DP) - snapThreshold ->
screenSize.first - dpToPx(FAB_SIZE_DP)
else -> currentX
}
if (targetX != currentX) {
animateToPosition(targetX, currentY)
}
}
private fun animateToPosition(targetX: Int, targetY: Int) {
val startX = currentX
val startY = currentY
ValueAnimator.ofFloat(0f, 1f).apply {
duration = SNAP_ANIMATION_DURATION
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener { animator ->
val progress = animator.animatedValue as Float
currentX = (startX + (targetX - startX) * progress).toInt()
currentY = (startY + (targetY - startY) * progress).toInt()
updateWindowPosition()
}
start()
}
}
private fun showMenu() {
if (isMenuExpanded) return
menuContainer?.let { container ->
container.visibility = View.VISIBLE
isMenuExpanded = true
// Animate menu items in
for (i in 0 until container.childCount) {
val child = container.getChildAt(i)
child.alpha = 0f
child.translationY = dpToPx(20).toFloat()
ObjectAnimator.ofFloat(child, "alpha", 0f, 1f).apply {
duration = MENU_ANIMATION_DURATION
startDelay = i * 50L
start()
}
ObjectAnimator.ofFloat(child, "translationY", dpToPx(20).toFloat(), 0f).apply {
duration = MENU_ANIMATION_DURATION
startDelay = i * 50L
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
}
// Update main FAB appearance
mainFAB?.background = createFABBackground(android.R.color.holo_red_light)
}
private fun hideMenu() {
if (!isMenuExpanded) return
menuContainer?.let { container ->
// Animate menu items out
for (i in 0 until container.childCount) {
val child = container.getChildAt(i)
ObjectAnimator.ofFloat(child, "alpha", 1f, 0f).apply {
duration = MENU_ANIMATION_DURATION / 2
start()
}
ObjectAnimator.ofFloat(child, "translationY", 0f, dpToPx(20).toFloat()).apply {
duration = MENU_ANIMATION_DURATION / 2
interpolator = AccelerateDecelerateInterpolator()
if (i == container.childCount - 1) {
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
container.visibility = View.GONE
isMenuExpanded = false
}
})
}
start()
}
}
}
// Reset main FAB appearance
mainFAB?.background = createFABBackground(android.R.color.holo_blue_bright)
}
private fun scheduleAutoHide() {
cancelAutoHide()
autoHideRunnable = Runnable {
if (isMenuExpanded) {
hideMenu()
}
}
handler.postDelayed(autoHideRunnable!!, AUTO_HIDE_DELAY)
}
private fun cancelAutoHide() {
autoHideRunnable?.let { handler.removeCallbacks(it) }
autoHideRunnable = null
}
private fun performHapticFeedback(feedbackType: Int) {
// Check if we have vibrate permission
if (context.checkSelfPermission(android.Manifest.permission.VIBRATE) != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Vibrate permission not granted, skipping haptic feedback")
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(25, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(25)
}
} catch (e: SecurityException) {
Log.w(TAG, "SecurityException during vibrate: ${e.message}")
} catch (e: Exception) {
Log.w(TAG, "Exception during vibrate: ${e.message}")
}
}
private fun getScreenSize(): Pair<Int, Int> {
val displayMetrics = context.resources.displayMetrics
return Pair(displayMetrics.widthPixels, displayMetrics.heightPixels)
}
private fun dpToPx(dp: Int): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
context.resources.displayMetrics
).toInt()
}
private data class MenuItemData(
val label: String,
val iconRes: Int,
val colorRes: Int,
val action: () -> Unit
)
}
Loading…
Cancel
Save