Compare commits

...

17 Commits

Author SHA1 Message Date
Quildra 023e6496a4 fix: resolve NullPointerException crash when tapping collapsed history entries 5 months ago
Quildra 50514d5272 fix: implement sophisticated touch event handling for nested NestedScrollView 5 months ago
Quildra 75ae5f8e4b feat: implement proper nested scrolling in history cards using architectural redesign 5 months ago
Quildra 293c0af196 fix: replace ScrollView with NestedScrollView for proper nested scrolling 5 months ago
Quildra e63e86e9d2 fix: ensure ScrollView content actually scrolls in expanded history cards 5 months ago
Quildra 6319f0f9d9 fix: improve scrolling behavior for expanded history cards 5 months ago
Quildra bc715fbc25 feat: enhance history expanded view to match drawer details 5 months ago
Quildra 3c1d730f3d feat: implement history list UI with expandable Pokemon cards (PGH-17) 5 months ago
Quildra 79489fa4c5 tweak: add in a close button for the bottom draw 5 months ago
Quildra fb2e481e87 feat: add spinning FAB animation and disable default overlay 5 months ago
Quildra d320ea1f0d feat: add re-show drawer button and auto-update functionality 5 months ago
Quildra 65fadd2060 feat: improve drawer behavior with scrolling and persistent minimal state 5 months ago
Quildra f6c89c9727 fix: use actual PokemonInfo data instead of incorrect Pokemon GO concepts 5 months ago
Quildra 013593cdca feat: enhance bottom drawer with multi-column layout and checkbox functionality 5 months ago
Quildra 66aae07e94 feat: enhance bottom drawer with expandable display and no auto-dismiss 5 months ago
Quildra 8611ca2b3e feat: implement PGH-16 Bottom Drawer Results UI with real-time integration 5 months ago
Quildra 8d9e4fd35c feat: implement PGH-15 Storage Interface & In-Memory Implementation 5 months ago
  1. 9
      .claude/settings.local.json
  2. 3
      .vscode/settings.json
  3. 59
      CLAUDE.md
  4. 1
      app/build.gradle
  5. 109
      app/src/main/java/com/quillstudios/pokegoalshelper/MainActivity.kt
  6. 196
      app/src/main/java/com/quillstudios/pokegoalshelper/ScreenCaptureService.kt
  7. 103
      app/src/main/java/com/quillstudios/pokegoalshelper/di/ServiceLocator.kt
  8. 78
      app/src/main/java/com/quillstudios/pokegoalshelper/models/DetectionResult.kt
  9. 258
      app/src/main/java/com/quillstudios/pokegoalshelper/storage/InMemoryStorageService.kt
  10. 112
      app/src/main/java/com/quillstudios/pokegoalshelper/storage/StorageInterface.kt
  11. 149
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/DetectionResultHandler.kt
  12. 52
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt
  13. 786
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/HistoryAdapter.kt
  14. 193
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/HistoryFragment.kt
  15. 981
      app/src/main/java/com/quillstudios/pokegoalshelper/ui/ResultsBottomDrawer.kt
  16. 261
      app/src/test/java/com/quillstudios/pokegoalshelper/storage/InMemoryStorageServiceTest.kt
  17. 112
      temp/HumanInTheLoop.py
  18. 8402
      temp/Pokemon_Home_Training.ipynb

9
.claude/settings.local.json

@ -12,7 +12,14 @@
"mcp__atlassian__createJiraIssue", "mcp__atlassian__createJiraIssue",
"mcp__atlassian__getConfluencePage", "mcp__atlassian__getConfluencePage",
"mcp__atlassian__getPagesInConfluenceSpace", "mcp__atlassian__getPagesInConfluenceSpace",
"mcp__atlassian__getJiraIssue" "mcp__atlassian__getJiraIssue",
"Bash(JAVA_HOME=\"C:\\Program Files\\Android\\Android Studio\\jbr\" ./gradlew compileDebugKotlin)",
"Bash(JAVA_HOME=\"C:\\Program Files\\Android\\Android Studio\\jbr\" ./gradlew assembleDebug -x lint)",
"mcp__atlassian__searchJiraIssuesUsingJql",
"mcp__atlassian__editJiraIssue",
"mcp__atlassian__getTransitionsForJiraIssue",
"mcp__atlassian__transitionJiraIssue",
"mcp__atlassian__addCommentToJiraIssue"
], ],
"deny": [] "deny": []
} }

3
.vscode/settings.json

@ -0,0 +1,3 @@
{
"cmake.ignoreCMakeListsMissing": true
}

59
CLAUDE.md

@ -297,4 +297,61 @@ When working on this project:
6. Test changes incrementally 6. Test changes incrementally
7. Update documentation when architecture changes 7. Update documentation when architecture changes
8. Use the build commands above for compilation testing 8. Use the build commands above for compilation testing
9. When asked about Jira/Confluence, use the Atlassian resources defined above 9. When asked about Jira/Confluence, use the Atlassian resources defined above
## JIRA Management Guidelines
### Task Status Updates
- **NEVER edit task descriptions** to update status or progress
- **ALWAYS use comments** to provide status updates instead
- **Preserve original task descriptions** - they document the original intent and requirements
- **Comments should include**: current progress, blockers, next steps, implementation notes
### Task Completion Rules
- **NEVER mark tasks as "Done"** without explicit user approval
- **Code compilation ≠ task completion** - must be tested on actual device
- **Implementation complete ≠ task complete** - requires validation and testing
- **Always check with user** before transitioning tasks to "Done" status
- **Use "In Progress"** for actively worked tasks, even if implementation is complete
### Progress Documentation
- **Use comments for progress updates**:
```
Progress Update:
✅ Implementation complete - added close functionality to drawer
✅ Compilation successful
🔄 Next steps: Device testing required before marking complete
📋 Files modified: ResultsBottomDrawer.kt, enhanced swipe-to-dismiss
```
### Status Workflow
1. **To Do** → Start working
2. **In Progress** → Implementation and testing in progress
3. **Ready for Review** → Implementation complete, needs device testing/validation
4. **Done** → Only after user confirmation that feature works as expected
### Implementation vs Completion
- **Implementation Complete**: Code written, compiles, logic appears correct
- **Task Complete**: Feature tested on device, user validated, works as intended
- **Always distinguish** between these two states in updates
### Example Comment Format
```
**Implementation Progress:**
- ✅ Added swipe-to-dismiss functionality
- ✅ Enhanced close buttons in both states
- ✅ Build compilation successful
- 🔄 **Pending**: Device testing to validate gesture behavior
- 🔄 **Pending**: User validation of close functionality
**Technical Details:**
- Modified touch handling in ResultsBottomDrawer.kt
- Enhanced gesture detection for full dismiss vs collapse
- Added header close button for expanded state
**Next Steps:**
- Device testing required
- User validation needed before marking Done
```
This preserves original task intent while providing clear progress visibility.

1
app/build.gradle

@ -56,6 +56,7 @@ dependencies {
implementation libs.androidx.ui.graphics implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview implementation libs.androidx.ui.tooling.preview
implementation libs.androidx.material3 implementation libs.androidx.material3
implementation 'androidx.navigation:navigation-compose:2.7.6'
testImplementation libs.junit testImplementation libs.junit
androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core androidTestImplementation libs.androidx.espresso.core

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

@ -14,7 +14,7 @@ import android.util.Log
import com.quillstudios.pokegoalshelper.utils.PGHLog import com.quillstudios.pokegoalshelper.utils.PGHLog
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.activity.ComponentActivity import androidx.fragment.app.FragmentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -25,12 +25,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.navigation.compose.NavHost
import android.view.View
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.quillstudios.pokegoalshelper.ui.theme.PokeGoalsHelperTheme import com.quillstudios.pokegoalshelper.ui.theme.PokeGoalsHelperTheme
import com.quillstudios.pokegoalshelper.ui.HistoryFragment
import com.quillstudios.pokegoalshelper.di.ServiceLocator
import org.opencv.android.OpenCVLoader import org.opencv.android.OpenCVLoader
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.CvType import org.opencv.core.CvType
class MainActivity : ComponentActivity() { class MainActivity : FragmentActivity() {
companion object { companion object {
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
@ -157,15 +164,33 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Initialize ServiceLocator
ServiceLocator.initialize(applicationContext)
setContent { setContent {
PokeGoalsHelperTheme { PokeGoalsHelperTheme {
val navController = rememberNavController()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
ScreenCaptureUI( NavHost(
isCapturing = isCapturing, navController = navController,
onStartCapture = { requestScreenCapturePermission() }, startDestination = "main",
onStopCapture = { stopScreenCaptureService() },
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) ) {
composable("main") {
ScreenCaptureUI(
isCapturing = isCapturing,
onStartCapture = { requestScreenCapturePermission() },
onStopCapture = { stopScreenCaptureService() },
onNavigateToHistory = { navController.navigate("history") }
)
}
composable("history") {
HistoryUI(
onNavigateBack = { navController.popBackStack() }
)
}
}
} }
} }
} }
@ -182,6 +207,7 @@ fun ScreenCaptureUI(
isCapturing: Boolean, isCapturing: Boolean,
onStartCapture: () -> Unit, onStartCapture: () -> Unit,
onStopCapture: () -> Unit, onStopCapture: () -> Unit,
onNavigateToHistory: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( Column(
@ -241,6 +267,18 @@ fun ScreenCaptureUI(
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
} }
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onNavigateToHistory,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
),
modifier = Modifier.fillMaxWidth()
) {
Text("View Detection History")
}
} }
} }
@ -254,6 +292,60 @@ fun ScreenCaptureUI(
} }
} }
@Composable
fun HistoryUI(
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize()
) {
// Header with back button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Detection History",
style = MaterialTheme.typography.headlineMedium
)
Button(
onClick = onNavigateBack,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("← Back")
}
}
// HistoryFragment embedded in Compose
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val fragmentManager = (context as androidx.fragment.app.FragmentActivity).supportFragmentManager
val historyFragment = HistoryFragment()
// Create a container for the fragment
val container = android.widget.FrameLayout(context).apply {
id = View.generateViewId()
}
// Add the fragment to the container
fragmentManager.beginTransaction()
.replace(container.id, historyFragment)
.commit()
container
}
)
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun ScreenCaptureUIPreview() { fun ScreenCaptureUIPreview() {
@ -261,7 +353,8 @@ fun ScreenCaptureUIPreview() {
ScreenCaptureUI( ScreenCaptureUI(
isCapturing = false, isCapturing = false,
onStartCapture = {}, onStartCapture = {},
onStopCapture = {} onStopCapture = {},
onNavigateToHistory = {}
) )
} }
} }

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

