Browse Source

feat: implement decoupled UI architecture with Material 3 FAB

- Create FloatingUIActivity with true Material 3 FloatingActionButton
- Implement Binder communication between UI Activity and Service
- Remove UI handling from ScreenCaptureService (now pure background service)
- Add proper Material 3 animations and haptic feedback
- Update MainActivity to launch FloatingUIActivity on capture start
- Register FloatingUIActivity in AndroidManifest with transparent theme

Architecture: FloatingUIActivity (Compose UI) ↔ Binder ↔ ScreenCaptureService (Detection)

🤖 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
ce8268b40f
  1. 2
      .idea/deploymentTargetSelector.xml
  2. 187
      UI_MODERNIZATION_TASKS.md
  3. 9
      app/src/main/AndroidManifest.xml
  4. 11
      app/src/main/java/com/quillstudios/pokegoalshelper/MainActivity.kt
  5. 49
      app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt
  6. 580
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/FloatingActionButtonUI.kt
  7. 368
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/FloatingUIActivity.kt
  8. BIN
      test_images/captured_screen_1753995229336.jpg
  9. BIN
      test_images/shiny_test.jpg

2
.idea/deploymentTargetSelector.xml

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

187
UI_MODERNIZATION_TASKS.md

@ -0,0 +1,187 @@
# UI Modernization Tasks - Capture Mode
## Overview
This document outlines the planned modernization of the capture mode UI, transforming it from a basic floating button system to a modern, feature-rich interface.
## Current State
- Basic floating "orb" (actually just a styled Button)
- Simple expandable menu with 5 options
- Basic visual state feedback
- MVC architecture with proper event handling
## Phase 1: Core Modernization ⚡
### 1.1 Material 3 Floating Action Button
**Priority: HIGH**
**Status: PENDING**
Convert the current basic floating orb to a proper Material 3 FAB:
- Use Material 3 FAB component with proper elevation and shadows
- Smooth expand/collapse animations using AnimatedVisibility
- Material 3 color theming and dynamic colors
- Proper ripple effects and touch feedback
- Size variants (mini FAB for menu items)
**Technical Details:**
- Replace `Button` with `FloatingActionButton` in Jetpack Compose
- Implement `AnimatedVisibility` for menu expansion
- Use `Material3` theme colors and elevation tokens
- Add haptic feedback for interactions
### 1.2 Visual Design Modernization
**Priority: MEDIUM**
**Status: PENDING**
- **Glass-morphism Effects**: Semi-transparent backgrounds with blur
- **Smooth Animations**: Choreographed transitions between states
- **Modern Icons**: Vector icons with proper scaling
- **Color Schemes**: Material You dynamic theming
- **Typography**: Material 3 typography scale
**Technical Details:**
- Implement blur effects using `Modifier.blur()`
- Custom animation specs for fluid motion
- Vector Drawable icons with animated state changes
- Dynamic color extraction from wallpaper (Android 12+)
## Phase 2: Enhanced Functionality 📊
### 2.1 Detection Results Overlay
**Priority: MEDIUM**
**Status: PENDING**
Real-time overlay showing detection results:
- Bounding boxes with confidence scores
- Class labels with color coding
- Performance metrics (FPS, inference time)
- Detection count by category
**Technical Details:**
- Custom Canvas drawing for overlays
- WindowManager overlay with proper Z-ordering
- Real-time data binding from detection controller
- Optimized rendering to avoid performance impact
### 2.2 Live Preview & Status
**Priority: MEDIUM**
**Status: PENDING**
- Mini preview window showing current screen region
- Real-time detection status indicators
- Processing queue visualization
- Error state handling with retry options
### 2.3 Settings Panel Integration
**Priority: MEDIUM**
**Status: PENDING**
Built-in settings accessible from floating UI:
- Detection sensitivity sliders
- Class filter toggles with visual preview
- Coordinate transformation mode selection
- Debug options panel
## Phase 3: Advanced UX Patterns 🎯
### 3.1 Gesture-Based Interactions
**Priority: MEDIUM**
**Status: PENDING**
- **Long Press**: Context menu with advanced options
- **Swipe Gestures**: Quick filter switching
- **Drag**: Repositioning FAB location
- **Double Tap**: Quick detection trigger
**Technical Details:**
- Custom gesture detection using `Modifier.pointerInput()`
- Haptic feedback patterns for different gestures
- Visual feedback during gesture recognition
- Gesture customization settings
### 3.2 Contextual Intelligence
**Priority: MEDIUM**
**Status: PENDING**
Smart behavior based on current context:
- Auto-hide during active detection
- Context-aware menu options
- Smart positioning to avoid UI occlusion
- Adaptive timeout based on usage patterns
### 3.3 Accessibility Enhancements
**Priority: HIGH**
**Status: PENDING**
- Screen reader compatibility
- Voice commands integration
- High contrast mode support
- Large text scaling support
- Keyboard navigation
## Phase 4: Advanced Features 🚀
### 4.1 Detection Analytics Dashboard
**Priority: LOW**
**Status: PENDING**
- Mini-map showing detection regions
- Historical detection data
- Performance trend graphs
- Export detection logs
### 4.2 Smart Automation
**Priority: LOW**
**Status: PENDING**
- Auto-detection based on screen content changes
- Smart filtering based on user behavior
- Predictive UI adaptation
- Background processing optimization
### 4.3 Integration Features
**Priority: LOW**
**Status: PENDING**
- Screenshot annotation and sharing
- Detection result export (JSON, CSV)
- Cloud sync for settings
- Integration with external Pokemon databases
## Technical Architecture
### Component Structure
```
FloatingActionButtonUI (Compose)
├── FABCore (Material 3 FAB)
├── ExpandableMenu (AnimatedVisibility)
├── DetectionOverlay (Canvas)
├── SettingsPanel (BottomSheet/Drawer)
└── StatusIndicators (Badges/Chips)
```
### Performance Considerations
- Lazy composition for menu items
- Efficient recomposition boundaries
- Background thread processing
- Memory management for overlays
- Battery optimization strategies
## Implementation Timeline
**Sprint 1 (Week 1-2)**: Material 3 FAB conversion
**Sprint 2 (Week 3-4)**: Visual design modernization
**Sprint 3 (Week 5-6)**: Detection results overlay
**Sprint 4 (Week 7-8)**: Enhanced UX patterns
**Sprint 5+ (Future)**: Advanced features as needed
## Success Metrics
- **User Experience**: Smooth 60fps animations, <100ms interaction response
- **Functionality**: All current features preserved + new capabilities
- **Performance**: No degradation in detection accuracy or speed
- **Accessibility**: WCAG 2.1 AA compliance
- **Code Quality**: Maintainable Compose architecture
---
**Note**: This is a living document that will be updated as features are implemented and new requirements emerge.

