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__getConfluencePage",
"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": []
}

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
7. Update documentation when architecture changes
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.tooling.preview
implementation libs.androidx.material3
implementation 'androidx.navigation:navigation-compose:2.7.6'
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
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 androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.activity.ComponentActivity
import androidx.fragment.app.FragmentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
@ -25,12 +25,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
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.HistoryFragment
import com.quillstudios.pokegoalshelper.di.ServiceLocator
import org.opencv.android.OpenCVLoader
import org.opencv.core.Mat
import org.opencv.core.CvType
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
companion object {
private const val TAG = "MainActivity"
@ -157,15 +164,33 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Initialize ServiceLocator
ServiceLocator.initialize(applicationContext)
setContent {
PokeGoalsHelperTheme {
val navController = rememberNavController()
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
ScreenCaptureUI(
isCapturing = isCapturing,
onStartCapture = { requestScreenCapturePermission() },
onStopCapture = { stopScreenCaptureService() },
NavHost(
navController = navController,
startDestination = "main",
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,
onStartCapture: () -> Unit,
onStopCapture: () -> Unit,
onNavigateToHistory: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
@ -241,6 +267,18 @@ fun ScreenCaptureUI(
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)
@Composable
fun ScreenCaptureUIPreview() {
@ -261,7 +353,8 @@ fun ScreenCaptureUIPreview() {
ScreenCaptureUI(
isCapturing = false,
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 com.quillstudios.pokegoalshelper.controllers.DetectionController
import com.quillstudios.pokegoalshelper.ui.EnhancedFloatingFAB
import com.quillstudios.pokegoalshelper.ui.DetectionResultHandler
import com.quillstudios.pokegoalshelper.di.ServiceLocator
import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManager
import com.quillstudios.pokegoalshelper.capture.ScreenCaptureManagerImpl
import com.quillstudios.pokegoalshelper.ml.MLInferenceEngine
@ -132,12 +134,13 @@ class ScreenCaptureService : Service() {
private lateinit var screenCaptureManager: ScreenCaptureManager
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
// MVC Components
private lateinit var detectionController: DetectionController
private var enhancedFloatingFAB: EnhancedFloatingFAB? = null
private var detectionResultHandler: DetectionResultHandler? = null
private val handler = Handler(Looper.getMainLooper())
private var captureInterval = DEFAULT_CAPTURE_INTERVAL_MS
@ -162,6 +165,9 @@ class ScreenCaptureService : Service() {
super.onCreate()
createNotificationChannel()
// Initialize ServiceLocator if not already done
ServiceLocator.initialize(applicationContext)
// Initialize screen capture manager
screenCaptureManager = ScreenCaptureManagerImpl(this, handler)
screenCaptureManager.setImageCallback { image -> handleCapturedImage(image) }
@ -193,9 +199,13 @@ class ScreenCaptureService : Service() {
context = this,
onDetectionRequested = { triggerDetection() },
onToggleOverlay = { toggleOverlay() },
onReturnToApp = { returnToMainApp() }
onReturnToApp = { returnToMainApp() },
onShowLastResult = { showLastDetectionResult() }
)
// Initialize detection result handler
detectionResultHandler = DetectionResultHandler(this)
PGHLog.d(TAG, "✅ MVC architecture initialized")
}
@ -210,41 +220,6 @@ class ScreenCaptureService : Service() {
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 {
when (intent?.action) {
@ -335,6 +310,7 @@ class ScreenCaptureService : Service() {
handler.removeCallbacks(captureRunnable)
hideDetectionOverlay()
enhancedFloatingFAB?.hide()
detectionResultHandler?.cleanup()
latestImage?.close()
latestImage = null
@ -556,13 +532,46 @@ class ScreenCaptureService : Service() {
// Post results back to main thread
handler.post {
try {
val duration = System.currentTimeMillis() - analysisStartTime
// Convert MLDetection to Detection for the result handler
val extractorDetections = detections.map { mlDetection ->
com.quillstudios.pokegoalshelper.ml.Detection(
className = mlDetection.className,
confidence = mlDetection.confidence,
boundingBox = com.quillstudios.pokegoalshelper.ml.BoundingBox(
left = mlDetection.boundingBox.left,
top = mlDetection.boundingBox.top,
right = mlDetection.boundingBox.right,
bottom = mlDetection.boundingBox.bottom
)
)
}
if (pokemonInfo != null) {
PGHLog.i(TAG, "🔥 POKEMON DATA EXTRACTED SUCCESSFULLY!")
logPokemonInfo(pokemonInfo)
// TODO: Send to your API
// sendToAPI(pokemonInfo)
// Handle successful detection
detectionResultHandler?.handleSuccessfulDetection(
detections = extractorDetections,
pokemonInfo = pokemonInfo,
processingTimeMs = duration
)
// Stop spinning animation - detection complete
enhancedFloatingFAB?.stopDetectionAnimation()
} else {
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 {
// Analysis cycle complete, allow next one
@ -579,6 +588,32 @@ class ScreenCaptureService : Service() {
PGHLog.e(TAG, "Error in async Pokemon extraction", e)
matCopy.release()
// Handle failed detection
val duration = System.currentTimeMillis() - analysisStartTime
val extractorDetections = detections.map { mlDetection ->
com.quillstudios.pokegoalshelper.ml.Detection(
className = mlDetection.className,
confidence = mlDetection.confidence,
boundingBox = com.quillstudios.pokegoalshelper.ml.BoundingBox(
left = mlDetection.boundingBox.left,
top = mlDetection.boundingBox.top,
right = mlDetection.boundingBox.right,
bottom = mlDetection.boundingBox.bottom
)
)
}
handler.post {
detectionResultHandler?.handleFailedDetection(
detections = extractorDetections,
errorMessage = "Processing error: ${e.message}",
processingTimeMs = duration
)
// Stop spinning animation - detection failed
enhancedFloatingFAB?.stopDetectionAnimation()
}
// Clear flag on error too
handler.post {
isAnalyzing = false
@ -1159,6 +1194,9 @@ class ScreenCaptureService : Service() {
private fun triggerManualDetection() {
PGHLog.d(TAG, "🔍 Manual detection triggered via MVC!")
// Start spinning animation to show detection is in progress
enhancedFloatingFAB?.startDetectionAnimation()
latestImage?.let { image ->
try {
// Convert image to Mat for processing
@ -1184,11 +1222,16 @@ class ScreenCaptureService : Service() {
// Extract Pokemon info using YOLO detections with OCR
extractPokemonInfoFromYOLOAsync(mat, detections)
} else {
// No detections found - stop animation
PGHLog.i(TAG, "No detections found")
enhancedFloatingFAB?.stopDetectionAnimation()
}
mat.release()
} else {
PGHLog.e(TAG, "❌ Failed to convert image to Mat")
enhancedFloatingFAB?.stopDetectionAnimation()
}
// Close the image after processing to free the buffer
@ -1197,9 +1240,11 @@ class ScreenCaptureService : Service() {
} catch (e: Exception) {
PGHLog.e(TAG, "❌ Error in manual detection", e)
enhancedFloatingFAB?.stopDetectionAnimation()
}
} ?: run {
PGHLog.w(TAG, "⚠️ No image available for detection")
enhancedFloatingFAB?.stopDetectionAnimation()
}
}
@ -1233,4 +1278,77 @@ class ScreenCaptureService : Service() {
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 onDetectionRequested: () -> Unit,
private val onToggleOverlay: () -> Unit,
private val onReturnToApp: () -> Unit
private val onReturnToApp: () -> Unit,
private val onShowLastResult: () -> Unit
) {
companion object {
private const val FAB_SIZE_DP = 56
@ -52,6 +53,8 @@ class EnhancedFloatingFAB(
private var isShowing = false
private var isMenuExpanded = false
private var isDragging = false
private var isDetecting = false
private var spinAnimator: ObjectAnimator? = null
// Animation and positioning
private var currentX = 0
@ -217,6 +220,9 @@ class EnhancedFloatingFAB(
MenuItemData("DETECT", android.R.drawable.ic_menu_search, android.R.color.holo_blue_dark) {
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) {
onToggleOverlay()
},
@ -561,6 +567,50 @@ class EnhancedFloatingFAB(
).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(
val label: String,
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