Browse Source

feat: implement true floating overlay with WindowManager + ComposeView

- 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
Quildra 5 months ago
parent
commit
4fa3cbc989
  1. 2
      app/src/main/java/com/quillstudios/pokegoalshelper/MainActivity.kt
  2. 19
      app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt
  3. 351
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/FloatingComposeOverlay.kt

2
app/src/main/java/com/quillstudios/pokegoalshelper/MainActivity.kt

@ -50,7 +50,7 @@ class MainActivity : ComponentActivity() {
if (data != null) {
try {
startScreenCaptureService(data)
startFloatingUI()
// FloatingUI now handled by service overlay
isCapturing = true
Log.d(TAG, "Screen capture service started successfully")
} catch (e: Exception) {

19
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
// FloatingActionButtonUI no longer used - UI handled by FloatingUIActivity
import com.quillstudios.pokegoalshelper.ui.FloatingComposeOverlay
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@ -114,6 +114,7 @@ class ScreenCaptureService : Service() {
// MVC Components
private lateinit var detectionController: DetectionController
private var floatingOverlay: FloatingComposeOverlay? = null
private val handler = Handler(Looper.getMainLooper())
private var captureInterval = 2000L // Capture every 2 seconds
@ -166,6 +167,15 @@ class ScreenCaptureService : Service() {
detectionController = DetectionController(yoloDetector!!)
detectionController.setDetectionRequestCallback { triggerManualDetection() }
// Initialize floating overlay
floatingOverlay = FloatingComposeOverlay(
context = this,
onDetectionRequested = { triggerDetection() },
onClassFilterRequested = { className -> setClassFilter(className) },
onDebugToggled = { toggleDebugMode() },
onClose = { stopSelf() }
)
Log.d(TAG, "✅ MVC architecture initialized")
}
@ -311,7 +321,8 @@ class ScreenCaptureService : Service() {
}
Log.d(TAG, "Screen capture setup complete")
// UI will be shown by FloatingUIActivity
// Show floating overlay
floatingOverlay?.show()
} catch (e: Exception) {
Log.e(TAG, "Error starting screen capture", e)
@ -324,7 +335,7 @@ class ScreenCaptureService : Service() {
handler.removeCallbacks(captureRunnable)
hideDetectionOverlay()
// UI is handled by FloatingUIActivity
floatingOverlay?.hide()
latestImage?.close()
latestImage = null
virtualDisplay?.release()
@ -1220,7 +1231,7 @@ class ScreenCaptureService : Service() {
override fun onDestroy() {
super.onDestroy()
hideDetectionOverlay()
// UI is handled by FloatingUIActivity
floatingOverlay?.hide()
detectionController.clearUICallbacks()
yoloDetector?.release()
ocrExecutor.shutdown()

351
app/src/main/java/com/quillstudios/pokegoalshelper/ui/FloatingComposeOverlay.kt

@ -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…
Cancel
Save