9
app/src/main/AndroidManifest.xml

@ -28,6 +28,15 @@
</intent-filter>
</activity>
<activity
android:name=".ui.FloatingUIActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:launchMode="singleTop"
android:excludeFromRecents="true"
android:taskAffinity=""
android:showOnLockScreen="true" />
<service
android:name=".ScreenCaptureService"
android:foregroundServiceType="mediaProjection"

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

@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.quillstudios.pokegoalshelper.ui.theme.PokeGoalsHelperTheme
import com.quillstudios.pokegoalshelper.ui.FloatingUIActivity
import org.opencv.android.OpenCVLoader
import org.opencv.core.Mat
import org.opencv.core.CvType
@ -49,6 +50,7 @@ class MainActivity : ComponentActivity() {
if (data != null) {
try {
startScreenCaptureService(data)
startFloatingUI()
isCapturing = true
Log.d(TAG, "Screen capture service started successfully")
} catch (e: Exception) {
@ -163,6 +165,15 @@ class MainActivity : ComponentActivity() {
startService(serviceIntent)
isCapturing = false
}
private fun startFloatingUI() {
val floatingUIIntent = Intent(this, FloatingUIActivity::class.java).apply {
action = FloatingUIActivity.ACTION_SHOW_FAB
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
startActivity(floatingUIIntent)
Log.d(TAG, "FloatingUIActivity started")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

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

@ -3,6 +3,7 @@ package com.quillstudios.pokegoalshelper
import android.app.*
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.graphics.PixelFormat
@ -22,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.FloatingOrbUI
// FloatingActionButtonUI no longer used - UI handled by FloatingUIActivity
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@ -89,6 +90,15 @@ class ScreenCaptureService : Service() {
const val ACTION_STOP = "STOP_SCREEN_CAPTURE"
const val EXTRA_RESULT_DATA = "result_data"
}
/**
* Binder for communication with FloatingUIActivity
*/
inner class LocalBinder : Binder() {
fun getService(): ScreenCaptureService = this@ScreenCaptureService
}
private val binder = LocalBinder()
// ONNX YOLO detector instance
private var yoloDetector: YOLOOnnxDetector? = null
@ -104,7 +114,6 @@ class ScreenCaptureService : Service() {
// MVC Components
private lateinit var detectionController: DetectionController
private var floatingOrbUI: FloatingOrbUI? = null
private val handler = Handler(Looper.getMainLooper())
private var captureInterval = 2000L // Capture every 2 seconds
@ -155,12 +164,35 @@ class ScreenCaptureService : Service() {
// Initialize MVC components
detectionController = DetectionController(yoloDetector!!)
floatingOrbUI = FloatingOrbUI(this, detectionController)
detectionController.setUICallbacks(floatingOrbUI!!)
detectionController.setDetectionRequestCallback { triggerManualDetection() }
Log.d(TAG, "✅ MVC architecture initialized")
}
override fun onBind(intent: Intent?) = binder
// === Public API for FloatingUIActivity ===
/**
* Trigger manual detection from UI
*/
fun triggerDetection() {
triggerManualDetection()
}
/**
* Set class filter from UI
*/
fun setClassFilter(className: String?) {
detectionController.onClassFilterChanged(className)
}
/**
* Toggle debug mode from UI
*/
fun toggleDebugMode() {
detectionController.onDebugModeToggled()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
@ -280,9 +312,8 @@ class ScreenCaptureService : Service() {
return
}
Log.d(TAG, "Screen capture setup complete, showing floating orb UI")
// Show the floating orb UI
floatingOrbUI?.show()
Log.d(TAG, "Screen capture setup complete")
// UI will be shown by FloatingUIActivity
} catch (e: Exception) {
Log.e(TAG, "Error starting screen capture", e)
@ -295,7 +326,7 @@ class ScreenCaptureService : Service() {
handler.removeCallbacks(captureRunnable)
hideDetectionOverlay()
floatingOrbUI?.hide()
// UI is handled by FloatingUIActivity
latestImage?.close()
latestImage = null
virtualDisplay?.release()
@ -1191,7 +1222,7 @@ class ScreenCaptureService : Service() {
override fun onDestroy() {
super.onDestroy()
hideDetectionOverlay()
floatingOrbUI?.hide()
// UI is handled by FloatingUIActivity
detectionController.clearUICallbacks()
yoloDetector?.release()
ocrExecutor.shutdown()

580
app/src/main/java/com/quillstudios/pokegoalshelper/ui/FloatingActionButtonUI.kt

@ -0,0 +1,580 @@
package com.quillstudios.pokegoalshelper.ui
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.graphics.drawable.GradientDrawable
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
// import com.quillstudios.pokegoalshelper.R // Not needed for now
import com.quillstudios.pokegoalshelper.ui.interfaces.DetectionUIEvents
import com.quillstudios.pokegoalshelper.ui.interfaces.DetectionUICallbacks
/**
* Modern Material 3 Floating Action Button UI for capture mode.
* Replaces the basic floating orb with a proper FAB implementation.
*
* Features:
* - Material 3 design with proper elevation and shadows
* - Smooth animations for state changes
* - Haptic feedback for interactions
* - Glass-morphism effects
* - Contextual menu expansion
*/
class FloatingActionButtonUI(
private val context: Context,
private val detectionEvents: DetectionUIEvents
) : DetectionUICallbacks {
companion object {
private const val TAG = "FloatingActionButtonUI"
// Material 3 FAB dimensions (dp converted to px)
private const val FAB_SIZE_LARGE = 56 // Main FAB
private const val FAB_SIZE_SMALL = 40 // Menu item FABs
private const val FAB_MARGIN = 16
// Animation durations
private const val ANIMATION_DURATION_FAST = 150L
private const val ANIMATION_DURATION_MEDIUM = 300L
// Material 3 elevation levels
private const val ELEVATION_RESTING = 6f
private const val ELEVATION_PRESSED = 12f
private const val ELEVATION_MENU = 8f
}
private var windowManager: WindowManager? = null
private var fabContainer: FrameLayout? = null
private var mainFab: View? = null
private var expandedMenu: LinearLayout? = null
private var scrimView: View? = null
private var isMenuExpanded = false
private var isProcessing = false
private var currentAnimator: AnimatorSet? = null
private val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
/**
* Initialize and show the floating action button
*/
fun show() {
try {
if (fabContainer != null) return // Already shown
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
createFloatingActionButton()
Log.d(TAG, "✅ Modern FAB UI shown")
} catch (e: Exception) {
Log.e(TAG, "❌ Error showing FAB", e)
}
}
/**
* Hide and cleanup the floating action button
*/
fun hide() {
try {
currentAnimator?.cancel()
if (isMenuExpanded) {
collapseMenu(animate = false)
}
fabContainer?.let {
windowManager?.removeView(it)
fabContainer = null
}
mainFab = null
expandedMenu = null
windowManager = null
Log.d(TAG, "🗑️ FAB UI hidden")
} catch (e: Exception) {
Log.e(TAG, "❌ Error hiding FAB", e)
}
}
// === DetectionUICallbacks Implementation ===
override fun onDetectionStarted() {
isProcessing = true
updateFabState()
// Auto-collapse menu during processing
if (isMenuExpanded) {
collapseMenu()
}
}
override fun onDetectionCompleted(detectionCount: Int) {
isProcessing = false
updateFabState()
// Provide haptic feedback for completion
provideFeedback(VibrationEffect.EFFECT_TICK)
Log.d(TAG, "🎯 Detection completed: $detectionCount objects")
}
override fun onDetectionFailed(error: String) {
isProcessing = false
updateFabState()
// Provide error feedback
provideFeedback(VibrationEffect.EFFECT_DOUBLE_CLICK)
Log.e(TAG, "❌ Detection failed: $error")
}
override fun onSettingsChanged(filterClass: String?, debugMode: Boolean, coordinateMode: String) {
Log.d(TAG, "⚙️ Settings updated - Filter: $filterClass, Debug: $debugMode, Mode: $coordinateMode")
// Update menu items based on current settings
updateMenuItems()
}
// === Private UI Methods ===
private fun createFloatingActionButton() {
// Create main container
fabContainer = FrameLayout(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
// Create main FAB
mainFab = createMainFab()
fabContainer?.addView(mainFab)
// Set up window parameters
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,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.END
x = dpToPx(FAB_MARGIN)
y = dpToPx(FAB_MARGIN + 80) // Account for potential system UI
}
windowManager?.addView(fabContainer, params)
}
private fun createMainFab(): View {
return FrameLayout(context).apply {
val fabSize = dpToPx(FAB_SIZE_LARGE)
layoutParams = FrameLayout.LayoutParams(fabSize, fabSize)
// Create FAB background with Material 3 styling
background = createFabBackground(
color = getColor(android.R.color.holo_blue_bright),
elevation = ELEVATION_RESTING
)
// Add icon
val iconView = ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
dpToPx(24), dpToPx(24), Gravity.CENTER
)
setImageResource(android.R.drawable.ic_menu_camera) // Using target icon
setColorFilter(Color.WHITE)
scaleType = ImageView.ScaleType.FIT_CENTER
}
addView(iconView)
// Set up click handling
setOnClickListener { handleFabClick() }
// Add touch feedback
isClickable = true
isFocusable = true
// Apply initial elevation
elevation = ELEVATION_RESTING
}
}
private fun createFabBackground(color: Int, elevation: Float): GradientDrawable {
return GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(color)
// Add subtle gradient for depth
colors = intArrayOf(
lightenColor(color, 0.1f),
color,
darkenColor(color, 0.1f)
)
gradientType = GradientDrawable.RADIAL_GRADIENT
gradientRadius = dpToPx(FAB_SIZE_LARGE).toFloat() / 2
// Material 3 shadow simulation (since we can't use real elevation in overlays)
setStroke(dpToPx(1), Color.argb(30, 0, 0, 0))
}
}
private fun handleFabClick() {
provideFeedback(VibrationEffect.EFFECT_CLICK)
if (isProcessing) {
Log.d(TAG, "⚠️ Ignoring click - detection in progress")
return
}
if (isMenuExpanded) {
collapseMenu()
} else {
expandMenu()
}
}
private fun expandMenu() {
if (isMenuExpanded || isProcessing) return
// Create scrim for backdrop
createScrim()
// Create expanded menu
expandedMenu = createExpandedMenu()
fabContainer?.addView(expandedMenu)
// Animate expansion
animateMenuExpansion(true)
isMenuExpanded = true
updateFabState()
}
private fun collapseMenu(animate: Boolean = true) {
if (!isMenuExpanded) return
if (animate) {
animateMenuExpansion(false) {
removeMenuViews()
}
} else {
removeMenuViews()
}
isMenuExpanded = false
updateFabState()
}
private fun createScrim() {
scrimView = View(context).apply {
layoutParams = ViewGroup.LayoutParams(
dpToPx(200), dpToPx(300)
)
setBackgroundColor(Color.argb(80, 0, 0, 0)) // Semi-transparent scrim
alpha = 0f
setOnClickListener { collapseMenu() }
}
fabContainer?.addView(scrimView, 0) // Add as first child (background)
}
private fun createExpandedMenu(): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM or Gravity.END
).apply {
bottomMargin = dpToPx(FAB_SIZE_LARGE + 16)
rightMargin = dpToPx(8)
}
// Create menu items
val menuItems = listOf(
FabMenuItem("🔍", "DETECT", getColor(android.R.color.holo_green_dark)) {
detectionEvents.onDetectionRequested()
},
FabMenuItem("", "SHINY", getColor(android.R.color.holo_orange_light)) {
detectionEvents.onClassFilterChanged("shiny_icon")
detectionEvents.onDetectionRequested()
},
FabMenuItem("", "BALL", getColor(android.R.color.holo_red_dark)) {
detectionEvents.onClassFilterChanged("ball_icon_cherishball")
detectionEvents.onDetectionRequested()
},
FabMenuItem("🎯", "ALL", getColor(android.R.color.darker_gray)) {
detectionEvents.onClassFilterChanged(null)
detectionEvents.onDetectionRequested()
},
FabMenuItem("🔧", "DEBUG", getColor(android.R.color.holo_purple)) {
detectionEvents.onDebugModeToggled()
detectionEvents.onDetectionRequested()
}
)
menuItems.forEachIndexed { index, item ->
val fabItem = createMenuFab(item)
addView(fabItem)
// Add spacing between items
if (index < menuItems.size - 1) {
val spacer = View(context).apply {
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dpToPx(8)
)
}
addView(spacer)
}
}
}
}
private fun createMenuFab(item: FabMenuItem): View {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
// Add label
val label = TextView(context).apply {
text = item.label
setTextColor(Color.WHITE)
textSize = 12f
setPadding(dpToPx(8), dpToPx(4), dpToPx(8), dpToPx(4))
setBackgroundResource(android.R.drawable.btn_default)
background.setTint(Color.argb(200, 0, 0, 0))
alpha = 0f // Start invisible for animation
}
addView(label)
// Add small FAB
val miniFab = FrameLayout(context).apply {
val size = dpToPx(FAB_SIZE_SMALL)
layoutParams = LinearLayout.LayoutParams(size, size).apply {
leftMargin = dpToPx(8)
}
background = createFabBackground(item.color, ELEVATION_MENU)
elevation = ELEVATION_MENU
alpha = 0f // Start invisible for animation
// Add icon
val iconView = TextView(context).apply {
text = item.icon
textSize = 16f
gravity = Gravity.CENTER
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
addView(iconView)
setOnClickListener {
provideFeedback(VibrationEffect.EFFECT_CLICK)
item.action()
collapseMenu()
}
isClickable = true
isFocusable = true
}
addView(miniFab)
}
}
private fun animateMenuExpansion(expand: Boolean, onComplete: (() -> Unit)? = null) {
currentAnimator?.cancel()
val animatorSet = AnimatorSet()
val animators = mutableListOf<ObjectAnimator>()
// Animate scrim
scrimView?.let { scrim ->
animators.add(ObjectAnimator.ofFloat(
scrim, "alpha",
if (expand) 0f else 1f,
if (expand) 1f else 0f
))
}
// Animate menu items
expandedMenu?.let { menu ->
for (i in 0 until menu.childCount) {
val child = menu.getChildAt(i)
if (child is LinearLayout) {
// Animate each FAB item
val delay = if (expand) i * 50L else (menu.childCount - i) * 30L
val scaleX = ObjectAnimator.ofFloat(
child, "scaleX",
if (expand) 0f else 1f,
if (expand) 1f else 0f
)
val scaleY = ObjectAnimator.ofFloat(
child, "scaleY",
if (expand) 0f else 1f,
if (expand) 1f else 0f
)
val alpha = ObjectAnimator.ofFloat(
child, "alpha",
if (expand) 0f else 1f,
if (expand) 1f else 0f
)
scaleX.startDelay = delay
scaleY.startDelay = delay
alpha.startDelay = delay
animators.addAll(listOf(scaleX, scaleY, alpha))
}
}
}
// Configure animation set
animatorSet.playTogether(animators)
animatorSet.duration = ANIMATION_DURATION_MEDIUM
animatorSet.interpolator = if (expand) OvershootInterpolator() else AccelerateDecelerateInterpolator()
animatorSet.addListener(object : android.animation.AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
onComplete?.invoke()
currentAnimator = null
}
})
currentAnimator = animatorSet
animatorSet.start()
}
private fun removeMenuViews() {
scrimView?.let { fabContainer?.removeView(it) }
expandedMenu?.let { fabContainer?.removeView(it) }
scrimView = null
expandedMenu = null
}
private fun updateFabState() {
val iconView = (mainFab as? FrameLayout)?.getChildAt(0) as? ImageView
val fab = mainFab as? FrameLayout
when {
isProcessing -> {
iconView?.setImageResource(android.R.drawable.ic_popup_sync)
fab?.background = createFabBackground(
getColor(android.R.color.holo_orange_light),
ELEVATION_PRESSED
)
// Add rotation animation for processing state
animateProcessing(iconView)
}
isMenuExpanded -> {
iconView?.setImageResource(android.R.drawable.ic_menu_close_clear_cancel)
fab?.background = createFabBackground(
getColor(android.R.color.holo_red_light),
ELEVATION_PRESSED
)
}
else -> {
iconView?.setImageResource(android.R.drawable.ic_menu_camera)
fab?.background = createFabBackground(
getColor(android.R.color.holo_blue_bright),
ELEVATION_RESTING
)
iconView?.clearAnimation()
}
}
}
private fun updateMenuItems() {
// This could update menu item states based on current filter/debug settings
// For now, just log that we received the update
Log.d(TAG, "Menu items updated based on current settings")
}
private fun animateProcessing(view: View?) {
view?.let {
val rotation = ObjectAnimator.ofFloat(it, "rotation", 0f, 360f)
rotation.duration = 1000L
rotation.repeatCount = ValueAnimator.INFINITE
rotation.interpolator = AccelerateDecelerateInterpolator()
rotation.start()
}
}
private fun provideFeedback(effect: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator?.vibrate(VibrationEffect.createPredefined(effect))
} else {
@Suppress("DEPRECATION")
vibrator?.vibrate(50)
}
}
// === Utility Methods ===
private fun dpToPx(dp: Int): Int {
return (dp * context.resources.displayMetrics.density).toInt()
}
private fun getColor(colorRes: Int): Int {
return ContextCompat.getColor(context, colorRes)
}
private fun lightenColor(color: Int, factor: Float): Int {
val red = Color.red(color)
val green = Color.green(color)
val blue = Color.blue(color)
return Color.rgb(
(red + (255 - red) * factor).toInt().coerceAtMost(255),
(green + (255 - green) * factor).toInt().coerceAtMost(255),
(blue + (255 - blue) * factor).toInt().coerceAtMost(255)
)
}
private fun darkenColor(color: Int, factor: Float): Int {
val red = Color.red(color)
val green = Color.green(color)
val blue = Color.blue(color)
return Color.rgb(
(red * (1 - factor)).toInt().coerceAtLeast(0),
(green * (1 - factor)).toInt().coerceAtLeast(0),
(blue * (1 - factor)).toInt().coerceAtLeast(0)
)
}
/**
* Data class for FAB menu items
*/
private data class FabMenuItem(
val icon: String,
val label: String,
val color: Int,
val action: () -> Unit
)
}