@ -26,6 +26,8 @@ import android.widget.LinearLayout
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.quillstudios.pokegoalshelper.controllers.DetectionController import com.quillstudios.pokegoalshelper.controllers.DetectionController
import com.quillstudios.pokegoalshelper.ui.EnhancedFloatingFAB 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.ScreenCaptureManager
import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManagerImpl import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManagerImpl
import com.quillstudios.pokegoalshelper.ml.MLInferenceEngine import com.quillstudios.pokegoalshelper.ml.MLInferenceEngine
@ -132,12 +134,13 @@ class ScreenCaptureService : Service() {
private lateinit var screenCaptureManager: ScreenCaptureManager private lateinit var screenCaptureManager: ScreenCaptureManager
private var detectionOverlay: DetectionOverlay? = null private var detectionOverlay: DetectionOverlay? = null
private var overlayEnabled = true // Track overlay visibility state private var overlayEnabled = false // Track overlay visibility state
private var lastDetections: List<MLDetection> = emptyList() // Cache last detections for toggle private var lastDetections: List<MLDetection> = emptyList() // Cache last detections for toggle
// MVC Components // MVC Components
private lateinit var detectionController: DetectionController private lateinit var detectionController: DetectionController
private var enhancedFloatingFAB: EnhancedFloatingFAB? = null private var enhancedFloatingFAB: EnhancedFloatingFAB? = null
private var detectionResultHandler: DetectionResultHandler? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var captureInterval = DEFAULT_CAPTURE_INTERVAL_MS private var captureInterval = DEFAULT_CAPTURE_INTERVAL_MS
@ -162,6 +165,9 @@ class ScreenCaptureService : Service() {
super.onCreate() super.onCreate()
createNotificationChannel() createNotificationChannel()
// Initialize ServiceLocator if not already done
ServiceLocator.initialize(applicationContext)
// Initialize screen capture manager // Initialize screen capture manager
screenCaptureManager = ScreenCaptureManagerImpl(this, handler) screenCaptureManager = ScreenCaptureManagerImpl(this, handler)
screenCaptureManager.setImageCallback { image -> handleCapturedImage(image) } screenCaptureManager.setImageCallback { image -> handleCapturedImage(image) }
@ -193,9 +199,13 @@ class ScreenCaptureService : Service() {
context = this, context = this,
onDetectionRequested = { triggerDetection() }, onDetectionRequested = { triggerDetection() },
onToggleOverlay = { toggleOverlay() }, onToggleOverlay = { toggleOverlay() },
onReturnToApp = { returnToMainApp() } onReturnToApp = { returnToMainApp() },
onShowLastResult = { showLastDetectionResult() }
) )
// Initialize detection result handler
detectionResultHandler = DetectionResultHandler(this)
PGHLog.d(TAG, "✅ MVC architecture initialized") PGHLog.d(TAG, "✅ MVC architecture initialized")
} }
@ -210,41 +220,6 @@ class ScreenCaptureService : Service() {
triggerManualDetection() triggerManualDetection()
} }
/**
* Toggle overlay visibility from UI
*/
fun toggleOverlay() {
overlayEnabled = !overlayEnabled
if (!overlayEnabled) {
hideDetectionOverlay()
PGHLog.i(TAG, "🔇 Detection overlay disabled and hidden")
} else {
// Show cached detections immediately if available
if (lastDetections.isNotEmpty()) {
showYOLODetectionOverlay(lastDetections)
PGHLog.i(TAG, "🔊 Detection overlay enabled and showing ${lastDetections.size} cached detections")
} else {
PGHLog.i(TAG, "🔊 Detection overlay enabled (no cached detections to show)")
}
}
}
/**
* Return to main app from UI
*/
fun returnToMainApp() {
try {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
startActivity(intent)
PGHLog.i(TAG, "🏠 Returning to main app")
} catch (e: Exception) {
PGHLog.e(TAG, "Failed to return to main app", e)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { when (intent?.action) {
@ -335,6 +310,7 @@ class ScreenCaptureService : Service() {
handler.removeCallbacks(captureRunnable) handler.removeCallbacks(captureRunnable)
hideDetectionOverlay() hideDetectionOverlay()
enhancedFloatingFAB?.hide() enhancedFloatingFAB?.hide()
detectionResultHandler?.cleanup()
latestImage?.close() latestImage?.close()
latestImage = null latestImage = null
@ -556,13 +532,46 @@ class ScreenCaptureService : Service() {
// Post results back to main thread // Post results back to main thread
handler.post { handler.post {
try { 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) { if (pokemonInfo != null) {
PGHLog.i(TAG, "🔥 POKEMON DATA EXTRACTED SUCCESSFULLY!") PGHLog.i(TAG, "🔥 POKEMON DATA EXTRACTED SUCCESSFULLY!")
logPokemonInfo(pokemonInfo) logPokemonInfo(pokemonInfo)
// TODO: Send to your API
// sendToAPI(pokemonInfo) // Handle successful detection
detectionResultHandler?.handleSuccessfulDetection(
detections = extractorDetections,
pokemonInfo = pokemonInfo,
processingTimeMs = duration
)
// Stop spinning animation - detection complete
enhancedFloatingFAB?.stopDetectionAnimation()
} else { } else {
PGHLog.i(TAG, "❌ Could not extract complete Pokemon info") PGHLog.i(TAG, "❌ Could not extract complete Pokemon info")
// Handle no results found
detectionResultHandler?.handleNoResults(
detections = extractorDetections,
processingTimeMs = duration
)
// Stop spinning animation - detection complete (no results)
enhancedFloatingFAB?.stopDetectionAnimation()
} }
} finally { } finally {
// Analysis cycle complete, allow next one // Analysis cycle complete, allow next one
@ -579,6 +588,32 @@ class ScreenCaptureService : Service() {
PGHLog.e(TAG, "Error in async Pokemon extraction", e) PGHLog.e(TAG, "Error in async Pokemon extraction", e)
matCopy.release() 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
)
// Stop spinning animation - detection failed
enhancedFloatingFAB?.stopDetectionAnimation()
}
// Clear flag on error too // Clear flag on error too
handler.post { handler.post {
isAnalyzing = false isAnalyzing = false
@ -1159,6 +1194,9 @@ class ScreenCaptureService : Service() {
private fun triggerManualDetection() { private fun triggerManualDetection() {
PGHLog.d(TAG, "🔍 Manual detection triggered via MVC!") PGHLog.d(TAG, "🔍 Manual detection triggered via MVC!")
// Start spinning animation to show detection is in progress
enhancedFloatingFAB?.startDetectionAnimation()
latestImage?.let { image -> latestImage?.let { image ->
try { try {
// Convert image to Mat for processing // Convert image to Mat for processing
@ -1184,11 +1222,16 @@ class ScreenCaptureService : Service() {
// Extract Pokemon info using YOLO detections with OCR // Extract Pokemon info using YOLO detections with OCR
extractPokemonInfoFromYOLOAsync(mat, detections) extractPokemonInfoFromYOLOAsync(mat, detections)
} else {
// No detections found - stop animation
PGHLog.i(TAG, "No detections found")
enhancedFloatingFAB?.stopDetectionAnimation()
} }
mat.release() mat.release()
} else { } else {
PGHLog.e(TAG, "❌ Failed to convert image to Mat") PGHLog.e(TAG, "❌ Failed to convert image to Mat")
enhancedFloatingFAB?.stopDetectionAnimation()
} }
// Close the image after processing to free the buffer // Close the image after processing to free the buffer
@ -1197,9 +1240,11 @@ class ScreenCaptureService : Service() {
} catch (e: Exception) { } catch (e: Exception) {
PGHLog.e(TAG, "❌ Error in manual detection", e) PGHLog.e(TAG, "❌ Error in manual detection", e)
enhancedFloatingFAB?.stopDetectionAnimation()
} }
} ?: run { } ?: run {
PGHLog.w(TAG, "⚠️ No image available for detection") PGHLog.w(TAG, "⚠️ No image available for detection")
enhancedFloatingFAB?.stopDetectionAnimation()
} }
} }
@ -1233,4 +1278,77 @@ class ScreenCaptureService : Service() {
stopScreenCapture() stopScreenCapture()
} }
/**
* Show the last detection result by re-displaying the drawer
*/
private fun showLastDetectionResult() {
// Run in background to avoid blocking UI
ocrExecutor.execute {
try {
// Use a proper coroutine scope for suspend function
val results = runBlocking {
val storageService = ServiceLocator.getStorageService()
storageService.getDetectionResults(
filter = null,
sortBy = com.quillstudios.pokegoalshelper.models.DetectionSortBy.TIMESTAMP_DESC
)
}
val latestResult = results.firstOrNull()
if (latestResult != null) {
// Show the drawer on the main thread
handler.post {
detectionResultHandler?.let { handler ->
// Use reflection to access the private bottomDrawer field
try {
val bottomDrawer = handler.javaClass.getDeclaredField("bottomDrawer")
bottomDrawer.isAccessible = true
val drawer = bottomDrawer.get(handler)
drawer.javaClass.getMethod("show", com.quillstudios.pokegoalshelper.models.DetectionResult::class.java)
.invoke(drawer, latestResult)
PGHLog.d(TAG, "✅ Re-showed last detection result: ${latestResult.id}")
} catch (e: Exception) {
PGHLog.e(TAG, "❌ Error accessing drawer via reflection", e)
}
}
}
} else {
PGHLog.i(TAG, "ℹ️ No previous detection results to show")
}
} catch (e: Exception) {
PGHLog.e(TAG, "❌ Error showing last detection result", e)
}
}
}
/**
* Toggle the detection overlay on/off
*/
private fun toggleOverlay() {
overlayEnabled = !overlayEnabled
if (overlayEnabled && lastDetections.isNotEmpty()) {
showYOLODetectionOverlay(lastDetections)
PGHLog.d(TAG, "✅ Detection overlay enabled")
} else {
hideDetectionOverlay()
PGHLog.d(TAG, "✅ Detection overlay disabled")
}
}
/**
* Return to the main app
*/
private fun returnToMainApp() {
try {
val intent = packageManager.getLaunchIntentForPackage(packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(intent)
PGHLog.d(TAG, "✅ Returned to main app")
} catch (e: Exception) {
PGHLog.e(TAG, "❌ Error returning to main app", e)
}
}
} }

103
app/src/main/java/com/quillstudios/pokegoalshelper/di/ServiceLocator.kt

@ -0,0 +1,103 @@
package com.quillstudios.pokegoalshelper.di
import android.content.Context
import com.quillstudios.pokegoalshelper.storage.StorageInterface
import com.quillstudios.pokegoalshelper.storage.InMemoryStorageService
import com.quillstudios.pokegoalshelper.utils.PGHLog
/**
* Simple service locator for dependency injection.
*
* Provides centralized access to application services with lazy initialization.
* This approach allows easy swapping of implementations (e.g., in-memory -> database)
* without changing dependent code.
*/
object ServiceLocator
{
private const val TAG = "ServiceLocator"
private var _storageService: StorageInterface? = null
private var _applicationContext: Context? = null
/**
* Initialize the service locator with application context.
* Should be called once during application startup.
*/
fun initialize(applicationContext: Context)
{
_applicationContext = applicationContext
PGHLog.d(TAG, "ServiceLocator initialized")
}
/**
* Get the storage service instance.
* Creates and initializes the service on first access.
*/
suspend fun getStorageService(): StorageInterface
{
if (_storageService == null)
{
_storageService = createStorageService()
_storageService?.initialize()
PGHLog.d(TAG, "Storage service created and initialized")
}
return _storageService!!
}
/**
* Get the storage service instance synchronously.
* Note: Service must already be initialized via getStorageService() first.
*/
fun getStorageServiceSync(): StorageInterface
{
return _storageService ?: run {
// Create service synchronously but initialization will happen later
val service = createStorageService()
_storageService = service
PGHLog.d(TAG, "Storage service created synchronously")
service
}
}
/**
* Manually set the storage service implementation.
* Useful for testing or switching implementations at runtime.
*/
suspend fun setStorageService(service: StorageInterface)
{
_storageService?.cleanup()
_storageService = service
_storageService?.initialize()
PGHLog.d(TAG, "Storage service implementation changed")
}
/**
* Clean up all services.
* Should be called during application shutdown.
*/
suspend fun cleanup()
{
_storageService?.cleanup()
_storageService = null
PGHLog.d(TAG, "ServiceLocator cleaned up")
}
/**
* Create the default storage service implementation.
* Override this to change the default implementation.
*/
private fun createStorageService(): StorageInterface
{
// For now, we use in-memory storage
// Later this can be changed to database-backed storage
return InMemoryStorageService()
}
/**
* Get application context (for services that need it)
*/
fun getApplicationContext(): Context
{
return _applicationContext ?: throw IllegalStateException("ServiceLocator not initialized")
}
}

78
app/src/main/java/com/quillstudios/pokegoalshelper/models/DetectionResult.kt

@ -0,0 +1,78 @@
package com.quillstudios.pokegoalshelper.models
import com.quillstudios.pokegoalshelper.ml.Detection
import java.time.LocalDateTime
import java.util.UUID
/**
* Complete Pokemon detection result for storage and display.
* Combines ML detection data with extracted Pokemon information and metadata.
*/
data class DetectionResult(
val id: String = UUID.randomUUID().toString(),
val timestamp: LocalDateTime = LocalDateTime.now(),
val detections: List<Detection>,
val pokemonInfo: com.quillstudios.pokegoalshelper.PokemonInfo?,
val processingTimeMs: Long,
val success: Boolean,
val errorMessage: String? = null
)
/**
* Pokemon-specific information extracted via OCR and processing.
* Uses import alias to avoid conflicts with existing PokemonInfo in ScreenCaptureService.
*/
data class PokemonDetectionInfo(
val name: String? = null,
val cp: Int? = null,
val hp: Int? = null,
val level: Float? = null,
val nationalDexNumber: Int? = null,
val stats: PokemonDetectionStats? = null,
val form: String? = null,
val gender: String? = null
)
/**
* Pokemon stats information (when available from OCR).
* Uses different name to avoid conflicts with existing PokemonStats.
*/
data class PokemonDetectionStats(
val attack: Int? = null,
val defense: Int? = null,
val stamina: Int? = null,
val perfectIV: Float? = null,
val attackIV: Int? = null,
val defenseIV: Int? = null,
val staminaIV: Int? = null
)
/**
* Filter criteria for querying detection results.
*/
data class DetectionFilter(
val pokemonName: String? = null,
val minLevel: Int? = null,
val maxLevel: Int? = null,
val isShiny: Boolean? = null,
val isAlpha: Boolean? = null,
val gameSource: String? = null,
val dateRange: Pair<LocalDateTime, LocalDateTime>? = null,
val successOnly: Boolean = false,
val limit: Int? = null
)
/**
* Sort options for detection results.
*/
enum class DetectionSortBy(val description: String)
{
TIMESTAMP_DESC("Newest first"),
TIMESTAMP_ASC("Oldest first"),
LEVEL_DESC("Highest level first"),
LEVEL_ASC("Lowest level first"),
CONFIDENCE_DESC("Best extraction confidence first"),
CONFIDENCE_ASC("Worst extraction confidence first"),
NAME_ASC("Name A-Z"),
NAME_DESC("Name Z-A")
}

258
app/src/main/java/com/quillstudios/pokegoalshelper/storage/InMemoryStorageService.kt

@ -0,0 +1,258 @@
package com.quillstudios.pokegoalshelper.storage
import com.quillstudios.pokegoalshelper.models.DetectionResult
import com.quillstudios.pokegoalshelper.models.DetectionFilter
import com.quillstudios.pokegoalshelper.models.DetectionSortBy
import com.quillstudios.pokegoalshelper.utils.PGHLog
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
/**
* In-memory implementation of StorageInterface.
*
* This provides a fast, thread-safe storage solution that persists data only during
* the application lifecycle. Perfect for initial implementation and testing.
*
* Features:
* - Thread-safe operations using Mutex and ConcurrentHashMap
* - Reactive Flow-based updates for UI
* - Memory usage tracking
* - Filtering and sorting capabilities
* - Statistics collection
*/
class InMemoryStorageService : StorageInterface
{
companion object
{
private const val TAG = "InMemoryStorage"
private const val MAX_RESULTS = 1000 // Prevent memory overflow
}
// Thread-safe storage
private val storage = ConcurrentHashMap<String, DetectionResult>()
private val mutex = Mutex()
// Reactive state for UI updates
private val _recentResultsFlow = MutableStateFlow<List<DetectionResult>>(emptyList())
// Statistics tracking
private var totalSaveAttempts = 0
private var successfulSaves = 0
override suspend fun initialize(): Boolean
{
PGHLog.d(TAG, "Initializing in-memory storage")
return true
}
override suspend fun saveDetectionResult(result: DetectionResult): Boolean = mutex.withLock {
try
{
totalSaveAttempts++
// Enforce max results limit (FIFO - remove oldest)
if (storage.size >= MAX_RESULTS)
{
val oldestResult = storage.values.minByOrNull { it.timestamp }
oldestResult?.let { storage.remove(it.id) }
PGHLog.d(TAG, "Removed oldest result to maintain limit of $MAX_RESULTS")
}
storage[result.id] = result
successfulSaves++
// Update reactive flow
updateRecentResultsFlow()
PGHLog.d(TAG, "Saved detection result: ${result.id}")
true
}
catch (e: Exception)
{
PGHLog.e(TAG, "Failed to save detection result", e)
false
}
}
override suspend fun getDetectionResults(
filter: DetectionFilter?,
sortBy: DetectionSortBy
): List<DetectionResult>
{
return storage.values
.let { results -> applyFilter(results, filter) }
.let { results -> applySorting(results, sortBy) }
.let { results -> filter?.limit?.let { results.take(it) } ?: results }
}
override suspend fun getDetectionResult(id: String): DetectionResult?
{
return storage[id]
}
override suspend fun deleteDetectionResult(id: String): Boolean = mutex.withLock {
val removed = storage.remove(id) != null
if (removed)
{
updateRecentResultsFlow()
PGHLog.d(TAG, "Deleted detection result: $id")
}
removed
}
override suspend fun clearAllResults(): Int = mutex.withLock {
val count = storage.size
storage.clear()
updateRecentResultsFlow()
PGHLog.d(TAG, "Cleared all $count detection results")
count
}
override suspend fun getResultCount(filter: DetectionFilter?): Int
{
return storage.values
.let { results -> applyFilter(results, filter) }
.size
}
override fun getRecentResultsFlow(limit: Int): Flow<List<DetectionResult>>
{
return _recentResultsFlow.asStateFlow().map { results ->
results.take(limit)
}
}
override suspend fun getStorageStats(): StorageStats
{
val results = storage.values.toList()
val successful = results.count { it.success }
val failed = results.count { !it.success }
val avgProcessingTime = if (results.isNotEmpty())
{
results.map { it.processingTimeMs }.average().toLong()
}
else 0L
val oldest = results.minByOrNull { it.timestamp }?.timestamp?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val newest = results.maxByOrNull { it.timestamp }?.timestamp?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
// Rough memory usage calculation (approximation)
val memoryUsage = calculateApproximateMemoryUsage(results)
return StorageStats(
totalResults = results.size,
successfulResults = successful,
failedResults = failed,
averageProcessingTimeMs = avgProcessingTime,
oldestResultTimestamp = oldest,
newestResultTimestamp = newest,
storageType = "In-Memory",
memoryUsageBytes = memoryUsage
)
}
override suspend fun cleanup()
{
mutex.withLock {
storage.clear()
_recentResultsFlow.value = emptyList()
}
PGHLog.d(TAG, "Cleaned up in-memory storage")
}
/**
* Update the reactive flow with current results sorted by timestamp (newest first)
*/
private fun updateRecentResultsFlow()
{
val recent = storage.values
.sortedByDescending { it.timestamp }
.take(50) // Only emit recent results to avoid large updates
_recentResultsFlow.value = recent
}
/**
* Apply filtering criteria to results
*/
private fun applyFilter(results: Collection<DetectionResult>, filter: DetectionFilter?): List<DetectionResult>
{
if (filter == null) return results.toList()
return results.filter { result ->
// Success filter
if (filter.successOnly && !result.success) return@filter false
// Pokemon name filter
if (filter.pokemonName != null &&
result.pokemonInfo?.species?.contains(filter.pokemonName, ignoreCase = true) != true)
{
return@filter false
}
// Level range filter
val level = result.pokemonInfo?.level
if (filter.minLevel != null && (level == null || level < filter.minLevel)) return@filter false
if (filter.maxLevel != null && (level == null || level > filter.maxLevel)) return@filter false
// Shiny filter
if (filter.isShiny != null && result.pokemonInfo?.isShiny != filter.isShiny) return@filter false
// Alpha filter
if (filter.isAlpha != null && result.pokemonInfo?.isAlpha != filter.isAlpha) return@filter false
// Game source filter
if (filter.gameSource != null &&
result.pokemonInfo?.gameSource?.contains(filter.gameSource, ignoreCase = true) != true)
{
return@filter false
}
// Date range filter
if (filter.dateRange != null)
{
val (start, end) = filter.dateRange
if (result.timestamp.isBefore(start) || result.timestamp.isAfter(end))
{
return@filter false
}
}
true
}
}
/**
* Apply sorting to results
*/
private fun applySorting(results: List<DetectionResult>, sortBy: DetectionSortBy): List<DetectionResult>
{
return when (sortBy)
{
DetectionSortBy.TIMESTAMP_DESC -> results.sortedByDescending { it.timestamp }
DetectionSortBy.TIMESTAMP_ASC -> results.sortedBy { it.timestamp }
DetectionSortBy.LEVEL_DESC -> results.sortedByDescending { it.pokemonInfo?.level ?: -1 }
DetectionSortBy.LEVEL_ASC -> results.sortedBy { it.pokemonInfo?.level ?: Int.MAX_VALUE }
DetectionSortBy.CONFIDENCE_DESC -> results.sortedByDescending { it.pokemonInfo?.extractionConfidence ?: -1.0 }
DetectionSortBy.CONFIDENCE_ASC -> results.sortedBy { it.pokemonInfo?.extractionConfidence ?: Double.MAX_VALUE }
DetectionSortBy.NAME_ASC -> results.sortedBy { it.pokemonInfo?.species ?: "zzz" }
DetectionSortBy.NAME_DESC -> results.sortedByDescending { it.pokemonInfo?.species ?: "" }
}
}
/**
* Calculate approximate memory usage of stored results
*/
private fun calculateApproximateMemoryUsage(results: List<DetectionResult>): Long
{
// Rough estimate: each result ~500 bytes average
// This includes object overhead, strings, timestamps, etc.
return results.size * 500L
}
}

112
app/src/main/java/com/quillstudios/pokegoalshelper/storage/StorageInterface.kt

@ -0,0 +1,112 @@
package com.quillstudios.pokegoalshelper.storage
import com.quillstudios.pokegoalshelper.models.DetectionResult
import com.quillstudios.pokegoalshelper.models.DetectionFilter
import com.quillstudios.pokegoalshelper.models.DetectionSortBy
import kotlinx.coroutines.flow.Flow
/**
* Abstraction layer for Pokemon detection result storage.
*
* This interface allows for different storage implementations (in-memory, database, etc.)
* while maintaining a consistent API for the rest of the application.
*
* All operations are suspend functions to support async storage backends like Room database.
*/
interface StorageInterface
{
/**
* Save a detection result to storage.
*
* @param result The detection result to store
* @return Success indicator - true if saved successfully
*/
suspend fun saveDetectionResult(result: DetectionResult): Boolean
/**
* Retrieve all detection results with optional filtering and sorting.
*
* @param filter Optional filter criteria to apply
* @param sortBy Sort order for results (default: newest first)
* @return List of detection results matching criteria
*/
suspend fun getDetectionResults(
filter: DetectionFilter? = null,
sortBy: DetectionSortBy = DetectionSortBy.TIMESTAMP_DESC
): List<DetectionResult>
/**
* Get a specific detection result by ID.
*
* @param id Unique identifier of the detection result
* @return The detection result if found, null otherwise
*/
suspend fun getDetectionResult(id: String): DetectionResult?
/**
* Delete a specific detection result.
*
* @param id Unique identifier of the detection result to delete
* @return True if deleted successfully, false if not found
*/
suspend fun deleteDetectionResult(id: String): Boolean
/**
* Delete all detection results (clear history).
*
* @return Number of results deleted
*/
suspend fun clearAllResults(): Int
/**
* Get count of stored detection results with optional filtering.
*
* @param filter Optional filter criteria to apply
* @return Count of results matching criteria
*/
suspend fun getResultCount(filter: DetectionFilter? = null): Int
/**
* Get recent detection results as a Flow for reactive UI updates.
* This is useful for live updating the history UI when new detections are added.
*
* @param limit Maximum number of recent results to emit (default: 50)
* @return Flow of recent detection results, updated when new results are added
*/
fun getRecentResultsFlow(limit: Int = 50): Flow<List<DetectionResult>>
/**
* Get storage statistics for debugging and monitoring.
*
* @return StorageStats containing usage information
*/
suspend fun getStorageStats(): StorageStats
/**
* Initialize the storage system.
* This may involve creating database tables, setting up connections, etc.
*
* @return True if initialization successful
*/
suspend fun initialize(): Boolean
/**
* Clean up storage resources.
* Should be called when the storage is no longer needed.
*/
suspend fun cleanup()
}
/**
* Storage statistics for monitoring and debugging.
*/
data class StorageStats(
val totalResults: Int,
val successfulResults: Int,
val failedResults: Int,
val averageProcessingTimeMs: Long,
val oldestResultTimestamp: String?,
val newestResultTimestamp: String?,
val storageType: String,
val memoryUsageBytes: Long? = null
)

149
app/src/main/java/com/quillstudios/pokegoalshelper/ui/DetectionResultHandler.kt

@ -0,0 +1,149 @@
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.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.
* Uses the actual PokemonInfo directly without conversion.
*/
fun handleSuccessfulDetection(
detections: List<Detection>,
pokemonInfo: com.quillstudios.pokegoalshelper.PokemonInfo?,
processingTimeMs: Long
)
{
coroutineScope.launch {
try
{
val result = DetectionResult(
timestamp = LocalDateTime.now(),
detections = detections,
pokemonInfo = pokemonInfo,
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<Detection>,
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<Detection>,
processingTimeMs: Long
)
{
handleFailedDetection(detections, "No Pokemon detected in current view", processingTimeMs)
}
/**
* Hide the bottom drawer if currently showing.
*/
fun hideDrawer()
{
bottomDrawer.hide()
}
/**
* Clean up resources.
*/
fun cleanup()
{
bottomDrawer.hide()
}
}

52
app/src/main/java/com/quillstudios/pokegoalshelper/ui/EnhancedFloatingFAB.kt

@ -32,7 +32,8 @@ class EnhancedFloatingFAB(
private val context: Context, private val context: Context,
private val onDetectionRequested: () -> Unit, private val onDetectionRequested: () -> Unit,
private val onToggleOverlay: () -> Unit, private val onToggleOverlay: () -> Unit,
private val onReturnToApp: () -> Unit private val onReturnToApp: () -> Unit,
private val onShowLastResult: () -> Unit
) { ) {
companion object { companion object {
private const val FAB_SIZE_DP = 56 private const val FAB_SIZE_DP = 56
@ -52,6 +53,8 @@ class EnhancedFloatingFAB(
private var isShowing = false private var isShowing = false
private var isMenuExpanded = false private var isMenuExpanded = false
private var isDragging = false private var isDragging = false
private var isDetecting = false
private var spinAnimator: ObjectAnimator? = null
// Animation and positioning // Animation and positioning
private var currentX = 0 private var currentX = 0
@ -217,6 +220,9 @@ class EnhancedFloatingFAB(
MenuItemData("DETECT", android.R.drawable.ic_menu_search, android.R.color.holo_blue_dark) { MenuItemData("DETECT", android.R.drawable.ic_menu_search, android.R.color.holo_blue_dark) {
onDetectionRequested() onDetectionRequested()
}, },
MenuItemData("RESULTS", android.R.drawable.ic_menu_info_details, android.R.color.holo_purple) {
onShowLastResult()
},
MenuItemData("OVERLAY", android.R.drawable.ic_menu_view, android.R.color.holo_green_dark) { MenuItemData("OVERLAY", android.R.drawable.ic_menu_view, android.R.color.holo_green_dark) {
onToggleOverlay() onToggleOverlay()
}, },
@ -561,6 +567,50 @@ class EnhancedFloatingFAB(
).toInt() ).toInt()
} }
/**
* Start spinning animation to indicate detection is in progress
*/
fun startDetectionAnimation() {
if (isDetecting) return // Already spinning
isDetecting = true
mainFAB?.let { fab ->
spinAnimator = ObjectAnimator.ofFloat(fab, "rotation", 0f, 360f).apply {
duration = 1000L // 1 second per rotation
repeatCount = ObjectAnimator.INFINITE
repeatMode = ObjectAnimator.RESTART
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
}
/**
* Stop spinning animation when detection completes
*/
fun stopDetectionAnimation() {
if (!isDetecting) return // Not spinning
isDetecting = false
spinAnimator?.let { animator ->
animator.cancel()
// Smoothly return to 0 rotation
mainFAB?.let { fab ->
ObjectAnimator.ofFloat(fab, "rotation", fab.rotation, 0f).apply {
duration = 200L
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
}
spinAnimator = null
}
/**
* Check if detection animation is currently running
*/
fun isDetectionAnimationRunning(): Boolean = isDetecting
private data class MenuItemData( private data class MenuItemData(
val label: String, val label: String,
val iconRes: Int, val iconRes: Int,

786
app/src/main/java/com/quillstudios/pokegoalshelper/ui/HistoryAdapter.kt

@ -0,0 +1,786 @@
package com.quillstudios.pokegoalshelper.ui
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.drawable.GradientDrawable
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.quillstudios.pokegoalshelper.models.DetectionResult
import com.quillstudios.pokegoalshelper.utils.PGHLog
import java.time.format.DateTimeFormatter
/**
* RecyclerView adapter for Pokemon detection history with expandable cards.
*
* Features:
* - Expandable/collapsible cards
* - Compact and detailed views
* - Delete functionality
* - Smooth animations
* - Performance optimized for large lists
*/
class HistoryAdapter(
private val onItemClick: (DetectionResult, Int) -> Unit,
private val onDeleteClick: (DetectionResult, Int) -> Unit
) : RecyclerView.Adapter<HistoryAdapter.HistoryViewHolder>()
{
companion object
{
private const val TAG = "HistoryAdapter"
}
data class HistoryItem(
val result: DetectionResult,
val isExpanded: Boolean = false
)
private val items = mutableListOf<HistoryItem>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryViewHolder
{
val cardView = createCardView(parent.context)
return HistoryViewHolder(cardView)
}
override fun onBindViewHolder(holder: HistoryViewHolder, position: Int)
{
val item = items[position]
holder.bind(item, position)
}
override fun getItemCount(): Int = items.size
fun updateResults(results: List<DetectionResult>)
{
items.clear()
items.addAll(results.map { HistoryItem(it, false) })
notifyDataSetChanged()
PGHLog.d(TAG, "Updated adapter with ${results.size} items")
}
fun removeItem(position: Int)
{
if (position in 0 until items.size) {
items.removeAt(position)
notifyItemRemoved(position)
PGHLog.d(TAG, "Removed item at position $position")
}
}
fun toggleExpansion(position: Int)
{
if (position in 0 until items.size) {
val item = items[position]
items[position] = item.copy(isExpanded = !item.isExpanded)
notifyItemChanged(position)
PGHLog.d(TAG, "Toggled expansion for position $position: ${items[position].isExpanded}")
}
}
private fun createCardView(context: Context): View
{
// Create main card container
val cardContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
background = createCardBackground(context)
setPadding(dpToPx(context, 16), dpToPx(context, 12), dpToPx(context, 16), dpToPx(context, 12))
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT
).apply {
setMargins(dpToPx(context, 8), dpToPx(context, 4), dpToPx(context, 8), dpToPx(context, 4))
}
}
// Collapsed content (always visible)
val collapsedContent = createCollapsedContent(context)
collapsedContent.tag = "collapsed_content"
// Expanded content (initially hidden)
val expandedContent = createExpandedContent(context)
expandedContent.tag = "expanded_content"
expandedContent.visibility = View.GONE
cardContainer.addView(collapsedContent)
cardContainer.addView(expandedContent)
return cardContainer
}
private fun createCollapsedContent(context: Context): View
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
// Status icon
val statusIcon = ImageView(context).apply {
tag = "status_icon"
layoutParams = LinearLayout.LayoutParams(
dpToPx(context, 24),
dpToPx(context, 24)
).apply {
setMargins(0, 0, dpToPx(context, 12), 0)
}
}
// Main info container
val infoContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Pokemon name/species
val titleText = TextView(context).apply {
tag = "title_text"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
// Timestamp and confidence
val subtitleText = TextView(context).apply {
tag = "subtitle_text"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
}
addView(titleText)
addView(subtitleText)
}
// Expand chevron
val chevronIcon = TextView(context).apply {
tag = "chevron_icon"
text = ""
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(dpToPx(context, 8), 0, 0, 0)
}
}
addView(statusIcon)
addView(infoContainer)
addView(chevronIcon)
}
}
private fun createExpandedContent(context: Context): View
{
// Create placeholder that will be replaced when content is needed
return android.widget.FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
tag = "expanded_content"
visibility = View.GONE
}
}
private fun createPopulatedExpandedContent(context: Context, result: DetectionResult): View
{
return object : androidx.core.widget.NestedScrollView(context) {
private var initialTouchY = 0f
private var touchSlop = android.view.ViewConfiguration.get(context).scaledTouchSlop
override fun onInterceptTouchEvent(ev: android.view.MotionEvent): Boolean {
when (ev.action) {
android.view.MotionEvent.ACTION_DOWN -> {
initialTouchY = ev.y
// Always request to handle touch events initially
parent?.requestDisallowInterceptTouchEvent(true)
}
android.view.MotionEvent.ACTION_MOVE -> {
val deltaY = kotlin.math.abs(ev.y - initialTouchY)
if (deltaY > touchSlop) {
// This is a scroll gesture - keep intercepting
parent?.requestDisallowInterceptTouchEvent(true)
}
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
// Allow parent to handle other gestures
parent?.requestDisallowInterceptTouchEvent(false)
}
}
return super.onInterceptTouchEvent(ev)
}
override fun onTouchEvent(ev: android.view.MotionEvent): Boolean {
// Maintain control during active scrolling
when (ev.action) {
android.view.MotionEvent.ACTION_DOWN, android.view.MotionEvent.ACTION_MOVE -> {
parent?.requestDisallowInterceptTouchEvent(true)
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
}
}
return super.onTouchEvent(ev)
}
}.apply {
// Set a reasonable fixed height for the scrollable area
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dpToPx(context, 300) // Fixed height to ensure scrolling works
)
// Configure scrolling behavior optimized for nested scrolling
isFillViewport = false
isNestedScrollingEnabled = true
// Remove scrollbar config that causes crash - scrollbars not essential for functionality
tag = "expanded_content"
val contentContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
setPadding(0, dpToPx(context, 8), 0, dpToPx(context, 16))
tag = "expanded_container"
}
// Populate content immediately - this is the key fix!
if (result.success && result.pokemonInfo != null) {
populatePokemonInfoViewsForContainer(result.pokemonInfo, contentContainer, context)
} else {
populateErrorInfoViewsForContainer(result, contentContainer, context)
}
// Technical info
populateTechnicalInfoViewsForContainer(result, contentContainer, context)
addView(contentContainer)
}
}
private fun createCardBackground(context: Context): GradientDrawable
{
return GradientDrawable().apply {
setColor(ContextCompat.getColor(context, android.R.color.black))
alpha = (0.8f * 255).toInt()
cornerRadius = dpToPx(context, 8).toFloat()
setStroke(1, ContextCompat.getColor(context, android.R.color.darker_gray))
}
}
private fun dpToPx(context: Context, dp: Int): Int
{
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
context.resources.displayMetrics
).toInt()
}
private fun populatePokemonInfoViewsForContainer(pokemonInfo: com.quillstudios.pokegoalshelper.PokemonInfo, container: LinearLayout, context: Context)
{
// Basic Pokemon Info Section
container.addView(createSectionHeaderForContainer("Pokemon Info", context))
container.addView(createTwoColumnRowForContainer(
leftLabel = "Species", leftValue = pokemonInfo.species ?: "Unknown",
rightLabel = "Dex #", rightValue = pokemonInfo.nationalDexNumber?.let { "#$it" } ?: "N/A",
context = context
))
container.addView(createTwoColumnRowForContainer(
leftLabel = "Nickname", leftValue = pokemonInfo.nickname ?: "None",
rightLabel = "Gender", rightValue = pokemonInfo.gender ?: "Unknown",
context = context
))
container.addView(createTwoColumnRowForContainer(
leftLabel = "Level", leftValue = pokemonInfo.level?.toString() ?: "N/A",
rightLabel = "Nature", rightValue = pokemonInfo.nature ?: "Unknown",
context = context
))
// Types Section
container.addView(createSectionHeaderForContainer("Types", context))
val typeDisplay = when {
pokemonInfo.primaryType != null && pokemonInfo.secondaryType != null ->
"${pokemonInfo.primaryType} / ${pokemonInfo.secondaryType}"
pokemonInfo.primaryType != null -> pokemonInfo.primaryType
else -> "Unknown"
}
container.addView(createTwoColumnRowForContainer(
leftLabel = "Type", leftValue = typeDisplay,
rightLabel = "Tera", rightValue = pokemonInfo.teraType ?: "N/A",
context = context
))
// Stats Section (if available)
pokemonInfo.stats?.let { stats ->
container.addView(createSectionHeaderForContainer("Base Stats", context))
container.addView(createThreeColumnRowForContainer(
leftLabel = "HP", leftValue = stats.hp?.toString() ?: "?",
middleLabel = "ATK", middleValue = stats.attack?.toString() ?: "?",
rightLabel = "DEF", rightValue = stats.defense?.toString() ?: "?",
context = context
))
container.addView(createThreeColumnRowForContainer(
leftLabel = "SP.ATK", leftValue = stats.spAttack?.toString() ?: "?",
middleLabel = "SP.DEF", middleValue = stats.spDefense?.toString() ?: "?",
rightLabel = "SPEED", rightValue = stats.speed?.toString() ?: "?",
context = context
))
}
// Special Properties Section
container.addView(createSectionHeaderForContainer("Properties", context))
container.addView(createCheckboxRowForContainer(
leftLabel = "Shiny", leftChecked = pokemonInfo.isShiny,
rightLabel = "Alpha", rightChecked = pokemonInfo.isAlpha,
context = context
))
container.addView(createMixedRowForContainer(
leftLabel = "Favorited", leftChecked = pokemonInfo.isFavorited,
rightLabel = "Pokeball", rightValue = pokemonInfo.pokeballType ?: "Unknown",
context = context
))
// Game Origin Section
container.addView(createSectionHeaderForContainer("Origin", context))
container.addView(createTwoColumnRowForContainer(
leftLabel = "Game", leftValue = pokemonInfo.gameSource ?: "Unknown",
rightLabel = "Language", rightValue = pokemonInfo.language ?: "Unknown",
context = context
))
pokemonInfo.originalTrainerName?.let { trainerName ->
container.addView(createTwoColumnRowForContainer(
leftLabel = "OT Name", leftValue = trainerName,
rightLabel = "OT ID", rightValue = pokemonInfo.originalTrainerId ?: "Unknown",
context = context
))
}
// Ability & Moves
pokemonInfo.ability?.let { ability ->
container.addView(createSectionHeaderForContainer("Ability & Moves", context))
container.addView(createInfoRowForContainer("Ability", ability, context))
}
if (pokemonInfo.moves.isNotEmpty()) {
if (pokemonInfo.ability == null) {
container.addView(createSectionHeaderForContainer("Moves", context))
}
container.addView(createInfoRowForContainer("Moves", pokemonInfo.moves.take(4).joinToString(", "), context))
}
// Additional Data
if (pokemonInfo.stamps.isNotEmpty() || pokemonInfo.labels.isNotEmpty() || pokemonInfo.marks.isNotEmpty()) {
container.addView(createSectionHeaderForContainer("Additional", context))
if (pokemonInfo.stamps.isNotEmpty()) {
container.addView(createInfoRowForContainer("Stamps", pokemonInfo.stamps.joinToString(", "), context))
}
if (pokemonInfo.labels.isNotEmpty()) {
container.addView(createInfoRowForContainer("Labels", pokemonInfo.labels.joinToString(", "), context))
}
if (pokemonInfo.marks.isNotEmpty()) {
container.addView(createInfoRowForContainer("Marks", pokemonInfo.marks.joinToString(", "), context))
}
}
}
private fun populateErrorInfoViewsForContainer(result: DetectionResult, container: LinearLayout, context: Context)
{
container.addView(createSectionHeaderForContainer("Error Details", context))
container.addView(createInfoRowForContainer("Status", if (result.success) "No Pokemon found" else "Detection failed", context))
result.errorMessage?.let { container.addView(createInfoRowForContainer("Error", it, context)) }
}
private fun populateTechnicalInfoViewsForContainer(result: DetectionResult, container: LinearLayout, context: Context)
{
container.addView(createSectionHeaderForContainer("Technical Info", context))
container.addView(createInfoRowForContainer("Processing Time", "${result.processingTimeMs}ms", context))
container.addView(createInfoRowForContainer("Detections Found", result.detections.size.toString(), context))
container.addView(createInfoRowForContainer("Timestamp", result.timestamp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), context))
}
// Helper methods for container population - using the methods from the inner class
private fun createSectionHeaderForContainer(title: String, context: Context): TextView
{
return TextView(context).apply {
text = title
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(ContextCompat.getColor(context, android.R.color.holo_blue_light))
typeface = android.graphics.Typeface.DEFAULT_BOLD
setPadding(0, dpToPx(context, 8), 0, dpToPx(context, 4))
}
}
private fun createInfoRowForContainer(label: String, value: String, context: Context): LinearLayout
{
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dpToPx(context, 2), 0, dpToPx(context, 2))
}
val labelView = TextView(context).apply {
text = "$label:"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
layoutParams = LinearLayout.LayoutParams(
dpToPx(context, 100),
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
val valueView = TextView(context).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
}
row.addView(labelView)
row.addView(valueView)
return row
}
private fun createTwoColumnRowForContainer(leftLabel: String, leftValue: String, rightLabel: String, rightValue: String, context: Context): LinearLayout
{
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dpToPx(context, 2), 0, dpToPx(context, 2))
}
// Left column
val leftColumn = createColumnItemForContainer(leftLabel, leftValue, context)
leftColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right column
val rightColumn = createColumnItemForContainer(rightLabel, rightValue, context)
rightColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(context, 8), 0, 0, 0)
}
row.addView(leftColumn)
row.addView(rightColumn)
return row
}
private fun createThreeColumnRowForContainer(leftLabel: String, leftValue: String, middleLabel: String, middleValue: String, rightLabel: String, rightValue: String, context: Context): LinearLayout
{
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dpToPx(context, 2), 0, dpToPx(context, 2))
}
// Left column
val leftColumn = createColumnItemForContainer(leftLabel, leftValue, context)
leftColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Middle column
val middleColumn = createColumnItemForContainer(middleLabel, middleValue, context)
middleColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(context, 4), 0, dpToPx(context, 4), 0)
}
// Right column
val rightColumn = createColumnItemForContainer(rightLabel, rightValue, context)
rightColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
row.addView(leftColumn)
row.addView(middleColumn)
row.addView(rightColumn)
return row
}
private fun createCheckboxRowForContainer(leftLabel: String, leftChecked: Boolean, rightLabel: String, rightChecked: Boolean, context: Context): LinearLayout
{
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dpToPx(context, 2), 0, dpToPx(context, 2))
}
// Left checkbox
val leftCheckbox = createCheckboxItemForContainer(leftLabel, leftChecked, context)
leftCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right checkbox
val rightCheckbox = createCheckboxItemForContainer(rightLabel, rightChecked, context)
rightCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(context, 8), 0, 0, 0)
}
row.addView(leftCheckbox)
row.addView(rightCheckbox)
return row
}
private fun createMixedRowForContainer(leftLabel: String, leftChecked: Boolean, rightLabel: String, rightValue: String, context: Context): LinearLayout
{
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dpToPx(context, 2), 0, dpToPx(context, 2))
}
// Left checkbox
val leftCheckbox = createCheckboxItemForContainer(leftLabel, leftChecked, context)
leftCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right text item
val rightItem = createColumnItemForContainer(rightLabel, rightValue, context)
rightItem.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(context, 8), 0, 0, 0)
}
row.addView(leftCheckbox)
row.addView(rightItem)
return row
}
private fun createColumnItemForContainer(label: String, value: String, context: Context): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = android.view.Gravity.START
val labelView = TextView(context).apply {
text = "$label:"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 9f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
}
val valueView = TextView(context).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
addView(labelView)
addView(valueView)
}
}
private fun createCheckboxItemForContainer(label: String, checked: Boolean, context: Context): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
// Checkbox symbol
val checkboxView = TextView(context).apply {
text = if (checked) "" else ""
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(
if (checked) ContextCompat.getColor(context, android.R.color.holo_green_light)
else ContextCompat.getColor(context, android.R.color.darker_gray)
)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, dpToPx(context, 6), 0)
}
}
// Label
val labelView = TextView(context).apply {
text = label
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
}
addView(checkboxView)
addView(labelView)
}
}
inner class HistoryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
{
private val cardContainer = itemView as LinearLayout
private val collapsedContent = itemView.findViewWithTag<LinearLayout>("collapsed_content")
private var expandedContent: View? = null // Will be created when needed
// Collapsed content views
private val statusIcon = collapsedContent.findViewWithTag<ImageView>("status_icon")
private val titleText = collapsedContent.findViewWithTag<TextView>("title_text")
private val subtitleText = collapsedContent.findViewWithTag<TextView>("subtitle_text")
private val chevronIcon = collapsedContent.findViewWithTag<TextView>("chevron_icon")
fun bind(item: HistoryItem, position: Int)
{
val result = item.result
val context = itemView.context
// Update collapsed content
updateCollapsedContent(result, context)
// Handle expanded content
if (item.isExpanded) {
ensureExpandedContent(result, context)
showExpandedContent()
} else {
hideExpandedContent()
}
// Set click listeners
collapsedContent.setOnClickListener {
onItemClick(result, position)
}
}
private fun ensureExpandedContent(result: DetectionResult, context: Context)
{
// Remove existing expanded content if any
expandedContent?.let { existing ->
if (existing.parent == cardContainer) {
cardContainer.removeView(existing)
}
}
// Create new expanded content with actual data
expandedContent = createPopulatedExpandedContent(context, result).apply {
// Add delete button to the content
val scrollView = this as androidx.core.widget.NestedScrollView
val container = scrollView.findViewWithTag<LinearLayout>("expanded_container")
container?.let {
addDeleteButton(result, adapterPosition, context, it)
}
}
// Add to card container
expandedContent?.let { cardContainer.addView(it) }
}
private fun updateCollapsedContent(result: DetectionResult, context: Context)
{
// Status icon
statusIcon.setImageResource(
if (result.success) android.R.drawable.ic_menu_myplaces
else android.R.drawable.ic_dialog_alert
)
statusIcon.setColorFilter(
ContextCompat.getColor(
context,
if (result.success) android.R.color.holo_green_light
else android.R.color.holo_red_light
)
)
// Title (Pokemon name or status)
titleText.text = when {
result.success && result.pokemonInfo?.species != null -> {
result.pokemonInfo.species +
(result.pokemonInfo.nationalDexNumber?.let { " (#$it)" } ?: "")
}
result.success -> "Pokemon Detected"
else -> "Detection Failed"
}
// Subtitle (timestamp and processing time)
val formatter = DateTimeFormatter.ofPattern("MMM dd, HH:mm")
subtitleText.text = "${result.timestamp.format(formatter)}${result.processingTimeMs}ms"
// Chevron rotation based on expansion state
val rotation = if (expandedContent?.visibility == View.VISIBLE) 180f else 0f
chevronIcon.rotation = rotation
}
private fun showExpandedContent()
{
expandedContent?.visibility = View.VISIBLE
// Animate chevron rotation
ObjectAnimator.ofFloat(chevronIcon, "rotation", 0f, 180f).apply {
duration = 200L
start()
}
}
private fun hideExpandedContent()
{
expandedContent?.visibility = View.GONE
// Animate chevron rotation
ObjectAnimator.ofFloat(chevronIcon, "rotation", 180f, 0f).apply {
duration = 200L
start()
}
}
private fun addDeleteButton(result: DetectionResult, position: Int, context: Context, container: LinearLayout)
{
val deleteButton = Button(context).apply {
text = "Delete"
setTextColor(ContextCompat.getColor(context, android.R.color.white))
background = GradientDrawable().apply {
setColor(ContextCompat.getColor(context, android.R.color.holo_red_light))
cornerRadius = dpToPx(context, 4).toFloat()
}
setPadding(dpToPx(context, 16), dpToPx(context, 8), dpToPx(context, 16), dpToPx(context, 8))
setOnClickListener {
onDeleteClick(result, position)
}
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(context, 8), 0, 0)
}
}
container.addView(deleteButton)
}
}
}

193
app/src/main/java/com/quillstudios/pokegoalshelper/ui/HistoryFragment.kt

@ -0,0 +1,193 @@
package com.quillstudios.pokegoalshelper.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.quillstudios.pokegoalshelper.models.DetectionResult
import com.quillstudios.pokegoalshelper.storage.StorageInterface
import com.quillstudios.pokegoalshelper.di.ServiceLocator
import com.quillstudios.pokegoalshelper.utils.PGHLog
import kotlinx.coroutines.launch
/**
* History Fragment displaying scrollable list of Pokemon detection results.
*
* Features:
* - RecyclerView with expandable cards
* - Empty state handling
* - Delete and refresh functionality
* - Performance optimized for large lists
*/
class HistoryFragment : Fragment()
{
companion object
{
private const val TAG = "HistoryFragment"
}
private lateinit var recyclerView: RecyclerView
private lateinit var historyAdapter: HistoryAdapter
private lateinit var emptyStateView: View
private val storage: StorageInterface by lazy { ServiceLocator.getStorageServiceSync() }
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View?
{
// Create the root view programmatically since we don't have XML layouts
return createHistoryView()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?)
{
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
loadHistory()
}
private fun createHistoryView(): View
{
val context = requireContext()
// Create main container
val container = androidx.constraintlayout.widget.ConstraintLayout(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
// Create RecyclerView
recyclerView = RecyclerView(context).apply {
id = View.generateViewId()
layoutParams = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams(
0, 0
).apply {
topToTop = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
bottomToBottom = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
startToStart = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
endToEnd = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
}
}
// Create empty state view
emptyStateView = android.widget.TextView(context).apply {
id = View.generateViewId()
text = "No Pokemon detections yet\\n\\nStart analyzing Pokemon Home screens to see results here!"
textAlignment = View.TEXT_ALIGNMENT_CENTER
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 16f)
setPadding(32, 32, 32, 32)
visibility = View.GONE
layoutParams = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams(
androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.WRAP_CONTENT,
androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.WRAP_CONTENT
).apply {
topToTop = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
bottomToBottom = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
startToStart = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
endToEnd = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID
}
}
container.addView(recyclerView)
container.addView(emptyStateView)
return container
}
private fun setupRecyclerView()
{
historyAdapter = HistoryAdapter(
onItemClick = { result, position -> toggleItemExpansion(result, position) },
onDeleteClick = { result, position -> deleteResult(result, position) }
)
recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = historyAdapter
// Add item decoration for spacing
addItemDecoration(androidx.recyclerview.widget.DividerItemDecoration(
requireContext(),
androidx.recyclerview.widget.DividerItemDecoration.VERTICAL
))
}
PGHLog.d(TAG, "RecyclerView setup complete")
}
private fun loadHistory()
{
lifecycleScope.launch {
try {
PGHLog.d(TAG, "Loading detection history...")
val results = storage.getDetectionResults()
if (results.isEmpty()) {
showEmptyState(true)
PGHLog.d(TAG, "No results found - showing empty state")
} else {
showEmptyState(false)
historyAdapter.updateResults(results)
PGHLog.d(TAG, "Loaded ${results.size} detection results")
}
} catch (e: Exception) {
PGHLog.e(TAG, "Error loading history", e)
showEmptyState(true)
}
}
}
fun refreshHistory()
{
PGHLog.d(TAG, "Refreshing history...")
loadHistory()
}
private fun deleteResult(result: DetectionResult, position: Int)
{
lifecycleScope.launch {
try {
val success = storage.deleteDetectionResult(result.id)
if (success) {
historyAdapter.removeItem(position)
PGHLog.d(TAG, "Deleted result: ${result.id}")
// Check if list is now empty
if (historyAdapter.itemCount == 0) {
showEmptyState(true)
}
} else {
PGHLog.w(TAG, "Failed to delete result: ${result.id}")
}
} catch (e: Exception) {
PGHLog.e(TAG, "Error deleting result", e)
}
}
}
private fun toggleItemExpansion(result: DetectionResult, position: Int)
{
historyAdapter.toggleExpansion(position)
PGHLog.d(TAG, "Toggled expansion for item at position $position")
}
private fun showEmptyState(show: Boolean)
{
if (show) {
recyclerView.visibility = View.GONE
emptyStateView.visibility = View.VISIBLE
} else {
recyclerView.visibility = View.VISIBLE
emptyStateView.visibility = View.GONE
}
}
}

981
app/src/main/java/com/quillstudios/pokegoalshelper/ui/ResultsBottomDrawer.kt

@ -0,0 +1,981 @@
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_COLLAPSED_DP = 80
private const val DRAWER_HEIGHT_EXPANDED_DP = 400 // Increased to show all data
private const val SLIDE_ANIMATION_DURATION = 300L
private const val SWIPE_THRESHOLD = 100f
private const val EXPAND_THRESHOLD = -50f // Negative because we're pulling up
}
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 isExpanded = false
private var currentDetectionResult: DetectionResult? = null
// Touch handling
private var initialTouchY = 0f
private var initialTranslationY = 0f
fun show(result: DetectionResult)
{
try
{
if (isShowing)
{
// Update existing drawer with new content
updateDrawerContent(result)
PGHLog.d(TAG, "Bottom drawer updated with new detection: ${result.id}")
return
}
// Show new drawer
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
currentDetectionResult = result
createDrawerView(result)
isShowing = true
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
{
// 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_COLLAPSED_DP) // Start collapsed
// 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 collapsed content (always visible)
addView(createCollapsedContent(result))
// Add expanded content (initially hidden)
addView(createExpandedContent(result))
// Set up touch handling for swipe and expand
setOnTouchListener(createExpandableSwipeTouchListener())
}
// 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 createCollapsedContent(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(20),
dpToPx(20)
).apply {
setMargins(0, 0, dpToPx(8), 0)
}
}
// Main content (compact)
val mainContent = createCompactDataRow(result)
// 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(24),
dpToPx(24)
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
}
addView(statusIcon)
addView(mainContent)
addView(dismissButton)
}
}
private fun createCompactDataRow(result: DetectionResult): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
if (result.success && result.pokemonInfo != null)
{
val pokemonInfo = result.pokemonInfo
val dataPoints = mutableListOf<String>()
// Collect all available data points from actual PokemonInfo structure
pokemonInfo.species?.let { dataPoints.add(it) }
pokemonInfo.nationalDexNumber?.let { dataPoints.add("#$it") }
pokemonInfo.level?.let { dataPoints.add("Lv$it") }
pokemonInfo.gender?.let { dataPoints.add(it) }
if (pokemonInfo.isShiny) dataPoints.add("")
if (pokemonInfo.isAlpha) dataPoints.add("🅰")
// Add processing time
dataPoints.add("${result.processingTimeMs}ms")
// Create compact display
val compactText = if (dataPoints.isNotEmpty()) {
dataPoints.joinToString("")
} else {
"Pokemon detected"
}
val textView = TextView(context).apply {
text = compactText
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
maxLines = 1
setSingleLine(true)
}
addView(textView)
}
else
{
val textView = TextView(context).apply {
text = "${if (result.success) "No Pokemon" else "Failed"}${result.processingTimeMs}ms"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
}
addView(textView)
}
}
}
private fun createExpandedContent(result: DetectionResult): ScrollView
{
return ScrollView(context).apply {
visibility = View.GONE // Initially hidden
tag = "expanded_content" // For easy finding
// Create the scrollable content container
val contentContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dpToPx(8), 0, dpToPx(16)) // Add bottom padding for scroll
}
if (result.success && result.pokemonInfo != null)
{
val pokemonInfo = result.pokemonInfo
// Basic Pokemon Info Section
contentContainer.addView(createSectionHeader("Pokemon Info"))
contentContainer.addView(createTwoColumnRow(
leftLabel = "Species", leftValue = pokemonInfo.species ?: "Unknown",
rightLabel = "Dex #", rightValue = pokemonInfo.nationalDexNumber?.let { "#$it" } ?: "N/A"
))
contentContainer.addView(createTwoColumnRow(
leftLabel = "Nickname", leftValue = pokemonInfo.nickname ?: "None",
rightLabel = "Gender", rightValue = pokemonInfo.gender ?: "Unknown"
))
contentContainer.addView(createTwoColumnRow(
leftLabel = "Level", leftValue = pokemonInfo.level?.toString() ?: "N/A",
rightLabel = "Nature", rightValue = pokemonInfo.nature ?: "Unknown"
))
// Types Section
contentContainer.addView(createSectionHeader("Types"))
val typeDisplay = when {
pokemonInfo.primaryType != null && pokemonInfo.secondaryType != null ->
"${pokemonInfo.primaryType} / ${pokemonInfo.secondaryType}"
pokemonInfo.primaryType != null -> pokemonInfo.primaryType
else -> "Unknown"
}
contentContainer.addView(createTwoColumnRow(
leftLabel = "Type", leftValue = typeDisplay,
rightLabel = "Tera", rightValue = pokemonInfo.teraType ?: "N/A"
))
// Stats Section (if available)
pokemonInfo.stats?.let { stats ->
contentContainer.addView(createSectionHeader("Base Stats"))
contentContainer.addView(createThreeColumnRow(
leftLabel = "HP", leftValue = stats.hp?.toString() ?: "?",
middleLabel = "ATK", middleValue = stats.attack?.toString() ?: "?",
rightLabel = "DEF", rightValue = stats.defense?.toString() ?: "?"
))
contentContainer.addView(createThreeColumnRow(
leftLabel = "SP.ATK", leftValue = stats.spAttack?.toString() ?: "?",
middleLabel = "SP.DEF", middleValue = stats.spDefense?.toString() ?: "?",
rightLabel = "SPEED", rightValue = stats.speed?.toString() ?: "?"
))
}
// Special Properties Section
contentContainer.addView(createSectionHeader("Properties"))
contentContainer.addView(createCheckboxRow(
leftLabel = "Shiny", leftChecked = pokemonInfo.isShiny,
rightLabel = "Alpha", rightChecked = pokemonInfo.isAlpha
))
contentContainer.addView(createMixedRow(
leftLabel = "Favorited", leftChecked = pokemonInfo.isFavorited,
rightLabel = "Pokeball", rightValue = pokemonInfo.pokeballType ?: "Unknown"
))
// Game Origin Section
contentContainer.addView(createSectionHeader("Origin"))
contentContainer.addView(createTwoColumnRow(
leftLabel = "Game", leftValue = pokemonInfo.gameSource ?: "Unknown",
rightLabel = "Language", rightValue = pokemonInfo.language ?: "Unknown"
))
pokemonInfo.originalTrainerName?.let { trainerName ->
contentContainer.addView(createTwoColumnRow(
leftLabel = "OT Name", leftValue = trainerName,
rightLabel = "OT ID", rightValue = pokemonInfo.originalTrainerId ?: "Unknown"
))
}
// Ability & Moves
pokemonInfo.ability?.let { ability ->
contentContainer.addView(createSectionHeader("Ability & Moves"))
contentContainer.addView(createDetailRow("Ability", ability))
}
if (pokemonInfo.moves.isNotEmpty()) {
contentContainer.addView(createDetailRow("Moves", pokemonInfo.moves.take(4).joinToString(", ")))
}
// Additional Data
if (pokemonInfo.stamps.isNotEmpty() || pokemonInfo.labels.isNotEmpty() || pokemonInfo.marks.isNotEmpty()) {
contentContainer.addView(createSectionHeader("Additional"))
if (pokemonInfo.stamps.isNotEmpty()) {
contentContainer.addView(createDetailRow("Stamps", pokemonInfo.stamps.joinToString(", ")))
}
if (pokemonInfo.labels.isNotEmpty()) {
contentContainer.addView(createDetailRow("Labels", pokemonInfo.labels.joinToString(", ")))
}
if (pokemonInfo.marks.isNotEmpty()) {
contentContainer.addView(createDetailRow("Marks", pokemonInfo.marks.joinToString(", ")))
}
}
}
else
{
// Show error details
contentContainer.addView(createSectionHeader("Detection Failed"))
contentContainer.addView(createDetailRow("Status", if (result.success) "No Pokemon detected" else "Detection failed"))
result.errorMessage?.let { error ->
contentContainer.addView(createDetailRow("Error", error))
}
}
// Technical Info Section
contentContainer.addView(createSectionHeader("Technical Info"))
contentContainer.addView(createTwoColumnRow(
leftLabel = "Processing", leftValue = "${result.processingTimeMs}ms",
rightLabel = "Detected", rightValue = "${result.detections.size} items"
))
contentContainer.addView(createDetailRow("Timestamp", result.timestamp.format(DateTimeFormatter.ofPattern("HH:mm:ss"))))
// Add the content container to the ScrollView
addView(contentContainer)
}
}
private fun createDetailRow(label: String, value: String): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val labelView = TextView(context).apply {
text = "$label:"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
layoutParams = LinearLayout.LayoutParams(
dpToPx(80),
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
val valueView = TextView(context).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
typeface = android.graphics.Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
}
addView(labelView)
addView(valueView)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createSectionHeader(title: String): TextView
{
return TextView(context).apply {
text = title
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setTextColor(ContextCompat.getColor(context, android.R.color.holo_blue_light))
typeface = android.graphics.Typeface.DEFAULT_BOLD
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(8), 0, dpToPx(4))
}
}
}
private fun createTwoColumnRow(
leftLabel: String, leftValue: String,
rightLabel: String, rightValue: String
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left column
val leftColumn = createColumnItem(leftLabel, leftValue)
leftColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right column
val rightColumn = createColumnItem(rightLabel, rightValue)
rightColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
addView(leftColumn)
addView(rightColumn)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createThreeColumnRow(
leftLabel: String, leftValue: String,
middleLabel: String, middleValue: String,
rightLabel: String, rightValue: String
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left column
val leftColumn = createColumnItem(leftLabel, leftValue)
leftColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Middle column
val middleColumn = createColumnItem(middleLabel, middleValue)
middleColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(4), 0, dpToPx(4), 0)
}
// Right column
val rightColumn = createColumnItem(rightLabel, rightValue)
rightColumn.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
addView(leftColumn)
addView(middleColumn)
addView(rightColumn)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createColumnItem(label: String, value: String): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.START
val labelView = TextView(context).apply {
text = "$label:"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 9f)
setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray))
}
val valueView = TextView(context).apply {
text = value
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
addView(labelView)
addView(valueView)
}
}
private fun createCheckboxRow(
leftLabel: String, leftChecked: Boolean,
rightLabel: String, rightChecked: Boolean
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left checkbox
val leftCheckbox = createCheckboxItem(leftLabel, leftChecked)
leftCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right checkbox
val rightCheckbox = createCheckboxItem(rightLabel, rightChecked)
rightCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
addView(leftCheckbox)
addView(rightCheckbox)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
private fun createCheckboxItem(label: String, checked: Boolean): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Checkbox symbol
val checkboxView = TextView(context).apply {
text = if (checked) "" else ""
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(
if (checked) ContextCompat.getColor(context, android.R.color.holo_green_light)
else ContextCompat.getColor(context, android.R.color.darker_gray)
)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, dpToPx(6), 0)
}
}
// Label
val labelView = TextView(context).apply {
text = label
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
}
addView(checkboxView)
addView(labelView)
}
}
private fun createMixedRow(
leftLabel: String, leftChecked: Boolean,
rightLabel: String, rightValue: String
): LinearLayout
{
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
// Left checkbox
val leftCheckbox = createCheckboxItem(leftLabel, leftChecked)
leftCheckbox.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
// Right text item
val rightItem = createColumnItem(rightLabel, rightValue)
rightItem.layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
).apply {
setMargins(dpToPx(8), 0, 0, 0)
}
addView(leftCheckbox)
addView(rightItem)
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, dpToPx(2), 0, dpToPx(2))
}
}
}
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 createExpandableSwipeTouchListener(): View.OnTouchListener
{
return View.OnTouchListener { view, event ->
when (event.action)
{
MotionEvent.ACTION_DOWN ->
{
isDragging = false
initialTouchY = event.rawY
initialTranslationY = view.translationY
true
}
MotionEvent.ACTION_MOVE ->
{
val deltaY = event.rawY - initialTouchY
if (!isDragging && abs(deltaY) > 20)
{
isDragging = true
}
if (isDragging)
{
if (deltaY > 0)
{
// Downward drag - dismissing
view.translationY = initialTranslationY + deltaY
}
else if (deltaY < 0 && !isExpanded)
{
// Upward drag - expanding (only if not already expanded)
// Don't move the view, just track the gesture
}
}
true
}
MotionEvent.ACTION_UP ->
{
if (isDragging)
{
val deltaY = event.rawY - initialTouchY
if (deltaY > SWIPE_THRESHOLD)
{
if (isExpanded)
{
// Collapse to minimal state if expanded
collapseDrawer()
ObjectAnimator.ofFloat(view, "translationY", view.translationY, initialTranslationY).apply {
duration = 200L
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
else
{
// Fully dismiss if collapsed and swiped down far enough
hide()
}
}
else if (deltaY < EXPAND_THRESHOLD && !isExpanded)
{
// Expand if swiped up enough
expandDrawer()
}
else if (deltaY > -EXPAND_THRESHOLD && isExpanded)
{
// Collapse if swiped down a bit while expanded
collapseDrawer()
}
else
{
// Snap back to current state
ObjectAnimator.ofFloat(view, "translationY", view.translationY, initialTranslationY).apply {
duration = 200L
interpolator = AccelerateDecelerateInterpolator()
start()
}
}
}
else
{
// Simple tap - toggle expand/collapse
if (isExpanded)
{
collapseDrawer()
}
else
{
expandDrawer()
}
}
isDragging = false
true
}
else -> false
}
}
}
private fun expandDrawer()
{
if (isExpanded) return
isExpanded = true
// Show expanded content
drawerContainer?.findViewWithTag<ScrollView>("expanded_content")?.let { expandedContent ->
expandedContent.visibility = View.VISIBLE
expandedContent.alpha = 0f
ObjectAnimator.ofFloat(expandedContent, "alpha", 0f, 1f).apply {
duration = SLIDE_ANIMATION_DURATION
start()
}
}
// Resize drawer window
drawerParams?.let { params ->
params.height = dpToPx(DRAWER_HEIGHT_EXPANDED_DP)
drawerContainer?.let { container ->
windowManager?.updateViewLayout(container, params)
}
}
PGHLog.d(TAG, "Drawer expanded")
}
private fun collapseDrawer()
{
if (!isExpanded) return
isExpanded = false
// Hide expanded content
drawerContainer?.findViewWithTag<ScrollView>("expanded_content")?.let { expandedContent ->
ObjectAnimator.ofFloat(expandedContent, "alpha", 1f, 0f).apply {
duration = SLIDE_ANIMATION_DURATION
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
expandedContent.visibility = View.GONE
}
})
start()
}
}
// Resize drawer window
drawerParams?.let { params ->
params.height = dpToPx(DRAWER_HEIGHT_COLLAPSED_DP)
drawerContainer?.let { container ->
windowManager?.updateViewLayout(container, params)
}
}
PGHLog.d(TAG, "Drawer collapsed")
}
private fun animateIn()
{
drawerContainer?.let { container ->
val screenHeight = getScreenSize().second
container.translationY = dpToPx(DRAWER_HEIGHT_COLLAPSED_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 ->
val currentHeight = if (isExpanded) DRAWER_HEIGHT_EXPANDED_DP else DRAWER_HEIGHT_COLLAPSED_DP
ObjectAnimator.ofFloat(container, "translationY", 0f, dpToPx(currentHeight).toFloat()).apply {
duration = SLIDE_ANIMATION_DURATION
interpolator = AccelerateDecelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
onComplete()
}
})
start()
}
}
}
/**
* Update the existing drawer content with new detection results.
* This allows the drawer to refresh its data when already visible.
*/
private fun updateDrawerContent(result: DetectionResult)
{
try
{
currentDetectionResult = result
drawerContainer?.let { container ->
// Find the collapsed and expanded content views and update them
// The container structure is: drag handle (index 0), collapsed content (index 1), expanded content (index 2)
if (container.childCount >= 3)
{
// Remove old collapsed content and replace with new
container.removeViewAt(1) // Remove collapsed content
container.addView(createCollapsedContent(result), 1) // Add new collapsed content at index 1
// Remove old expanded content and replace with new
container.removeViewAt(2) // Remove expanded content (now at index 2 after previous removal)
container.addView(createExpandedContent(result), 2) // Add new expanded content at index 2
PGHLog.d(TAG, "Drawer content updated successfully")
}
else
{
PGHLog.w(TAG, "Unexpected drawer container structure, cannot update content")
}
}
}
catch (e: Exception)
{
PGHLog.e(TAG, "Error updating drawer content", e)
}
}
private fun cleanup()
{
drawerContainer = null
drawerParams = null
windowManager = null
currentDetectionResult = null
isShowing = false
isExpanded = false
}
private fun getScreenSize(): Pair<Int, Int>
{
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()
}
}

261
app/src/test/java/com/quillstudios/pokegoalshelper/storage/InMemoryStorageServiceTest.kt

@ -0,0 +1,261 @@
package com.quillstudios.pokegoalshelper.storage
import com.quillstudios.pokegoalshelper.models.*
import com.quillstudios.pokegoalshelper.ml.Detection
import com.quillstudios.pokegoalshelper.ml.BoundingBox
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.time.LocalDateTime
/**
* Unit tests for InMemoryStorageService.
*
* Tests core functionality including:
* - Basic CRUD operations
* - Filtering and sorting
* - Thread safety
* - Memory limits
* - Statistics calculation
*/
class InMemoryStorageServiceTest
{
private lateinit var storageService: InMemoryStorageService
@Before
fun setup()
{
storageService = InMemoryStorageService()
}
@After
fun cleanup() = runBlocking {
storageService.cleanup()
}
@Test
fun `initialize returns true`() = runBlocking {
assertTrue(storageService.initialize())
}
@Test
fun `save and retrieve detection result`() = runBlocking {
// Arrange
val result = createTestDetectionResult("pikachu", 1500)
// Act
val saved = storageService.saveDetectionResult(result)
val retrieved = storageService.getDetectionResult(result.id)
// Assert
assertTrue(saved)
assertNotNull(retrieved)
assertEquals(result.id, retrieved?.id)
assertEquals("pikachu", retrieved?.pokemonInfo?.name)
assertEquals(1500, retrieved?.pokemonInfo?.cp)
}
@Test
fun `get all results returns saved results`() = runBlocking {
// Arrange
val result1 = createTestDetectionResult("pikachu", 1500)
val result2 = createTestDetectionResult("charizard", 2000)
// Act
storageService.saveDetectionResult(result1)
storageService.saveDetectionResult(result2)
val allResults = storageService.getDetectionResults()
// Assert
assertEquals(2, allResults.size)
assertTrue(allResults.any { it.pokemonInfo?.name == "pikachu" })
assertTrue(allResults.any { it.pokemonInfo?.name == "charizard" })
}
@Test
fun `delete detection result works`() = runBlocking {
// Arrange
val result = createTestDetectionResult("pikachu", 1500)
storageService.saveDetectionResult(result)
// Act
val deleted = storageService.deleteDetectionResult(result.id)
val retrieved = storageService.getDetectionResult(result.id)
// Assert
assertTrue(deleted)
assertNull(retrieved)
}
@Test
fun `delete non-existent result returns false`() = runBlocking {
val deleted = storageService.deleteDetectionResult("non-existent-id")
assertFalse(deleted)
}
@Test
fun `clear all results works`() = runBlocking {
// Arrange
storageService.saveDetectionResult(createTestDetectionResult("pikachu", 1500))
storageService.saveDetectionResult(createTestDetectionResult("charizard", 2000))
// Act
val deletedCount = storageService.clearAllResults()
val remainingResults = storageService.getDetectionResults()
// Assert
assertEquals(2, deletedCount)
assertEquals(0, remainingResults.size)
}
@Test
fun `filter by pokemon name works`() = runBlocking {
// Arrange
storageService.saveDetectionResult(createTestDetectionResult("pikachu", 1500))
storageService.saveDetectionResult(createTestDetectionResult("charizard", 2000))
storageService.saveDetectionResult(createTestDetectionResult("pikachuuuu", 1600)) // partial match
val filter = DetectionFilter(pokemonName = "pikachu")
// Act
val filteredResults = storageService.getDetectionResults(filter)
// Assert
assertEquals(2, filteredResults.size) // Should match both pikachu and pikachuuuu
assertTrue(filteredResults.all { it.pokemonInfo?.name?.contains("pikachu", true) == true })
}
@Test
fun `filter by CP range works`() = runBlocking {
// Arrange
storageService.saveDetectionResult(createTestDetectionResult("pikachu", 1500))
storageService.saveDetectionResult(createTestDetectionResult("charizard", 2000))
storageService.saveDetectionResult(createTestDetectionResult("squirtle", 800))
val filter = DetectionFilter(minCP = 1000, maxCP = 1800)
// Act
val filteredResults = storageService.getDetectionResults(filter)
// Assert
assertEquals(1, filteredResults.size)
assertEquals("pikachu", filteredResults[0].pokemonInfo?.name)
}
@Test
fun `sort by CP descending works`() = runBlocking {
// Arrange
storageService.saveDetectionResult(createTestDetectionResult("pikachu", 1500))
storageService.saveDetectionResult(createTestDetectionResult("charizard", 2000))
storageService.saveDetectionResult(createTestDetectionResult("squirtle", 800))
// Act
val sortedResults = storageService.getDetectionResults(sortBy = DetectionSortBy.CP_DESC)
// Assert
assertEquals(3, sortedResults.size)
assertEquals("charizard", sortedResults[0].pokemonInfo?.name) // Highest CP first
assertEquals("pikachu", sortedResults[1].pokemonInfo?.name)
assertEquals("squirtle", sortedResults[2].pokemonInfo?.name)
}
@Test
fun `get result count works`() = runBlocking {
// Arrange
storageService.saveDetectionResult(createTestDetectionResult("pikachu", 1500))
storageService.saveDetectionResult(createTestDetectionResult("charizard", 2000))
// Act
val totalCount = storageService.getResultCount()
val filteredCount = storageService.getResultCount(DetectionFilter(pokemonName = "pikachu"))
// Assert
assertEquals(2, totalCount)
assertEquals(1, filteredCount)
}
@Test
fun `get storage stats works`() = runBlocking {
// Arrange
val successResult = createTestDetectionResult("pikachu", 1500, success = true)
val failResult = createTestDetectionResult("unknown", null, success = false)
storageService.saveDetectionResult(successResult)
storageService.saveDetectionResult(failResult)
// Act
val stats = storageService.getStorageStats()
// Assert
assertEquals(2, stats.totalResults)
assertEquals(1, stats.successfulResults)
assertEquals(1, stats.failedResults)
assertEquals("In-Memory", stats.storageType)
assertNotNull(stats.memoryUsageBytes)
assertTrue(stats.memoryUsageBytes!! > 0)
}
@Test
fun `success only filter works`() = runBlocking {
// Arrange
val successResult = createTestDetectionResult("pikachu", 1500, success = true)
val failResult = createTestDetectionResult("unknown", null, success = false)
storageService.saveDetectionResult(successResult)
storageService.saveDetectionResult(failResult)
val filter = DetectionFilter(successOnly = true)
// Act
val filteredResults = storageService.getDetectionResults(filter)
// Assert
assertEquals(1, filteredResults.size)
assertTrue(filteredResults[0].success)
assertEquals("pikachu", filteredResults[0].pokemonInfo?.name)
}
/**
* Helper method to create test detection results
*/
private fun createTestDetectionResult(
pokemonName: String?,
cp: Int?,
success: Boolean = true
): DetectionResult
{
val detection = Detection(
className = "pokemon",
confidence = 0.95f,
boundingBox = BoundingBox(100f, 100f, 200f, 200f)
)
val pokemonInfo = if (success && pokemonName != null)
{
PokemonDetectionInfo(
name = pokemonName,
cp = cp,
hp = 100,
level = 25f,
stats = PokemonDetectionStats(
attack = 15,
defense = 15,
stamina = 15,
perfectIV = 100f
)
)
}
else null
return DetectionResult(
detections = listOf(detection),
pokemonInfo = pokemonInfo,
processingTimeMs = 1000L,
success = success,
errorMessage = if (!success) "Test error" else null,
timestamp = LocalDateTime.now()
)
}
}

112
temp/HumanInTheLoop.py

@ -0,0 +1,112 @@
from ultralytics import YOLO
import os
import shutil
from PIL import Image
# --- Configuration ---
# Load your trained model
model = YOLO('./best.pt') # Adjust 'train8' if needed
# Directory with new, unannotated images (your input)
unlabeled_image_dir = './untrained_images'
# Base directory for YOLOv8's output. YOLOv8 will create 'predictX' folders inside this.
# Example: /TempReview/predict, /TempReview/predict1, etc.
yolov8_project_dir = './.yolo_yemp' # This is where your 'predictX' folders are generated
# The final, flat directory where images and labels will be moved for LabelImg
flat_output_dir = './for_labelimg_review'
# --- Ensure input directory exists ---
os.makedirs(unlabeled_image_dir, exist_ok=True) # Make sure this exists if you haven't uploaded images yet
# Get all image files from the unlabeled directory
image_files = [f for f in os.listdir(unlabeled_image_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))]
if not image_files:
print(f"No image files found in '{unlabeled_image_dir}'. Please upload images there.")
else:
print(f"--- Step 1: Running YOLOv8 Inference on new images ---")
print(f"Running inference on {len(image_files)} new images...")
# Run inference on all images in the directory
# YOLOv8 will automatically create a new 'predictX' folder (e.g., predict, predict1, predict2)
# inside the yolov8_project_dir for each unique run.
results_list = model(unlabeled_image_dir,
#save=True, # Save images with plotted boxes
save_txt=True, # Save the YOLO .txt label files
conf=0.6, # Confidence threshold for predictions (adjust as needed)
project=yolov8_project_dir # This is your 'TempReview'
)
# Find the most recently created 'predictX' folder
# This assumes the latest run will be the one with the highest number
# or the one most recently modified.
predict_dirs = [d for d in os.listdir(yolov8_project_dir) if d.startswith('predict') and os.path.isdir(os.path.join(yolov8_project_dir, d))]
if not predict_dirs:
print(f"Error: No 'predictX' folders found in '{yolov8_project_dir}'. Inference might have failed.")
else:
# Sort by creation time (most recent first) or by name (highest number)
# Sorting by name (predict, predict1, predict10, predict2...) doesn't always work numerically.
# Sorting by modification time is safer.
latest_predict_dir_name = max(predict_dirs, key=lambda d: os.path.getmtime(os.path.join(yolov8_project_dir, d)))
yolov8_run_path = os.path.join(yolov8_project_dir, latest_predict_dir_name)
yolov8_images_path = yolov8_run_path
yolov8_labels_path = os.path.join(yolov8_run_path, 'labels')
print(f"YOLOv8 results saved to: '{yolov8_run_path}'")
print(f"\n--- Step 2: Flattening output structure for LabelImg ---")
os.makedirs(flat_output_dir, exist_ok=True)
# Move images
if os.path.exists(yolov8_images_path):
for img_file in os.listdir(yolov8_images_path):
# Only move files that are original image files (e.g., .jpg, .png)
if img_file.lower().endswith(('.jpg', '.jpeg', '.png')):
shutil.move(os.path.join(yolov8_images_path, img_file),
os.path.join(flat_output_dir, img_file))
print(f"Moved images to '{flat_output_dir}'")
else:
print(f"Warning: No images found at '{yolov8_images_path}'.")
# Process and move labels
if os.path.exists(yolov8_labels_path):
for label_file in os.listdir(yolov8_labels_path):
if label_file.lower().endswith('.txt'):
label_path_src = os.path.join(yolov8_labels_path, label_file)
# Read lines, parse class_id, and sort
with open(label_path_src, 'r') as f:
lines = f.readlines()
# Sort lines based on the first element (class_id) as an integer
# Handle potential errors if a line is malformed, though unlikely from YOLO.
try:
sorted_lines = sorted(lines, key=lambda line: int(line.strip().split(' ')[0]))
except ValueError as e:
print(f"Warning: Could not sort lines in {label_file} due to format error: {e}. Skipping sort for this file.")
sorted_lines = lines # Fallback to original order
# Write sorted lines to the destination file
label_path_dest = os.path.join(flat_output_dir, label_file)
with open(label_path_dest, 'w') as f:
f.writelines(sorted_lines)
# Remove the original label file after processing (optional, but keeps source clean)
os.remove(label_path_src)
print(f"Moved and sorted labels to '{flat_output_dir}'")
else:
print(f"Warning: No labels found at '{yolov8_labels_path}'.")
# Clean up the intermediate YOLOv8 output directory (optional, but recommended for Colab space)
# Be careful with shutil.rmtree - it deletes recursively!
if os.path.exists(yolov8_run_path):
print(f"Cleaning up temporary YOLOv8 output: '{yolov8_run_path}'")
shutil.rmtree(yolov8_run_path)
print(f"\n--- Process Complete! ---")
print(f"Your images and predicted .txt labels are now in a flat structure in: '{flat_output_dir}'")
print("You can now open LabelImg and point it to this directory to begin review and correction.")

8402
temp/Pokemon_Home_Training.ipynb

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save