Browse Source
- 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
6 changed files with 528 additions and 8 deletions
@ -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…
Reference in new issue