368
app/src/main/java/com/quillstudios/pokegoalshelper/ui/FloatingUIActivity.kt

@ -0,0 +1,368 @@
package com.quillstudios.pokegoalshelper.ui
import android.app.Activity
import android.content.*
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.quillstudios.pokegoalshelper.ScreenCaptureService
import com.quillstudios.pokegoalshelper.ui.theme.PokeGoalsHelperTheme
/**
* Transparent Activity that hosts the floating Material 3 FAB UI.
* Communicates with ScreenCaptureService via Binder for detection functionality.
*
* This approach allows us to use true Jetpack Compose with Material 3 components
* while keeping the detection logic in a background service.
*/
class FloatingUIActivity : ComponentActivity() {
companion object {
private const val TAG = "FloatingUIActivity"
const val ACTION_SHOW_FAB = "SHOW_FAB"
const val ACTION_HIDE_FAB = "HIDE_FAB"
}
private var screenCaptureService: ScreenCaptureService? = null
private var serviceBound = false
// Service connection to communicate with ScreenCaptureService
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
Log.d(TAG, "Connected to ScreenCaptureService")
val binder = service as ScreenCaptureService.LocalBinder
screenCaptureService = binder.getService()
serviceBound = true
}
override fun onServiceDisconnected(arg0: ComponentName) {
Log.d(TAG, "Disconnected from ScreenCaptureService")
screenCaptureService = null
serviceBound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Make activity transparent and overlay-capable
setupTransparentOverlay()
// Bind to the ScreenCaptureService
bindToScreenCaptureService()
setContent {
PokeGoalsHelperTheme {
FloatingFABInterface(
onDetectionRequested = { requestDetection() },
onClassFilterRequested = { className -> requestClassFilter(className) },
onDebugToggled = { toggleDebugMode() },
onClose = { finish() }
)
}
}
}
override fun onDestroy() {
super.onDestroy()
if (serviceBound) {
unbindService(serviceConnection)
serviceBound = false
}
}
private fun setupTransparentOverlay() {
// Make the activity transparent
window.apply {
setFlags(
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
)
setFlags(
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
)
// Make it show over other apps
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
} else {
@Suppress("DEPRECATION")
setType(WindowManager.LayoutParams.TYPE_PHONE)
}
}
}
private fun bindToScreenCaptureService() {
val intent = Intent(this, ScreenCaptureService::class.java)
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
// === Communication with Service ===
private fun requestDetection() {
screenCaptureService?.triggerManualDetection()
Log.d(TAG, "Requested detection from service")
}
private fun requestClassFilter(className: String?) {
screenCaptureService?.setClassFilter(className)
Log.d(TAG, "Set class filter to: $className")
}
private fun toggleDebugMode() {
screenCaptureService?.toggleDebugMode()
Log.d(TAG, "Toggled debug mode")
}
}
@Composable
fun FloatingFABInterface(
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
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent),
contentAlignment = Alignment.BottomEnd
) {
// Main content area - transparent to allow touches through
Spacer(modifier = Modifier.fillMaxSize())
// FAB and menu area
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Bottom,
modifier = Modifier.padding(16.dp)
) {
// 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.FiberManualRecord,
label = "POKEBALL",
containerColor = MaterialTheme.colorScheme.error,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onClassFilterRequested("ball_icon_cherishball")
onDetectionRequested()
isMenuExpanded = false
}
)
MenuFABItem(
icon = Icons.Default.SelectAll,
label = "ALL",
containerColor = MaterialTheme.colorScheme.secondary,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onClassFilterRequested(null)
onDetectionRequested()
isMenuExpanded = false
}
)
MenuFABItem(
icon = Icons.Default.BugReport,
label = "DEBUG",
containerColor = MaterialTheme.colorScheme.outline,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onDebugToggled()
onDetectionRequested()
isMenuExpanded = false
}
)
}
}
// Main FAB
MainFloatingActionButton(
isProcessing = isProcessing,
isMenuExpanded = isMenuExpanded,
onClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
if (!isProcessing) {
isMenuExpanded = !isMenuExpanded
}
},
onLongClick = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onClose()
}
)
}
}
}
@Composable
fun MainFloatingActionButton(
isProcessing: Boolean,
isMenuExpanded: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
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"
)
FloatingActionButton(
onClick = onClick,
modifier = Modifier
.size(56.dp)
.rotate(if (isProcessing) processingRotation else rotation),
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.Sync
isMenuExpanded -> Icons.Default.Close
else -> Icons.Default.CameraAlt
},
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)
)
}
}
}

BIN
test_images/captured_screen_1753995229336.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

BIN
test_images/shiny_test.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Loading…
Cancel
Save