Compare commits
17 Commits
master
...
feature/pg
| Author | SHA1 | Date |
|---|---|---|
|
|
023e6496a4 | 5 months ago |
|
|
50514d5272 | 5 months ago |
|
|
75ae5f8e4b | 5 months ago |
|
|
293c0af196 | 5 months ago |
|
|
e63e86e9d2 | 5 months ago |
|
|
6319f0f9d9 | 5 months ago |
|
|
bc715fbc25 | 5 months ago |
|
|
3c1d730f3d | 5 months ago |
|
|
79489fa4c5 | 5 months ago |
|
|
fb2e481e87 | 5 months ago |
|
|
d320ea1f0d | 5 months ago |
|
|
65fadd2060 | 5 months ago |
|
|
f6c89c9727 | 5 months ago |
|
|
013593cdca | 5 months ago |
|
|
66aae07e94 | 5 months ago |
|
|
8611ca2b3e | 5 months ago |
|
|
8d9e4fd35c | 5 months ago |
18 changed files with 11814 additions and 50 deletions
@ -0,0 +1,3 @@ |
|||
{ |
|||
"cmake.ignoreCMakeListsMissing": true |
|||
} |
|||
@ -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") |
|||
} |
|||
} |
|||
@ -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") |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
) |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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) |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
} |
|||
@ -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() |
|||
} |
|||
} |
|||
@ -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() |
|||
) |
|||
} |
|||
} |
|||
@ -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.") |
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue