Browse Source
- Create FloatingComposeOverlay using WindowManager for true system overlay - Replace Activity-based approach with proper overlay window - Add unrestricted drag support with WindowManager position updates - Use transparent background to show underlying apps - Integrate overlay directly into ScreenCaptureService - Remove FloatingUIActivity dependency from MainActivity Benefits: - True floating behavior over all apps - Unrestricted dragging anywhere on screen - Transparent background shows underlying content - Proper system overlay like CalcIV 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>feature/modern-capture-ui
3 changed files with 367 additions and 5 deletions
@ -0,0 +1,351 @@ |
|||
package com.quillstudios.pokegoalshelper.ui |
|||
|
|||
import android.content.Context |
|||
import android.graphics.PixelFormat |
|||
import android.os.Build |
|||
import android.util.Log |
|||
import android.view.Gravity |
|||
import android.view.View |
|||
import android.view.WindowManager |
|||
import androidx.compose.animation.* |
|||
import androidx.compose.animation.core.* |
|||
import androidx.compose.foundation.gestures.detectDragGestures |
|||
import androidx.compose.foundation.layout.* |
|||
import androidx.compose.foundation.shape.CircleShape |
|||
import androidx.compose.material.icons.Icons |
|||
import androidx.compose.material.icons.filled.* |
|||
import androidx.compose.material3.* |
|||
import androidx.compose.runtime.* |
|||
import androidx.compose.ui.Alignment |
|||
import androidx.compose.ui.Modifier |
|||
import androidx.compose.ui.draw.rotate |
|||
import androidx.compose.ui.geometry.Offset |
|||
import androidx.compose.ui.graphics.Color |
|||
import androidx.compose.ui.graphics.vector.ImageVector |
|||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType |
|||
import androidx.compose.ui.input.pointer.pointerInput |
|||
import androidx.compose.ui.platform.ComposeView |
|||
import androidx.compose.ui.platform.LocalHapticFeedback |
|||
import androidx.compose.ui.unit.dp |
|||
import androidx.compose.ui.unit.sp |
|||
import com.quillstudios.pokegoalshelper.ui.theme.PokeGoalsHelperTheme |
|||
|
|||
/** |
|||
* True floating overlay using WindowManager + ComposeView. |
|||
* This creates a proper system overlay that floats over all apps. |
|||
*/ |
|||
class FloatingComposeOverlay( |
|||
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 = "FloatingComposeOverlay" |
|||
} |
|||
|
|||
private var windowManager: WindowManager? = null |
|||
private var overlayView: ComposeView? = null |
|||
private var isShowing = false |
|||
|
|||
// Mutable state for Compose |
|||
private var fabPosition = mutableStateOf(Offset.Zero) |
|||
|
|||
fun show() { |
|||
if (isShowing) return |
|||
|
|||
try { |
|||
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager |
|||
createOverlayView() |
|||
isShowing = true |
|||
Log.d(TAG, "✅ Floating Compose overlay shown") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "❌ Error showing floating overlay", e) |
|||
} |
|||
} |
|||
|
|||
fun hide() { |
|||
if (!isShowing) return |
|||
|
|||
try { |
|||
overlayView?.let { windowManager?.removeView(it) } |
|||
overlayView = null |
|||
windowManager = null |
|||
isShowing = false |
|||
Log.d(TAG, "🗑️ Floating Compose overlay hidden") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "❌ Error hiding floating overlay", e) |
|||
} |
|||
} |
|||
|
|||
private fun createOverlayView() { |
|||
overlayView = ComposeView(context).apply { |
|||
setContent { |
|||
PokeGoalsHelperTheme { |
|||
FloatingFABContent( |
|||
position = fabPosition.value, |
|||
onPositionChanged = { newPosition -> |
|||
fabPosition.value = newPosition |
|||
updateWindowPosition(newPosition) |
|||
}, |
|||
onDetectionRequested = onDetectionRequested, |
|||
onClassFilterRequested = onClassFilterRequested, |
|||
onDebugToggled = onDebugToggled, |
|||
onClose = onClose |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Window parameters for true floating behavior |
|||
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 |
|||
}, |
|||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or |
|||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or |
|||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or |
|||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, |
|||
PixelFormat.TRANSLUCENT |
|||
).apply { |
|||
gravity = Gravity.TOP or Gravity.START |
|||
x = 50 // Initial position |
|||
y = 200 |
|||
} |
|||
|
|||
windowManager?.addView(overlayView, params) |
|||
} |
|||
|
|||
private fun updateWindowPosition(position: Offset) { |
|||
overlayView?.let { view -> |
|||
val params = view.layoutParams as WindowManager.LayoutParams |
|||
params.x = position.x.toInt() |
|||
params.y = position.y.toInt() |
|||
try { |
|||
windowManager?.updateViewLayout(view, params) |
|||
} catch (e: Exception) { |
|||
Log.w(TAG, "Failed to update window position", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
fun FloatingFABContent( |
|||
position: Offset, |
|||
onPositionChanged: (Offset) -> Unit, |
|||
onDetectionRequested: () -> Unit, |
|||
onClassFilterRequested: (String?) -> Unit, |
|||
onDebugToggled: () -> Unit, |
|||
onClose: () -> Unit |
|||
) { |
|||
var isMenuExpanded by remember { mutableStateOf(false) } |
|||
var isProcessing by remember { mutableStateOf(false) } |
|||
val hapticFeedback = LocalHapticFeedback.current |
|||
|
|||
// Animation states |
|||
val rotation by animateFloatAsState( |
|||
targetValue = if (isMenuExpanded) 45f else 0f, |
|||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), |
|||
label = "FAB rotation" |
|||
) |
|||
|
|||
val processingRotation by animateFloatAsState( |
|||
targetValue = if (isProcessing) 360f else 0f, |
|||
animationSpec = infiniteRepeatable( |
|||
animation = tween(1000, easing = LinearEasing), |
|||
repeatMode = RepeatMode.Restart |
|||
), |
|||
label = "Processing rotation" |
|||
) |
|||
|
|||
Column( |
|||
horizontalAlignment = Alignment.End, |
|||
verticalArrangement = Arrangement.Bottom |
|||
) { |
|||
// Expanded Menu Items |
|||
AnimatedVisibility( |
|||
visible = isMenuExpanded, |
|||
enter = fadeIn(animationSpec = tween(300)) + |
|||
slideInVertically( |
|||
initialOffsetY = { it / 2 }, |
|||
animationSpec = spring( |
|||
dampingRatio = Spring.DampingRatioMediumBouncy, |
|||
stiffness = Spring.StiffnessLow |
|||
) |
|||
), |
|||
exit = fadeOut(animationSpec = tween(200)) + |
|||
slideOutVertically( |
|||
targetOffsetY = { it / 2 }, |
|||
animationSpec = tween(200) |
|||
) |
|||
) { |
|||
Column( |
|||
horizontalAlignment = Alignment.End, |
|||
verticalArrangement = Arrangement.spacedBy(8.dp), |
|||
modifier = Modifier.padding(bottom = 16.dp) |
|||
) { |
|||
// Menu FAB items |
|||
MenuFABItem( |
|||
icon = Icons.Default.Search, |
|||
label = "DETECT", |
|||
containerColor = MaterialTheme.colorScheme.primary, |
|||
onClick = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) |
|||
onDetectionRequested() |
|||
isMenuExpanded = false |
|||
} |
|||
) |
|||
|
|||
MenuFABItem( |
|||
icon = Icons.Default.Star, |
|||
label = "SHINY", |
|||
containerColor = MaterialTheme.colorScheme.tertiary, |
|||
onClick = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) |
|||
onClassFilterRequested("shiny_icon") |
|||
onDetectionRequested() |
|||
isMenuExpanded = false |
|||
} |
|||
) |
|||
|
|||
MenuFABItem( |
|||
icon = Icons.Default.AccountCircle, |
|||
label = "POKEBALL", |
|||
containerColor = MaterialTheme.colorScheme.error, |
|||
onClick = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) |
|||
onClassFilterRequested("ball_icon_cherishball") |
|||
onDetectionRequested() |
|||
isMenuExpanded = false |
|||
} |
|||
) |
|||
|
|||
MenuFABItem( |
|||
icon = Icons.Default.List, |
|||
label = "ALL", |
|||
containerColor = MaterialTheme.colorScheme.secondary, |
|||
onClick = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) |
|||
onClassFilterRequested(null) |
|||
onDetectionRequested() |
|||
isMenuExpanded = false |
|||
} |
|||
) |
|||
|
|||
MenuFABItem( |
|||
icon = Icons.Default.Build, |
|||
label = "DEBUG", |
|||
containerColor = MaterialTheme.colorScheme.outline, |
|||
onClick = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) |
|||
onDebugToggled() |
|||
onDetectionRequested() |
|||
isMenuExpanded = false |
|||
} |
|||
) |
|||
} |
|||
} |
|||
|
|||
// Main FAB with drag support |
|||
FloatingActionButton( |
|||
onClick = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) |
|||
if (!isProcessing) { |
|||
isMenuExpanded = !isMenuExpanded |
|||
} |
|||
}, |
|||
modifier = Modifier |
|||
.size(56.dp) |
|||
.rotate(if (isProcessing) processingRotation else rotation) |
|||
.pointerInput(Unit) { |
|||
detectDragGestures( |
|||
onDragStart = { |
|||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) |
|||
}, |
|||
onDrag = { _, dragAmount -> |
|||
val newPosition = Offset( |
|||
position.x + dragAmount.x, |
|||
position.y + dragAmount.y |
|||
) |
|||
onPositionChanged(newPosition) |
|||
} |
|||
) |
|||
}, |
|||
containerColor = when { |
|||
isProcessing -> MaterialTheme.colorScheme.tertiary |
|||
isMenuExpanded -> MaterialTheme.colorScheme.error |
|||
else -> MaterialTheme.colorScheme.primary |
|||
}, |
|||
elevation = FloatingActionButtonDefaults.elevation( |
|||
defaultElevation = 6.dp, |
|||
pressedElevation = 12.dp |
|||
) |
|||
) { |
|||
Icon( |
|||
imageVector = when { |
|||
isProcessing -> Icons.Default.Refresh |
|||
isMenuExpanded -> Icons.Default.Close |
|||
else -> Icons.Default.Home |
|||
}, |
|||
contentDescription = when { |
|||
isProcessing -> "Processing..." |
|||
isMenuExpanded -> "Close menu" |
|||
else -> "Open detection menu" |
|||
}, |
|||
tint = MaterialTheme.colorScheme.onPrimary |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Composable |
|||
fun MenuFABItem( |
|||
icon: ImageVector, |
|||
label: String, |
|||
containerColor: Color, |
|||
onClick: () -> Unit |
|||
) { |
|||
Row( |
|||
verticalAlignment = Alignment.CenterVertically, |
|||
horizontalArrangement = Arrangement.spacedBy(8.dp) |
|||
) { |
|||
// Label |
|||
Surface( |
|||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), |
|||
shape = MaterialTheme.shapes.small, |
|||
shadowElevation = 2.dp |
|||
) { |
|||
Text( |
|||
text = label, |
|||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), |
|||
color = MaterialTheme.colorScheme.onSurface, |
|||
fontSize = 12.sp |
|||
) |
|||
} |
|||
|
|||
// Mini FAB |
|||
FloatingActionButton( |
|||
onClick = onClick, |
|||
modifier = Modifier.size(40.dp), |
|||
containerColor = containerColor, |
|||
elevation = FloatingActionButtonDefaults.elevation( |
|||
defaultElevation = 4.dp, |
|||
pressedElevation = 8.dp |
|||
) |
|||
) { |
|||
Icon( |
|||
imageVector = icon, |
|||
contentDescription = label, |
|||
tint = Color.White, |
|||
modifier = Modifier.size(20.dp) |
|||
) |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue