diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt index fc86aa7..3e3fcf7 100644 --- a/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt @@ -26,6 +26,8 @@ import android.widget.LinearLayout import androidx.core.app.NotificationCompat import com.quillstudios.pokegoalshelper.controllers.DetectionController import com.quillstudios.pokegoalshelper.ui.EnhancedFloatingFAB +import com.quillstudios.pokegoalshelper.ui.DetectionResultHandler +import com.quillstudios.pokegoalshelper.di.ServiceLocator import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManager import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManagerImpl import com.quillstudios.pokegoalshelper.ml.MLInferenceEngine @@ -138,6 +140,7 @@ class ScreenCaptureService : Service() { // MVC Components private lateinit var detectionController: DetectionController private var enhancedFloatingFAB: EnhancedFloatingFAB? = null + private var detectionResultHandler: DetectionResultHandler? = null private val handler = Handler(Looper.getMainLooper()) private var captureInterval = DEFAULT_CAPTURE_INTERVAL_MS @@ -162,6 +165,9 @@ class ScreenCaptureService : Service() { super.onCreate() createNotificationChannel() + // Initialize ServiceLocator if not already done + ServiceLocator.initialize(applicationContext) + // Initialize screen capture manager screenCaptureManager = ScreenCaptureManagerImpl(this, handler) screenCaptureManager.setImageCallback { image -> handleCapturedImage(image) } @@ -196,6 +202,9 @@ class ScreenCaptureService : Service() { onReturnToApp = { returnToMainApp() } ) + // Initialize detection result handler + detectionResultHandler = DetectionResultHandler(this) + PGHLog.d(TAG, "✅ MVC architecture initialized") } @@ -335,6 +344,7 @@ class ScreenCaptureService : Service() { handler.removeCallbacks(captureRunnable) hideDetectionOverlay() enhancedFloatingFAB?.hide() + detectionResultHandler?.cleanup() latestImage?.close() latestImage = null @@ -556,13 +566,40 @@ class ScreenCaptureService : Service() { // Post results back to main thread handler.post { try { + val duration = System.currentTimeMillis() - analysisStartTime + + // Convert MLDetection to Detection for the result handler + val extractorDetections = detections.map { mlDetection -> + com.quillstudios.pokegoalshelper.ml.Detection( + className = mlDetection.className, + confidence = mlDetection.confidence, + boundingBox = com.quillstudios.pokegoalshelper.ml.BoundingBox( + left = mlDetection.boundingBox.left, + top = mlDetection.boundingBox.top, + right = mlDetection.boundingBox.right, + bottom = mlDetection.boundingBox.bottom + ) + ) + } + if (pokemonInfo != null) { PGHLog.i(TAG, "🔥 POKEMON DATA EXTRACTED SUCCESSFULLY!") logPokemonInfo(pokemonInfo) - // TODO: Send to your API - // sendToAPI(pokemonInfo) + + // Handle successful detection + detectionResultHandler?.handleSuccessfulDetection( + detections = extractorDetections, + pokemonInfo = pokemonInfo, + processingTimeMs = duration + ) } else { PGHLog.i(TAG, "❌ Could not extract complete Pokemon info") + + // Handle no results found + detectionResultHandler?.handleNoResults( + detections = extractorDetections, + processingTimeMs = duration + ) } } finally { // Analysis cycle complete, allow next one @@ -579,6 +616,29 @@ class ScreenCaptureService : Service() { PGHLog.e(TAG, "Error in async Pokemon extraction", e) matCopy.release() + // Handle failed detection + val duration = System.currentTimeMillis() - analysisStartTime + val extractorDetections = detections.map { mlDetection -> + com.quillstudios.pokegoalshelper.ml.Detection( + className = mlDetection.className, + confidence = mlDetection.confidence, + boundingBox = com.quillstudios.pokegoalshelper.ml.BoundingBox( + left = mlDetection.boundingBox.left, + top = mlDetection.boundingBox.top, + right = mlDetection.boundingBox.right, + bottom = mlDetection.boundingBox.bottom + ) + ) + } + + handler.post { + detectionResultHandler?.handleFailedDetection( + detections = extractorDetections, + errorMessage = "Processing error: ${e.message}", + processingTimeMs = duration + ) + } + // Clear flag on error too handler.post { isAnalyzing = false diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/ui/DetectionResultHandler.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/DetectionResultHandler.kt new file mode 100644 index 0000000..c52b10e --- /dev/null +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/DetectionResultHandler.kt @@ -0,0 +1,201 @@ +package com.quillstudios.pokegoalshelper.ui + +import android.content.Context +import com.quillstudios.pokegoalshelper.di.ServiceLocator +import com.quillstudios.pokegoalshelper.models.DetectionResult +import com.quillstudios.pokegoalshelper.models.PokemonDetectionInfo +import com.quillstudios.pokegoalshelper.models.PokemonDetectionStats +import com.quillstudios.pokegoalshelper.ml.Detection +import com.quillstudios.pokegoalshelper.utils.PGHLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +/** + * Handles detection results by saving to storage and showing the bottom drawer. + * + * This component bridges the detection pipeline with the results display system, + * converting between the old PokemonInfo format and the new DetectionResult format. + */ +class DetectionResultHandler(private val context: Context) +{ + companion object + { + private const val TAG = "DetectionResultHandler" + } + + private val bottomDrawer = ResultsBottomDrawer(context) + private val coroutineScope = CoroutineScope(Dispatchers.Main) + + /** + * Handle successful detection results. + * Converts old PokemonInfo format to new DetectionResult format. + */ + fun handleSuccessfulDetection( + detections: List, + pokemonInfo: com.quillstudios.pokegoalshelper.PokemonInfo?, + processingTimeMs: Long + ) + { + coroutineScope.launch { + try + { + // Convert old PokemonInfo to new format + val detectionInfo = pokemonInfo?.let { convertPokemonInfo(it) } + + val result = DetectionResult( + timestamp = LocalDateTime.now(), + detections = detections, + pokemonInfo = detectionInfo, + processingTimeMs = processingTimeMs, + success = pokemonInfo != null, + errorMessage = null + ) + + // Save to storage + val storageService = ServiceLocator.getStorageService() + val saved = storageService.saveDetectionResult(result) + + if (saved) + { + PGHLog.d(TAG, "Detection result saved: ${result.id}") + } + else + { + PGHLog.w(TAG, "Failed to save detection result") + } + + // Show bottom drawer + bottomDrawer.show(result) + + PGHLog.d(TAG, "Handled successful detection with ${detections.size} objects") + } + catch (e: Exception) + { + PGHLog.e(TAG, "Error handling successful detection", e) + } + } + } + + /** + * Handle failed detection results. + */ + fun handleFailedDetection( + detections: List, + errorMessage: String, + processingTimeMs: Long + ) + { + coroutineScope.launch { + try + { + val result = DetectionResult( + timestamp = LocalDateTime.now(), + detections = detections, + pokemonInfo = null, + processingTimeMs = processingTimeMs, + success = false, + errorMessage = errorMessage + ) + + // Save to storage + val storageService = ServiceLocator.getStorageService() + val saved = storageService.saveDetectionResult(result) + + if (saved) + { + PGHLog.d(TAG, "Failed detection result saved: ${result.id}") + } + else + { + PGHLog.w(TAG, "Failed to save failed detection result") + } + + // Show bottom drawer + bottomDrawer.show(result) + + PGHLog.d(TAG, "Handled failed detection: $errorMessage") + } + catch (e: Exception) + { + PGHLog.e(TAG, "Error handling failed detection", e) + } + } + } + + /** + * Handle no Pokemon found (successful detection but no results). + */ + fun handleNoResults( + detections: List, + processingTimeMs: Long + ) + { + handleFailedDetection(detections, "No Pokemon detected in current view", processingTimeMs) + } + + /** + * Convert old PokemonInfo format to new PokemonDetectionInfo format. + */ + private fun convertPokemonInfo(oldInfo: com.quillstudios.pokegoalshelper.PokemonInfo): PokemonDetectionInfo + { + // Extract basic stats from old format + val stats = oldInfo.stats?.let { oldStats -> + PokemonDetectionStats( + attack = oldStats.attack, + defense = oldStats.defense, + stamina = oldStats.hp, // HP maps to stamina in Pokemon GO + perfectIV = calculatePerfectIV(oldStats), + attackIV = null, // Not available in old format + defenseIV = null, + staminaIV = null + ) + } + + return PokemonDetectionInfo( + name = oldInfo.species ?: oldInfo.nickname, + cp = null, // Not available in old format - this is Pokemon Home, not GO + hp = oldInfo.stats?.hp, + level = null, // Not available in old format + nationalDexNumber = oldInfo.nationalDexNumber, + stats = stats, + form = null, // Could be extracted from species string if needed + gender = oldInfo.gender + ) + } + + /** + * Calculate perfect IV percentage from stats. + */ + private fun calculatePerfectIV(stats: com.quillstudios.pokegoalshelper.PokemonStats): Float? + { + // This is a simplified calculation - in reality, IV calculation is more complex + val attack = stats.attack ?: return null + val defense = stats.defense ?: return null + val hp = stats.hp ?: return null + + // Max stats vary by Pokemon, but this gives a rough percentage + // In Pokemon Home context, this might not be accurate IVs + val totalStats = attack + defense + hp + val maxPossibleTotal = 300f // Rough estimate + + return (totalStats.toFloat() / maxPossibleTotal * 100f).coerceAtMost(100f) + } + + /** + * Hide the bottom drawer if currently showing. + */ + fun hideDrawer() + { + bottomDrawer.hide() + } + + /** + * Clean up resources. + */ + fun cleanup() + { + bottomDrawer.hide() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/quillstudios/pokegoalshelper/ui/ResultsBottomDrawer.kt b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/ResultsBottomDrawer.kt new file mode 100644 index 0000000..fe8592b --- /dev/null +++ b/app/src/main/java/com/quillstudios/pokegoalshelper/ui/ResultsBottomDrawer.kt @@ -0,0 +1,466 @@ +package com.quillstudios.pokegoalshelper.ui + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.animation.AnimatorListenerAdapter +import android.animation.Animator +import android.content.Context +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.util.TypedValue +import android.view.* +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.* +import androidx.core.content.ContextCompat +import com.quillstudios.pokegoalshelper.R +import com.quillstudios.pokegoalshelper.models.DetectionResult +import com.quillstudios.pokegoalshelper.utils.PGHLog +import java.time.format.DateTimeFormatter +import kotlin.math.abs + +/** + * Bottom drawer that slides up to display detection results immediately after capture. + * + * Features: + * - Slides up from bottom with smooth animation + * - Shows Pokemon detection results with formatted data + * - Auto-dismiss after timeout or manual dismiss + * - Expandable for more details + * - Gesture handling for swipe dismiss + */ +class ResultsBottomDrawer(private val context: Context) +{ + companion object + { + private const val TAG = "ResultsBottomDrawer" + private const val DRAWER_HEIGHT_DP = 120 + private const val SLIDE_ANIMATION_DURATION = 300L + private const val AUTO_DISMISS_DELAY = 5000L // 5 seconds + private const val SWIPE_THRESHOLD = 100f + } + + private var windowManager: WindowManager? = null + private var drawerContainer: LinearLayout? = null + private var drawerParams: WindowManager.LayoutParams? = null + private var isShowing = false + private var isDragging = false + private var autoDismissRunnable: Runnable? = null + + // Touch handling + private var initialTouchY = 0f + private var initialTranslationY = 0f + + fun show(result: DetectionResult) + { + if (isShowing) return + + try + { + windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + createDrawerView(result) + isShowing = true + + // Schedule auto-dismiss + scheduleAutoDismiss() + + PGHLog.d(TAG, "Bottom drawer shown for detection: ${result.id}") + } + catch (e: Exception) + { + PGHLog.e(TAG, "Failed to show bottom drawer", e) + } + } + + fun hide() + { + if (!isShowing) return + + try + { + // Cancel auto-dismiss + autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) } + + // Animate out + animateOut { + try + { + drawerContainer?.let { windowManager?.removeView(it) } + cleanup() + } + catch (e: Exception) + { + PGHLog.e(TAG, "Error removing drawer view", e) + } + } + + PGHLog.d(TAG, "Bottom drawer hidden") + } + catch (e: Exception) + { + PGHLog.e(TAG, "Failed to hide bottom drawer", e) + } + } + + private fun createDrawerView(result: DetectionResult) + { + val screenSize = getScreenSize() + val drawerHeight = dpToPx(DRAWER_HEIGHT_DP) + + // Create main container + drawerContainer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + background = createDrawerBackground() + gravity = Gravity.CENTER_HORIZONTAL + setPadding(dpToPx(16), dpToPx(12), dpToPx(16), dpToPx(16)) + + // Add drag handle + addView(createDragHandle()) + + // Add result content + addView(createResultContent(result)) + + // Set up touch handling for swipe dismiss + setOnTouchListener(createSwipeTouchListener()) + } + + // Create window parameters + drawerParams = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + drawerHeight, + 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_NO_LIMITS, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.BOTTOM + y = 0 + } + + // Add to window manager + windowManager?.addView(drawerContainer, drawerParams) + + // Animate in + animateIn() + } + + private fun createDragHandle(): View + { + return View(context).apply { + background = GradientDrawable().apply { + setColor(ContextCompat.getColor(context, android.R.color.darker_gray)) + cornerRadius = dpToPx(2).toFloat() + } + + layoutParams = LinearLayout.LayoutParams( + dpToPx(40), + dpToPx(4) + ).apply { + setMargins(0, 0, 0, dpToPx(8)) + } + } + } + + private fun createResultContent(result: DetectionResult): LinearLayout + { + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + // Status icon + val statusIcon = ImageView(context).apply { + setImageResource( + if (result.success) android.R.drawable.ic_menu_myplaces + else android.R.drawable.ic_dialog_alert + ) + setColorFilter( + ContextCompat.getColor( + context, + if (result.success) android.R.color.holo_green_light + else android.R.color.holo_red_light + ) + ) + layoutParams = LinearLayout.LayoutParams( + dpToPx(24), + dpToPx(24) + ).apply { + setMargins(0, 0, dpToPx(12), 0) + } + } + + // Content container + val contentContainer = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + layoutParams = LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ) + } + + if (result.success && result.pokemonInfo != null) + { + // Pokemon found - show details + val pokemonInfo = result.pokemonInfo + + // Pokemon name and CP + val titleText = buildString { + append(pokemonInfo.name ?: "Unknown Pokemon") + pokemonInfo.cp?.let { append(" (CP $it)") } + } + + val titleView = TextView(context).apply { + text = titleText + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setTextColor(ContextCompat.getColor(context, android.R.color.white)) + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + + // Additional details + val detailsText = buildString { + pokemonInfo.level?.let { append("Level ${String.format("%.1f", it)}") } + pokemonInfo.stats?.perfectIV?.let { + if (isNotEmpty()) append(" • ") + append("${String.format("%.1f", it)}% IV") + } + pokemonInfo.hp?.let { + if (isNotEmpty()) append(" • ") + append("${it} HP") + } + } + + val detailsView = TextView(context).apply { + text = detailsText + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) + } + + contentContainer.addView(titleView) + if (detailsText.isNotEmpty()) + { + contentContainer.addView(detailsView) + } + } + else + { + // Detection failed or no Pokemon found + val titleView = TextView(context).apply { + text = if (result.success) "No Pokemon detected" else "Detection failed" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) + setTextColor(ContextCompat.getColor(context, android.R.color.white)) + typeface = android.graphics.Typeface.DEFAULT_BOLD + } + + val detailsView = TextView(context).apply { + text = result.errorMessage ?: "Try again with a clearer view" + setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f) + setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) + } + + contentContainer.addView(titleView) + contentContainer.addView(detailsView) + } + + // Processing time and timestamp + val metaText = buildString { + append("${result.processingTimeMs}ms") + append(" • ") + append(result.timestamp.format(DateTimeFormatter.ofPattern("HH:mm:ss"))) + } + + val metaView = TextView(context).apply { + text = metaText + setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f) + setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)) + } + + contentContainer.addView(metaView) + + // Dismiss button + val dismissButton = ImageButton(context).apply { + setImageResource(android.R.drawable.ic_menu_close_clear_cancel) + background = createCircularBackground() + setColorFilter(ContextCompat.getColor(context, android.R.color.white)) + setOnClickListener { hide() } + + layoutParams = LinearLayout.LayoutParams( + dpToPx(32), + dpToPx(32) + ).apply { + setMargins(dpToPx(12), 0, 0, 0) + } + } + + addView(statusIcon) + addView(contentContainer) + addView(dismissButton) + } + } + + private fun createDrawerBackground(): GradientDrawable + { + return GradientDrawable().apply { + setColor(ContextCompat.getColor(context, android.R.color.black)) + alpha = (0.9f * 255).toInt() + cornerRadii = floatArrayOf( + dpToPx(12).toFloat(), dpToPx(12).toFloat(), // top-left + dpToPx(12).toFloat(), dpToPx(12).toFloat(), // top-right + 0f, 0f, // bottom-right + 0f, 0f // bottom-left + ) + setStroke(2, ContextCompat.getColor(context, android.R.color.darker_gray)) + } + } + + private fun createCircularBackground(): GradientDrawable + { + return GradientDrawable().apply { + setColor(ContextCompat.getColor(context, android.R.color.transparent)) + shape = GradientDrawable.OVAL + setStroke(1, ContextCompat.getColor(context, android.R.color.darker_gray)) + } + } + + private fun createSwipeTouchListener(): View.OnTouchListener + { + return View.OnTouchListener { view, event -> + when (event.action) + { + MotionEvent.ACTION_DOWN -> + { + isDragging = false + initialTouchY = event.rawY + initialTranslationY = view.translationY + + // Cancel auto-dismiss while user is interacting + autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) } + true + } + + MotionEvent.ACTION_MOVE -> + { + val deltaY = event.rawY - initialTouchY + + if (!isDragging && abs(deltaY) > 20) + { + isDragging = true + } + + if (isDragging && deltaY > 0) + { + // Only allow downward drag (dismissing) + view.translationY = initialTranslationY + deltaY + } + true + } + + MotionEvent.ACTION_UP -> + { + if (isDragging) + { + val deltaY = event.rawY - initialTouchY + if (deltaY > SWIPE_THRESHOLD) + { + // Dismiss if swiped down enough + hide() + } + else + { + // Snap back + ObjectAnimator.ofFloat(view, "translationY", view.translationY, initialTranslationY).apply { + duration = 200L + interpolator = AccelerateDecelerateInterpolator() + start() + } + + // Restart auto-dismiss + scheduleAutoDismiss() + } + } + else + { + // Restart auto-dismiss on tap + scheduleAutoDismiss() + } + + isDragging = false + true + } + + else -> false + } + } + } + + private fun animateIn() + { + drawerContainer?.let { container -> + val screenHeight = getScreenSize().second + container.translationY = dpToPx(DRAWER_HEIGHT_DP).toFloat() + + ObjectAnimator.ofFloat(container, "translationY", container.translationY, 0f).apply { + duration = SLIDE_ANIMATION_DURATION + interpolator = AccelerateDecelerateInterpolator() + start() + } + } + } + + private fun animateOut(onComplete: () -> Unit) + { + drawerContainer?.let { container -> + ObjectAnimator.ofFloat(container, "translationY", 0f, dpToPx(DRAWER_HEIGHT_DP).toFloat()).apply { + duration = SLIDE_ANIMATION_DURATION + interpolator = AccelerateDecelerateInterpolator() + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + onComplete() + } + }) + start() + } + } + } + + private fun scheduleAutoDismiss() + { + autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) } + + autoDismissRunnable = Runnable { hide() } + android.os.Handler().postDelayed(autoDismissRunnable!!, AUTO_DISMISS_DELAY) + } + + private fun cleanup() + { + drawerContainer = null + drawerParams = null + windowManager = null + isShowing = false + autoDismissRunnable?.let { android.os.Handler().removeCallbacks(it) } + autoDismissRunnable = null + } + + private fun getScreenSize(): Pair + { + val displayMetrics = context.resources.displayMetrics + return Pair(displayMetrics.widthPixels, displayMetrics.heightPixels) + } + + private fun dpToPx(dp: Int): Int + { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + context.resources.displayMetrics + ).toInt() + } +} \ No newline at end of file