Browse Source
- Add DetectionUIEvents and DetectionUICallbacks interfaces - Create DetectionController for business logic separation - Add FloatingOrbUI pure UI component with no business logic - Establish clean event-driven communication pattern Related todos: #24refactor/mvc-architecture
3 changed files with 464 additions and 0 deletions
@ -0,0 +1,135 @@ |
|||
package com.quillstudios.pokegoalshelper.controllers |
|||
|
|||
import android.util.Log |
|||
import com.quillstudios.pokegoalshelper.YOLOOnnxDetector |
|||
import com.quillstudios.pokegoalshelper.ui.interfaces.DetectionUIEvents |
|||
import com.quillstudios.pokegoalshelper.ui.interfaces.DetectionUICallbacks |
|||
import org.opencv.core.Mat |
|||
|
|||
/** |
|||
* Controller handling detection business logic. |
|||
* Decouples UI interactions from YOLO detection implementation. |
|||
*/ |
|||
class DetectionController( |
|||
private val yoloDetector: YOLOOnnxDetector |
|||
) : DetectionUIEvents { |
|||
|
|||
companion object { |
|||
private const val TAG = "DetectionController" |
|||
} |
|||
|
|||
private var uiCallbacks: DetectionUICallbacks? = null |
|||
private var currentSettings = DetectionSettings() |
|||
|
|||
/** |
|||
* Register UI callbacks for status updates |
|||
*/ |
|||
fun setUICallbacks(callbacks: DetectionUICallbacks) { |
|||
uiCallbacks = callbacks |
|||
} |
|||
|
|||
/** |
|||
* Remove UI callbacks (cleanup) |
|||
*/ |
|||
fun clearUICallbacks() { |
|||
uiCallbacks = null |
|||
} |
|||
|
|||
// === DetectionUIEvents Implementation === |
|||
|
|||
override fun onDetectionRequested() { |
|||
Log.d(TAG, "🔍 Detection requested via controller") |
|||
uiCallbacks?.onDetectionStarted() |
|||
|
|||
// Business logic will be handled by the service layer |
|||
// For now, just notify completion |
|||
uiCallbacks?.onDetectionCompleted(0) |
|||
} |
|||
|
|||
override fun onClassFilterChanged(className: String?) { |
|||
Log.i(TAG, "🔍 Class filter changed to: ${className ?: "ALL CLASSES"}") |
|||
|
|||
currentSettings.classFilter = className |
|||
|
|||
// Apply filter to YOLO detector |
|||
YOLOOnnxDetector.setClassFilter(className) |
|||
|
|||
// Notify UI of settings change |
|||
uiCallbacks?.onSettingsChanged( |
|||
currentSettings.classFilter, |
|||
currentSettings.debugMode, |
|||
currentSettings.coordinateMode |
|||
) |
|||
} |
|||
|
|||
override fun onDebugModeToggled() { |
|||
currentSettings.debugMode = !currentSettings.debugMode |
|||
Log.i(TAG, "📊 Debug mode toggled: ${currentSettings.debugMode}") |
|||
|
|||
// Apply debug mode to YOLO detector |
|||
YOLOOnnxDetector.toggleShowAllConfidences() |
|||
|
|||
// Notify UI of settings change |
|||
uiCallbacks?.onSettingsChanged( |
|||
currentSettings.classFilter, |
|||
currentSettings.debugMode, |
|||
currentSettings.coordinateMode |
|||
) |
|||
} |
|||
|
|||
override fun onCoordinateModeChanged(mode: String) { |
|||
Log.i(TAG, "🔧 Coordinate mode changed to: $mode") |
|||
|
|||
currentSettings.coordinateMode = mode |
|||
|
|||
// Apply coordinate mode to YOLO detector |
|||
YOLOOnnxDetector.setCoordinateMode(mode) |
|||
|
|||
// Notify UI of settings change |
|||
uiCallbacks?.onSettingsChanged( |
|||
currentSettings.classFilter, |
|||
currentSettings.debugMode, |
|||
currentSettings.coordinateMode |
|||
) |
|||
} |
|||
|
|||
// === Business Logic Methods === |
|||
|
|||
/** |
|||
* Process detection on the given image |
|||
* This will be called by the service layer |
|||
*/ |
|||
fun processDetection(inputMat: Mat): Int { |
|||
return try { |
|||
uiCallbacks?.onDetectionStarted() |
|||
|
|||
val detections = yoloDetector.detect(inputMat) |
|||
val detectionCount = detections.size |
|||
|
|||
Log.i(TAG, "✅ Detection completed: $detectionCount objects found") |
|||
uiCallbacks?.onDetectionCompleted(detectionCount) |
|||
|
|||
detectionCount |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "❌ Detection failed", e) |
|||
uiCallbacks?.onDetectionFailed(e.message ?: "Unknown error") |
|||
0 |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get current detection settings |
|||
*/ |
|||
fun getCurrentSettings(): DetectionSettings { |
|||
return currentSettings.copy() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Data class representing current detection settings |
|||
*/ |
|||
data class DetectionSettings( |
|||
var classFilter: String? = null, |
|||
var debugMode: Boolean = false, |
|||
var coordinateMode: String = "HYBRID" |
|||
) |
|||
@ -0,0 +1,269 @@ |
|||
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.ViewGroup |
|||
import android.view.WindowManager |
|||
import android.widget.Button |
|||
import android.widget.LinearLayout |
|||
import com.quillstudios.pokegoalshelper.ui.interfaces.DetectionUIEvents |
|||
import com.quillstudios.pokegoalshelper.ui.interfaces.DetectionUICallbacks |
|||
|
|||
/** |
|||
* Floating orb UI component that handles user interactions. |
|||
* Implements CalcIV-style expandable menu system. |
|||
* |
|||
* This is pure UI logic - no business logic or direct detector calls. |
|||
*/ |
|||
class FloatingOrbUI( |
|||
private val context: Context, |
|||
private val detectionEvents: DetectionUIEvents |
|||
) : DetectionUICallbacks { |
|||
|
|||
companion object { |
|||
private const val TAG = "FloatingOrbUI" |
|||
private const val ORB_SIZE = 120 |
|||
private const val MENU_BUTTON_WIDTH = 160 |
|||
private const val MENU_BUTTON_HEIGHT = 60 |
|||
} |
|||
|
|||
private var windowManager: WindowManager? = null |
|||
private var orbButton: View? = null |
|||
private var expandedMenu: View? = null |
|||
private var isMenuExpanded = false |
|||
private var isProcessing = false |
|||
|
|||
/** |
|||
* Initialize and show the floating orb |
|||
*/ |
|||
fun show() { |
|||
try { |
|||
if (orbButton != null) return // Already shown |
|||
|
|||
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager |
|||
createFloatingOrb() |
|||
Log.d(TAG, "✅ Floating orb UI shown") |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "❌ Error showing floating orb", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Hide and cleanup the floating orb |
|||
*/ |
|||
fun hide() { |
|||
try { |
|||
if (isMenuExpanded) { |
|||
collapseMenu() |
|||
} |
|||
|
|||
orbButton?.let { |
|||
windowManager?.removeView(it) |
|||
orbButton = null |
|||
} |
|||
|
|||
windowManager = null |
|||
Log.d(TAG, "🗑️ Floating orb UI hidden") |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "❌ Error hiding floating orb", e) |
|||
} |
|||
} |
|||
|
|||
// === DetectionUICallbacks Implementation === |
|||
|
|||
override fun onDetectionStarted() { |
|||
isProcessing = true |
|||
updateOrbAppearance() |
|||
|
|||
// Auto-collapse menu during processing |
|||
if (isMenuExpanded) { |
|||
collapseMenu() |
|||
} |
|||
} |
|||
|
|||
override fun onDetectionCompleted(detectionCount: Int) { |
|||
isProcessing = false |
|||
updateOrbAppearance() |
|||
Log.d(TAG, "🎯 Detection completed: $detectionCount objects") |
|||
} |
|||
|
|||
override fun onDetectionFailed(error: String) { |
|||
isProcessing = false |
|||
updateOrbAppearance() |
|||
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") |
|||
// UI could update visual indicators here if needed |
|||
} |
|||
|
|||
// === Private UI Methods === |
|||
|
|||
private fun createFloatingOrb() { |
|||
orbButton = Button(context).apply { |
|||
text = "🎯" |
|||
textSize = 20f |
|||
setBackgroundResource(android.R.drawable.btn_default) |
|||
background.setTint(0xFF4CAF50.toInt()) // Green |
|||
setTextColor(0xFFFFFFFF.toInt()) |
|||
|
|||
width = ORB_SIZE |
|||
height = ORB_SIZE |
|||
layoutParams = ViewGroup.LayoutParams(ORB_SIZE, ORB_SIZE) |
|||
|
|||
setOnClickListener { handleOrbClick() } |
|||
} |
|||
|
|||
val params = WindowManager.LayoutParams( |
|||
ORB_SIZE, ORB_SIZE, |
|||
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.TOP or Gravity.START |
|||
x = 50 |
|||
y = 200 |
|||
} |
|||
|
|||
windowManager?.addView(orbButton, params) |
|||
} |
|||
|
|||
private fun handleOrbClick() { |
|||
if (isProcessing) { |
|||
Log.d(TAG, "⚠️ Ignoring click - detection in progress") |
|||
return |
|||
} |
|||
|
|||
if (isMenuExpanded) { |
|||
collapseMenu() |
|||
} else { |
|||
expandMenu() |
|||
} |
|||
} |
|||
|
|||
private fun expandMenu() { |
|||
if (isMenuExpanded || isProcessing) return |
|||
|
|||
val menuContainer = LinearLayout(context).apply { |
|||
orientation = LinearLayout.VERTICAL |
|||
setBackgroundColor(0xE0000000.toInt()) // Semi-transparent black |
|||
setPadding(16, 16, 16, 16) |
|||
} |
|||
|
|||
// Define menu options with their actions |
|||
val menuItems = listOf( |
|||
MenuOption("🔍 DETECT", 0xFF4CAF50.toInt()) { |
|||
detectionEvents.onDetectionRequested() |
|||
}, |
|||
MenuOption("SHINY", 0xFFFFD700.toInt()) { |
|||
detectionEvents.onClassFilterChanged("shiny_icon") |
|||
detectionEvents.onDetectionRequested() |
|||
}, |
|||
MenuOption("POKEBALL", 0xFFE91E63.toInt()) { |
|||
detectionEvents.onClassFilterChanged("ball_icon_cherishball") |
|||
detectionEvents.onDetectionRequested() |
|||
}, |
|||
MenuOption("ALL", 0xFF607D8B.toInt()) { |
|||
detectionEvents.onClassFilterChanged(null) |
|||
detectionEvents.onDetectionRequested() |
|||
}, |
|||
MenuOption("DEBUG", 0xFFFF5722.toInt()) { |
|||
detectionEvents.onDebugModeToggled() |
|||
detectionEvents.onDetectionRequested() |
|||
} |
|||
) |
|||
|
|||
menuItems.forEach { option -> |
|||
val button = createMenuButton(option) |
|||
menuContainer.addView(button) |
|||
} |
|||
|
|||
expandedMenu = menuContainer |
|||
|
|||
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.TOP or Gravity.START |
|||
x = 180 // Position next to the orb |
|||
y = 200 |
|||
} |
|||
|
|||
windowManager?.addView(expandedMenu, params) |
|||
isMenuExpanded = true |
|||
updateOrbAppearance() |
|||
} |
|||
|
|||
private fun collapseMenu() { |
|||
if (!isMenuExpanded) return |
|||
|
|||
expandedMenu?.let { windowManager?.removeView(it) } |
|||
expandedMenu = null |
|||
isMenuExpanded = false |
|||
updateOrbAppearance() |
|||
} |
|||
|
|||
private fun createMenuButton(option: MenuOption): Button { |
|||
return Button(context).apply { |
|||
text = option.text |
|||
textSize = 12f |
|||
setBackgroundColor(option.color) |
|||
setTextColor(0xFFFFFFFF.toInt()) |
|||
layoutParams = LinearLayout.LayoutParams(MENU_BUTTON_WIDTH, MENU_BUTTON_HEIGHT).apply { |
|||
setMargins(0, 0, 0, 8) |
|||
} |
|||
setOnClickListener { |
|||
option.action() |
|||
collapseMenu() |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun updateOrbAppearance() { |
|||
(orbButton as? Button)?.apply { |
|||
when { |
|||
isProcessing -> { |
|||
text = "⏳" |
|||
background.setTint(0xFFFF9800.toInt()) // Orange |
|||
} |
|||
isMenuExpanded -> { |
|||
text = "✖" |
|||
background.setTint(0xFFFF5722.toInt()) // Orange-red |
|||
} |
|||
else -> { |
|||
text = "🎯" |
|||
background.setTint(0xFF4CAF50.toInt()) // Green |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Data class for menu options |
|||
*/ |
|||
private data class MenuOption( |
|||
val text: String, |
|||
val color: Int, |
|||
val action: () -> Unit |
|||
) |
|||
} |
|||
@ -0,0 +1,60 @@ |
|||
package com.quillstudios.pokegoalshelper.ui.interfaces |
|||
|
|||
/** |
|||
* Interface for UI events related to detection functionality. |
|||
* UI components implement this to communicate with business logic controllers. |
|||
*/ |
|||
interface DetectionUIEvents { |
|||
/** |
|||
* Triggered when user requests manual detection |
|||
*/ |
|||
fun onDetectionRequested() |
|||
|
|||
/** |
|||
* Triggered when user changes class filter |
|||
* @param className Name of class to filter, or null for all classes |
|||
*/ |
|||
fun onClassFilterChanged(className: String?) |
|||
|
|||
/** |
|||
* Triggered when user toggles debug mode |
|||
*/ |
|||
fun onDebugModeToggled() |
|||
|
|||
/** |
|||
* Triggered when user changes coordinate transformation mode |
|||
* @param mode Transformation mode (DIRECT, LETTERBOX, HYBRID) |
|||
*/ |
|||
fun onCoordinateModeChanged(mode: String) |
|||
} |
|||
|
|||
/** |
|||
* Interface for callbacks from business logic back to UI. |
|||
* UI components implement this to receive status updates. |
|||
*/ |
|||
interface DetectionUICallbacks { |
|||
/** |
|||
* Called when detection starts processing |
|||
*/ |
|||
fun onDetectionStarted() |
|||
|
|||
/** |
|||
* Called when detection completes successfully |
|||
* @param detectionCount Number of objects detected |
|||
*/ |
|||
fun onDetectionCompleted(detectionCount: Int) |
|||
|
|||
/** |
|||
* Called when detection fails |
|||
* @param error Error message |
|||
*/ |
|||
fun onDetectionFailed(error: String) |
|||
|
|||
/** |
|||
* Called when settings change |
|||
* @param filterClass Current class filter (null if showing all) |
|||
* @param debugMode Current debug mode state |
|||
* @param coordinateMode Current coordinate transformation mode |
|||
*/ |
|||
fun onSettingsChanged(filterClass: String?, debugMode: Boolean, coordinateMode: String) |
|||
} |
|||
Loading…
Reference in new issue