diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 784fe95..eba13ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.adamaps.varroa" minSdk = 26 targetSdk = 34 - versionCode = 11 - versionName = "1.7.5" + versionCode = 12 + versionName = "1.7.6" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt b/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt index 87ed0ae..74fffd8 100644 --- a/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt +++ b/app/src/main/java/com/adamaps/varroa/VarroaApplication.kt @@ -3,6 +3,7 @@ package com.adamaps.varroa import android.app.Application import com.adamaps.varroa.network.NetworkStateMonitor import com.adamaps.varroa.service.AdaMapsUploadWorker +import com.adamaps.varroa.service.ImageCollectorService import org.osmdroid.config.Configuration class VarroaApplication : Application() { @@ -26,6 +27,9 @@ class VarroaApplication : Application() { // Schedule background uploads when internet is available AdaMapsUploadWorker.schedule(this) + + // Schedule background image collection from Bee device + ImageCollectorService.schedule(this) } override fun onTerminate() { diff --git a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt index a7452af..6fcf7a3 100644 --- a/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/AdaMapsApiClient.kt @@ -8,9 +8,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Dns import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File import java.net.InetAddress import java.util.concurrent.TimeUnit @@ -109,4 +112,61 @@ class AdaMapsApiClient( false } } + + /** + * Upload detection image to ADAMaps API. + * + * @param detectionId The detection ID to link the image to + * @param imageFile The image file to upload + * @param deviceId The device ID for reference + * @return ApiResult indicating success/failure + */ + suspend fun uploadImage( + detectionId: Long, + imageFile: File, + deviceId: String + ): ApiResult = withContext(Dispatchers.IO) { + val uploadUrl = "$apiUrl/api/images" + Log.d(TAG, "POST request to: $uploadUrl for detection $detectionId") + + try { + if (!imageFile.exists()) { + return@withContext ApiResult.Error("Image file does not exist: ${imageFile.absolutePath}") + } + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("detection_id", detectionId.toString()) + .addFormDataPart("device_id", deviceId) + .addFormDataPart( + "image", + imageFile.name, + imageFile.asRequestBody("image/jpeg".toMediaType()) + ) + .build() + + val request = Request.Builder() + .url(uploadUrl) + .addHeader("X-MapNet-Key", apiKey) + .post(requestBody) + .build() + + Log.d(TAG, "Uploading image for detection $detectionId: ${imageFile.length()} bytes") + client.newCall(request).execute().use { resp -> + val respBody = resp.body?.string() ?: "" + Log.d(TAG, "Image upload HTTP ${resp.code} ${resp.message} - response: ${respBody.take(200)}") + + if (resp.isSuccessful) { + Log.i(TAG, "Image upload successful for detection $detectionId") + ApiResult.Success(respBody) + } else { + Log.e(TAG, "Image upload failed for detection $detectionId - HTTP ${resp.code}") + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } + } catch (e: Exception) { + Log.e(TAG, "Image upload request failed for detection $detectionId", e) + ApiResult.Error(e.message ?: "Network error") + } + } } diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt index ec6feaa..721911a 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -325,6 +325,29 @@ class BeeApiClient( return configured to ApiResult.Error("No camera endpoint responded") } + /** + * Fetch detection image from Bee device. + * + * @param detectionId The landmark/detection ID from the Bee API + * @return ApiResult containing image bytes (JPEG) or error + */ + suspend fun getDetectionImage(detectionId: Long): ApiResult = withContext(Dispatchers.IO) { + val endpoint = "/api/1/landmarks/images/$detectionId" + Log.d(TAG, "Fetching detection image for ID: $detectionId") + + val result = getBytes(endpoint) + when (result) { + is ApiResult.Success -> { + Log.d(TAG, "Detection image fetched successfully: ${result.data.size} bytes") + result + } + is ApiResult.Error -> { + Log.w(TAG, "Failed to fetch detection image for ID $detectionId: ${result.message}") + result + } + } + } + /** * Attempt to delete landmarks from Bee device after successful upload. * This tries various potential DELETE endpoints. diff --git a/app/src/main/java/com/adamaps/varroa/data/local/DetectionDao.kt b/app/src/main/java/com/adamaps/varroa/data/local/DetectionDao.kt index 08e9a8d..8676a53 100644 --- a/app/src/main/java/com/adamaps/varroa/data/local/DetectionDao.kt +++ b/app/src/main/java/com/adamaps/varroa/data/local/DetectionDao.kt @@ -72,4 +72,35 @@ interface DetectionDao { */ @Query("SELECT EXISTS(SELECT 1 FROM detections WHERE beeDetectionId = :beeId AND timestamp = :ts)") suspend fun exists(beeId: Long, ts: Long?): Boolean + + /** + * Update image information for a detection. + */ + @Query("UPDATE detections SET hasImage = :hasImage, localImagePath = :localImagePath WHERE id = :id") + suspend fun updateImageInfo(id: Long, hasImage: Boolean, localImagePath: String?) + + /** + * Get detections that have images but haven't been uploaded yet. + */ + @Query("SELECT * FROM detections WHERE hasImage = 1 AND imageSynced = 0 ORDER BY createdAt ASC LIMIT :limit") + suspend fun getUnsyncedImagesBatch(limit: Int = 20): List + + /** + * Mark images as synced after successful upload. + */ + @Query("UPDATE detections SET imageSynced = 1 WHERE id IN (:ids)") + suspend fun markImagesSynced(ids: List) + + /** + * Count detections with images available. + */ + @Query("SELECT COUNT(*) FROM detections WHERE hasImage = 1") + fun countWithImages(): Flow + + /** + * Get detections that don't have images yet. + * Prioritizes recent detections for image collection. + */ + @Query("SELECT * FROM detections WHERE hasImage = 0 ORDER BY createdAt DESC LIMIT :limit") + suspend fun getDetectionsNeedingImages(limit: Int = 10): List } diff --git a/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt b/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt index 0684857..6748a2a 100644 --- a/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt +++ b/app/src/main/java/com/adamaps/varroa/data/local/DetectionEntity.kt @@ -38,6 +38,11 @@ data class DetectionEntity( val posConfidence: Double?, val azimuth: Double?, + // Image state + val hasImage: Boolean = false, + val localImagePath: String? = null, + val imageSynced: Boolean = false, + // Sync state val synced: Boolean = false, val createdAt: Long = System.currentTimeMillis(), diff --git a/app/src/main/java/com/adamaps/varroa/data/local/ImageStorageManager.kt b/app/src/main/java/com/adamaps/varroa/data/local/ImageStorageManager.kt new file mode 100644 index 0000000..c518b4b --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/data/local/ImageStorageManager.kt @@ -0,0 +1,197 @@ +package com.adamaps.varroa.data.local + +import android.content.Context +import android.util.Log +import java.io.File +import java.io.FileOutputStream + +/** + * Manages local storage of detection images. + * Images are stored in the app's internal storage for security and automatic cleanup. + */ +class ImageStorageManager(private val context: Context) { + + companion object { + private const val TAG = "ImageStorage" + private const val IMAGES_DIR = "detection_images" + } + + private val imagesDir: File by lazy { + File(context.filesDir, IMAGES_DIR).apply { + if (!exists()) { + mkdirs() + Log.d(TAG, "Created images directory: $absolutePath") + } + } + } + + /** + * Save image bytes to local storage. + * + * @param detectionId The detection ID to use as filename + * @param imageData The image bytes (JPEG) + * @return File path if successful, null if failed + */ + fun saveImage(detectionId: Long, imageData: ByteArray): String? { + if (imageData.isEmpty()) { + Log.w(TAG, "Cannot save empty image data for detection $detectionId") + return null + } + + return try { + val filename = "detection_${detectionId}.jpg" + val file = File(imagesDir, filename) + + // Don't overwrite existing files + if (file.exists()) { + Log.d(TAG, "Image already exists for detection $detectionId") + return file.absolutePath + } + + Log.d(TAG, "Saving image for detection $detectionId: ${imageData.size} bytes to $filename") + + FileOutputStream(file).use { output -> + output.write(imageData) + output.flush() + } + + Log.i(TAG, "Image saved successfully for detection $detectionId: ${file.absolutePath}") + file.absolutePath + + } catch (e: Exception) { + Log.e(TAG, "Failed to save image for detection $detectionId", e) + null + } + } + + /** + * Check if image exists locally. + */ + fun hasImage(detectionId: Long): Boolean { + val filename = "detection_${detectionId}.jpg" + val file = File(imagesDir, filename) + return file.exists() + } + + /** + * Get image file for a detection. + */ + fun getImageFile(detectionId: Long): File? { + val filename = "detection_${detectionId}.jpg" + val file = File(imagesDir, filename) + return if (file.exists()) file else null + } + + /** + * Read image bytes from storage. + */ + fun readImage(localImagePath: String): ByteArray? { + return try { + val file = File(localImagePath) + if (file.exists()) { + file.readBytes() + } else { + Log.w(TAG, "Image file not found: $localImagePath") + null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to read image from $localImagePath", e) + null + } + } + + /** + * Delete image file. + */ + fun deleteImage(localImagePath: String): Boolean { + return try { + val file = File(localImagePath) + val deleted = file.delete() + if (deleted) { + Log.d(TAG, "Deleted image file: $localImagePath") + } else { + Log.w(TAG, "Failed to delete image file: $localImagePath") + } + deleted + } catch (e: Exception) { + Log.e(TAG, "Exception deleting image: $localImagePath", e) + false + } + } + + /** + * Cleanup old images that are no longer referenced by the database. + * Call this periodically to free up storage space. + */ + suspend fun cleanupOrphanedImages(database: VarroaDatabase) { + try { + Log.d(TAG, "Starting orphaned image cleanup...") + + val imageFiles = imagesDir.listFiles { _, name -> + name.endsWith(".jpg") && name.startsWith("detection_") + } ?: emptyArray() + + var deletedCount = 0 + for (file in imageFiles) { + // Extract detection ID from filename + val detectionId = file.name + .removePrefix("detection_") + .removeSuffix(".jpg") + .toLongOrNull() + + if (detectionId != null) { + // Check if this detection still exists in database + val exists = database.detectionDao().exists(detectionId, null) + if (!exists) { + if (file.delete()) { + deletedCount++ + Log.d(TAG, "Deleted orphaned image: ${file.name}") + } + } + } + } + + if (deletedCount > 0) { + Log.i(TAG, "Cleanup complete: deleted $deletedCount orphaned images") + } else { + Log.d(TAG, "No orphaned images found") + } + + } catch (e: Exception) { + Log.e(TAG, "Error during image cleanup", e) + } + } + + /** + * Get storage statistics. + */ + fun getStorageInfo(): ImageStorageInfo { + return try { + val files = imagesDir.listFiles { _, name -> + name.endsWith(".jpg") && name.startsWith("detection_") + } ?: emptyArray() + + val totalSize = files.sumOf { it.length() } + + ImageStorageInfo( + imageCount = files.size, + totalSizeBytes = totalSize, + storageDir = imagesDir.absolutePath + ) + } catch (e: Exception) { + Log.e(TAG, "Error getting storage info", e) + ImageStorageInfo(0, 0, imagesDir.absolutePath) + } + } +} + +/** + * Storage statistics for UI display. + */ +data class ImageStorageInfo( + val imageCount: Int, + val totalSizeBytes: Long, + val storageDir: String +) { + val totalSizeMB: Double get() = totalSizeBytes / (1024.0 * 1024.0) +} \ No newline at end of file diff --git a/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt b/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt index c65d783..4a0a688 100644 --- a/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt +++ b/app/src/main/java/com/adamaps/varroa/data/local/VarroaDatabase.kt @@ -7,7 +7,7 @@ import androidx.room.RoomDatabase @Database( entities = [DetectionEntity::class], - version = 1, + version = 2, exportSchema = false ) abstract class VarroaDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt b/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt index 927e481..e8e94be 100644 --- a/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt +++ b/app/src/main/java/com/adamaps/varroa/service/AdaMapsUploadWorker.kt @@ -16,7 +16,9 @@ import com.adamaps.varroa.data.AdaMapsDetection import com.adamaps.varroa.data.ApiResult import com.adamaps.varroa.data.SettingsDataStore import com.adamaps.varroa.data.local.DetectionEntity +import com.adamaps.varroa.data.local.ImageStorageManager import com.adamaps.varroa.data.local.VarroaDatabase +import java.io.File import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -111,6 +113,7 @@ class AdaMapsUploadWorker( private val adamapsClient = AdaMapsApiClient() private val beeClient = BeeApiClient() private val settingsStore = SettingsDataStore(applicationContext) + private val imageStorage = ImageStorageManager(applicationContext) override suspend fun doWork(): Result { Log.i(TAG, "AdaMapsUploadWorker starting - checking for unsynced detections") @@ -143,7 +146,7 @@ class AdaMapsUploadWorker( when (val result = uploadBatch(batch)) { is Result.Success -> { - // Mark as synced + // Mark detection metadata as synced val ids = batch.map { it.id } Log.d(TAG, "Marking ${ids.size} records as synced in database...") database.detectionDao().markSynced(ids) @@ -151,6 +154,9 @@ class AdaMapsUploadWorker( _totalUploaded.value += batch.size Log.i(TAG, "Batch #$batchNum uploaded successfully - running total: $totalUploaded") + // Upload images for detections that have them + uploadImagesForBatch(batch) + // Attempt cleanup from Bee device after successful upload val beeDetectionIds = batch.map { it.beeDetectionId } Log.d(TAG, "Attempting to cleanup ${beeDetectionIds.size} landmarks from Bee device...") @@ -260,4 +266,53 @@ class AdaMapsUploadWorker( } } } + + /** + * Upload images for detections that have them. + * This runs after successful metadata upload. + */ + private suspend fun uploadImagesForBatch(batch: List) { + val detectionsWithImages = batch.filter { it.hasImage && !it.imageSynced && it.localImagePath != null } + if (detectionsWithImages.isEmpty()) { + Log.d(TAG, "No images to upload for this batch") + return + } + + Log.i(TAG, "Uploading images for ${detectionsWithImages.size} detections...") + + var uploadedCount = 0 + for (detection in detectionsWithImages) { + try { + val imagePath = detection.localImagePath!! + val imageFile = File(imagePath) + + if (!imageFile.exists()) { + Log.w(TAG, "Image file not found for detection ${detection.beeDetectionId}: $imagePath") + continue + } + + Log.d(TAG, "Uploading image for detection ${detection.beeDetectionId}") + when (val result = adamapsClient.uploadImage( + detection.beeDetectionId, + imageFile, + detection.deviceId + )) { + is ApiResult.Success -> { + // Mark image as synced + database.detectionDao().markImagesSynced(listOf(detection.id)) + uploadedCount++ + Log.i(TAG, "Image uploaded successfully for detection ${detection.beeDetectionId}") + } + is ApiResult.Error -> { + Log.w(TAG, "Image upload failed for detection ${detection.beeDetectionId}: ${result.message}") + // Don't fail the entire batch for image upload failures + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception uploading image for detection ${detection.beeDetectionId}", e) + } + } + + Log.i(TAG, "Image upload batch complete: $uploadedCount/${detectionsWithImages.size} images uploaded") + } } diff --git a/app/src/main/java/com/adamaps/varroa/service/ImageCollectorService.kt b/app/src/main/java/com/adamaps/varroa/service/ImageCollectorService.kt new file mode 100644 index 0000000..4a60db8 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/service/ImageCollectorService.kt @@ -0,0 +1,192 @@ +package com.adamaps.varroa.service + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.adamaps.varroa.api.BeeApiClient +import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.local.ImageStorageManager +import com.adamaps.varroa.data.local.VarroaDatabase +import kotlinx.coroutines.flow.first +import java.util.concurrent.TimeUnit + +/** + * ImageCollectorService - Downloads detection images from Bee device and stores them locally. + * + * This runs when connected to the Bee AP network and: + * - Finds detections that don't have images yet + * - Downloads images from Bee device using /api/1/landmarks/images/{id} + * - Stores images in local storage + * - Updates database to mark which detections have images + */ +class ImageCollectorService( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + private const val TAG = "ImageCollector" + private const val WORK_NAME = "image_collector" + private const val BATCH_SIZE = 10 // Smaller batches for images + + /** + * Schedule periodic image collection when connected to Bee network. + */ + fun schedule(context: Context) { + Log.d(TAG, "Scheduling periodic image collector...") + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val collectRequest = PeriodicWorkRequestBuilder( + 20, TimeUnit.MINUTES, // Run every 20 minutes + 5, TimeUnit.MINUTES // Flex interval + ) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 60, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + collectRequest + ) + Log.i(TAG, "Image collector scheduled - 20min interval") + } + + /** + * Cancel scheduled image collection. + */ + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + Log.d(TAG, "Image collector cancelled") + } + + /** + * Trigger immediate image collection. + */ + fun triggerNow(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val collectRequest = androidx.work.OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueue(collectRequest) + Log.d(TAG, "Immediate image collection triggered") + } + } + + private val database = VarroaDatabase.getInstance(applicationContext) + private val beeClient = BeeApiClient() + private val settingsStore = SettingsDataStore(applicationContext) + private val imageStorage = ImageStorageManager(applicationContext) + + override suspend fun doWork(): Result { + Log.i(TAG, "ImageCollectorService starting - checking for detections without images") + + try { + // Load settings + val settings = settingsStore.settings.first() + beeClient.updateUrl(settings.beeApiUrl) + Log.d(TAG, "BeeApiClient configured with URL: ${settings.beeApiUrl}") + + // Check if we can reach the Bee device + if (!beeClient.ping()) { + Log.w(TAG, "Cannot reach Bee device - skipping image collection") + return Result.retry() + } + + // Process batches of detections without images + var totalCollected = 0 + var batchNum = 0 + + while (true) { + // Get detections that don't have images yet + val batch = getDetectionsNeedingImages(BATCH_SIZE) + if (batch.isEmpty()) { + Log.i(TAG, "No more detections need images - collection complete") + break + } + + batchNum++ + Log.i(TAG, "Processing image batch #$batchNum with ${batch.size} detections") + + var batchCollected = 0 + for (detection in batch) { + try { + Log.d(TAG, "Fetching image for detection ${detection.beeDetectionId}") + + when (val result = beeClient.getDetectionImage(detection.beeDetectionId)) { + is ApiResult.Success -> { + val imagePath = imageStorage.saveImage( + detection.beeDetectionId, + result.data + ) + + if (imagePath != null) { + // Update database to mark this detection as having an image + database.detectionDao().updateImageInfo( + detection.id, + hasImage = true, + localImagePath = imagePath + ) + batchCollected++ + Log.i(TAG, "Image saved for detection ${detection.beeDetectionId}: $imagePath") + } else { + Log.w(TAG, "Failed to save image for detection ${detection.beeDetectionId}") + } + } + is ApiResult.Error -> { + Log.w(TAG, "Failed to fetch image for detection ${detection.beeDetectionId}: ${result.message}") + // Continue with next detection - image may not exist for this detection + } + } + } catch (e: Exception) { + Log.e(TAG, "Exception processing detection ${detection.beeDetectionId}", e) + } + } + + totalCollected += batchCollected + Log.i(TAG, "Batch #$batchNum complete: $batchCollected/$${batch.size} images collected") + + // If we didn't collect any images in this batch, the Bee device might not have + // images for these detections, so we can stop + if (batchCollected == 0) { + Log.i(TAG, "No images available for current detections - stopping collection") + break + } + } + + // Cleanup orphaned images periodically + if (totalCollected > 0) { + Log.d(TAG, "Running orphaned image cleanup...") + imageStorage.cleanupOrphanedImages(database) + } + + Log.i(TAG, "Image collection completed: $totalCollected images collected in $batchNum batches") + return Result.success() + + } catch (e: Exception) { + Log.e(TAG, "Image collector encountered unexpected error", e) + return Result.retry() + } + } + + /** + * Get detections that need images downloaded. + * Prioritizes recent detections that don't have images yet. + */ + private suspend fun getDetectionsNeedingImages(limit: Int) = + database.detectionDao().getDetectionsNeedingImages(limit) +} \ No newline at end of file diff --git a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt index 4e7d4ef..0a5fb53 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/dashboard/DashboardScreen.kt @@ -63,6 +63,7 @@ fun DashboardScreen( val localUnsynced by vm.localUnsynced.collectAsState() val localTotal by vm.localTotal.collectAsState() val localSynced by vm.localSynced.collectAsState() + val localWithImages by vm.localWithImages.collectAsState() // Network status val networkStatus by vm.networkStatus.collectAsState() @@ -138,7 +139,8 @@ fun DashboardScreen( sessionCollected = sessionCollected, localUnsynced = localUnsynced, totalUploaded = totalUploaded, - localTotal = localTotal + localTotal = localTotal, + localWithImages = localWithImages ) // Camera snapshot @@ -357,7 +359,8 @@ private fun SessionStatsCardV7( sessionCollected: Int, localUnsynced: Int, totalUploaded: Int, - localTotal: Int + localTotal: Int, + localWithImages: Int ) { Card( colors = CardDefaults.cardColors(containerColor = Surface), @@ -383,7 +386,7 @@ private fun SessionStatsCardV7( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.SpaceBetween ) { Text( text = "Total stored: $localTotal detections", @@ -391,6 +394,26 @@ private fun SessionStatsCardV7( fontFamily = FontFamily.Monospace, fontSize = 10.sp ) + + if (localWithImages > 0) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.PhotoCamera, + contentDescription = "Images available", + tint = Amber, + modifier = Modifier.size(12.dp) + ) + Text( + text = "$localWithImages images", + color = Amber, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } } } } diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt index a680b21..6d8f837 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DashboardViewModel.kt @@ -83,6 +83,10 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) { val localSynced: StateFlow = database.detectionDao().countSynced() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + // ── Image statistics ───────────────────────────────────────────────────── + val localWithImages: StateFlow = database.detectionDao().countWithImages() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) // ── Legacy compatibility ────────────────────────────────────────────────── // For UI components that still reference old ForwardingService states