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