diff --git a/.kotlin/sessions/kotlin-compiler-1933245961721594071.salive b/.kotlin/sessions/kotlin-compiler-7349953870880591348.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-1933245961721594071.salive rename to .kotlin/sessions/kotlin-compiler-7349953870880591348.salive diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5938e78..02e9809 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,6 +86,7 @@ dependencies { // Coil implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-video:2.5.0") + implementation("io.coil-kt:coil-svg:2.5.0") // Paging implementation("androidx.paging:paging-runtime:3.2.1") diff --git a/app/src/main/java/com/example/livingai/MainActivity.kt b/app/src/main/java/com/example/livingai/MainActivity.kt index f992230..a17ba23 100644 --- a/app/src/main/java/com/example/livingai/MainActivity.kt +++ b/app/src/main/java/com/example/livingai/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -15,11 +16,12 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import com.example.livingai.pages.home.HomeViewModel import com.example.livingai.pages.navigation.NavGraph +import com.example.livingai.pages.navigation.Route import com.example.livingai.ui.theme.LivingAITheme import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : ComponentActivity() { - val viewModel by viewModel() + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -29,6 +31,10 @@ class MainActivity : ComponentActivity() { viewModel.splashCondition.value } } + + // The splash screen is shown until the start destination is determined. + // If there's a delay, the splash screen will cover it. + setContent { LivingAITheme { enableEdgeToEdge( @@ -43,7 +49,13 @@ class MainActivity : ComponentActivity() { ) Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { val startDestination = viewModel.startDestination.value - NavGraph(startDestination = startDestination) + // Ensure startDestination is not null before rendering NavGraph + if (startDestination != null) { + NavGraph(startDestination = startDestination) + } else { + // Optional: Show a loading indicator if startDestination is null + // for an extended period, though splash screen should handle it. + } } } } diff --git a/app/src/main/java/com/example/livingai/data/local/CSVDataSource.kt b/app/src/main/java/com/example/livingai/data/local/CSVDataSource.kt index 69ac26e..7eab25c 100644 --- a/app/src/main/java/com/example/livingai/data/local/CSVDataSource.kt +++ b/app/src/main/java/com/example/livingai/data/local/CSVDataSource.kt @@ -23,7 +23,8 @@ import java.io.* class CSVDataSource( private val context: Context, - private val fileName: String + private val fileName: String, + private val dispatchers: com.example.livingai.utils.CoroutineDispatchers ) : DataSource { private val folderName = "LivingAI" @@ -32,7 +33,7 @@ class CSVDataSource( private var cachedUri: Uri? = null - private suspend fun getCsvUri(): Uri = withContext(Dispatchers.IO) { + private suspend fun getCsvUri(): Uri = withContext(dispatchers.io) { cachedUri?.let { return@withContext it } val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -105,7 +106,7 @@ class CSVDataSource( private suspend fun readAllLines(): List> = mutex.withLock { val uri = getCsvUri() - return withContext(Dispatchers.IO) { + return withContext(dispatchers.io) { try { context.contentResolver.openInputStream(uri)?.use { input -> val reader = CSVReader(InputStreamReader(input)) @@ -123,7 +124,7 @@ class CSVDataSource( private suspend fun writeAllLines(lines: List>) = mutex.withLock { val uri = getCsvUri() - withContext(Dispatchers.IO) { + withContext(dispatchers.io) { try { context.contentResolver.openOutputStream(uri, "wt")?.use { out -> val writer = CSVWriter(OutputStreamWriter(out)) diff --git a/app/src/main/java/com/example/livingai/data/ml/AIModelImpl.kt b/app/src/main/java/com/example/livingai/data/ml/AIModelImpl.kt index 1f9fdbf..5f2c462 100644 --- a/app/src/main/java/com/example/livingai/data/ml/AIModelImpl.kt +++ b/app/src/main/java/com/example/livingai/data/ml/AIModelImpl.kt @@ -1,11 +1,90 @@ package com.example.livingai.data.ml import android.graphics.Bitmap +import android.graphics.Color import com.example.livingai.domain.ml.AIModel +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation +import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.max + +// Define a constant color for the segmentation mask +private const val MASK_COLOR = 0x5500FF00 // Semi-transparent green class AIModelImpl : AIModel { + + private val segmenter by lazy { + val options = SubjectSegmenterOptions.Builder() + .enableForegroundBitmap() + .build() + SubjectSegmentation.getClient(options) + } + override fun deriveInference(bitmap: Bitmap): String { - // Placeholder for actual inference logic return "Inference Result" } -} \ No newline at end of file + + override suspend fun segmentImage(bitmap: Bitmap): Pair? { + return suspendCancellableCoroutine { cont -> + val image = InputImage.fromBitmap(bitmap, 0) + segmenter.process(image) + .addOnSuccessListener { result -> + val foregroundBitmap = result.foregroundBitmap + if (foregroundBitmap != null) { + val colorizedMask = createColorizedMask(foregroundBitmap) + val booleanMask = createBooleanMask(foregroundBitmap) + cont.resume(Pair(colorizedMask, booleanMask)) + } else { + cont.resume(null) + } + } + .addOnFailureListener { e -> + cont.resumeWithException(e) + } + } + } + + private fun createColorizedMask(maskBitmap: Bitmap): Bitmap { + val width = maskBitmap.width + val height = maskBitmap.height + val pixels = IntArray(width * height) + maskBitmap.getPixels(pixels, 0, width, 0, 0, width, height) + + for (i in pixels.indices) { + if (Color.alpha(pixels[i]) > 0) { + pixels[i] = MASK_COLOR + } + } + + return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888) + } + + private fun createBooleanMask(bitmap: Bitmap): BooleanArray { + val w = bitmap.width + val h = bitmap.height + val mask = BooleanArray(w * h) + val pixels = IntArray(w * h) + bitmap.getPixels(pixels, 0, w, 0, 0, w, h) + for (i in pixels.indices) { + val alpha = Color.alpha(pixels[i]) + mask[i] = alpha > 0 // Assuming foreground bitmap has transparent background + } + return mask + } + + fun jaccardIndex(maskA: BooleanArray, maskB: BooleanArray): Double { + val len = max(maskA.size, maskB.size) + var inter = 0 + var union = 0 + for (i in 0 until len) { + val a = if (i < maskA.size) maskA[i] else false + val b = if (i < maskB.size) maskB[i] else false + if (a || b) union++ + if (a && b) inter++ + } + return if (union == 0) 0.0 else inter.toDouble() / union.toDouble() + } +} diff --git a/app/src/main/java/com/example/livingai/di/AppModule.kt b/app/src/main/java/com/example/livingai/di/AppModule.kt index ff63050..cf1885c 100644 --- a/app/src/main/java/com/example/livingai/di/AppModule.kt +++ b/app/src/main/java/com/example/livingai/di/AppModule.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import coil.ImageLoader +import coil.decode.SvgDecoder import com.example.livingai.data.local.CSVDataSource import com.example.livingai.data.manager.LocalUserManagerImpl import com.example.livingai.data.ml.AIModelImpl @@ -42,6 +44,9 @@ import com.example.livingai.pages.onboarding.OnBoardingViewModel import com.example.livingai.pages.ratings.RatingViewModel import com.example.livingai.pages.settings.SettingsViewModel import com.example.livingai.utils.Constants +import com.example.livingai.utils.CoroutineDispatchers +import com.example.livingai.utils.DefaultCoroutineDispatchers +import com.example.livingai.utils.SilhouetteManager import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -59,14 +64,35 @@ val appModule = module { ) } + // Coroutine dispatchers (for testability) + single { DefaultCoroutineDispatchers() } + // Data Source single { CSVDataSource( context = androidContext(), - fileName = Constants.ANIMAL_DATA_FILENAME + fileName = Constants.ANIMAL_DATA_FILENAME, + dispatchers = get() ) } + // Coil ImageLoader singleton + single { + ImageLoader.Builder(androidContext()) + .components { + add(SvgDecoder.Factory()) + } + .build() + } + + // Initialize silhouettes once + single(createdAtStart = true) { + val ctx: Context = androidContext() + // Names expected to be provided as drawable resource names like "front_silhoutte" + val names = listOf("front_silhouette", "side_silhouette", "rear_silhouette") + SilhouetteManager.initialize(ctx, names) + } + // ML Model single { AIModelImpl() } @@ -104,10 +130,12 @@ val appModule = module { // ViewModels viewModel { HomeViewModel(get()) } viewModel { OnBoardingViewModel(get(), get()) } - viewModel { AddProfileViewModel(get()) } + viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) -> + AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle) + } viewModel { ListingsViewModel(get()) } - viewModel { SettingsViewModel(get()) } + viewModel { SettingsViewModel(get(), androidContext()) } viewModel { RatingViewModel(get(), get(), get(), get()) } - viewModel { CameraViewModel(get()) } + viewModel { CameraViewModel(get(), get()) } viewModel { VideoViewModel() } } diff --git a/app/src/main/java/com/example/livingai/domain/ml/AIModel.kt b/app/src/main/java/com/example/livingai/domain/ml/AIModel.kt index 5c1f336..ee66295 100644 --- a/app/src/main/java/com/example/livingai/domain/ml/AIModel.kt +++ b/app/src/main/java/com/example/livingai/domain/ml/AIModel.kt @@ -4,4 +4,5 @@ import android.graphics.Bitmap interface AIModel { fun deriveInference(bitmap: Bitmap): String + suspend fun segmentImage(bitmap: Bitmap): Pair? } \ No newline at end of file diff --git a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt index da8409f..b14a9e0 100644 --- a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -49,6 +50,12 @@ fun AddProfileScreen( onTakeVideo: () -> Unit ) { val context = LocalContext.current + + // If opened for edit, attempt to load existing animal details + LaunchedEffect(Unit) { + val existing = viewModel.savedStateHandle?.get("animalId") + if (existing != null) viewModel.loadAnimal(existing) + } val speciesList = stringArrayResource(id = R.array.species_list).toList() val breedList = stringArrayResource(id = R.array.cow_breed_list).toList() @@ -59,10 +66,9 @@ fun AddProfileScreen( stringResource(R.string.option_none) ) - val silhouette = Constants.silhouetteList.associate { item -> + val silhouette = Constants.silhouetteList.associateWith { item -> val resId = context.resources.getIdentifier("label_${item}", "string", context.packageName) - - item to if (resId != 0) resId else R.string.default_orientation_label + if (resId != 0) resId else R.string.default_orientation_label } // Use ViewModel state @@ -172,7 +178,7 @@ fun AddProfileScreen( // Video Button item { VideoThumbnailButton( - videoSource = videoUri, // Removed videoThumbnail fallback as it's not in VM + videoSource = videoUri, onClick = onTakeVideo ) } diff --git a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt index d5ebed8..3b43c20 100644 --- a/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/addprofile/AddProfileViewModel.kt @@ -1,21 +1,32 @@ package com.example.livingai.pages.addprofile +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.livingai.domain.model.AnimalDetails +import com.example.livingai.domain.usecases.GetAnimalDetails import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase +import com.example.livingai.utils.CoroutineDispatchers import com.example.livingai.utils.IdGenerator import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AddProfileViewModel( - private val profileEntryUseCase: ProfileEntryUseCase + private val profileEntryUseCase: ProfileEntryUseCase, + private val getAnimalDetails: GetAnimalDetails, + private val dispatchers: CoroutineDispatchers, + private val context: Context, + val savedStateHandle: SavedStateHandle? = null ) : ViewModel() { - + private val _animalDetails = mutableStateOf(null) val animalDetails: State = _animalDetails @@ -36,7 +47,7 @@ class AddProfileViewModel( private val _videoUri = mutableStateOf(null) val videoUri: State = _videoUri - fun loadAnimalDetails(animalId: String?) { + fun loadAnimal(animalId: String?) { if (animalId == null) { val newId = IdGenerator.generateAnimalId() _currentAnimalId.value = newId @@ -70,15 +81,22 @@ class AddProfileViewModel( // Populate photos photos.clear() - details.images.forEach { path -> - // path: .../{id}_{orientation}.jpg - val filename = path.substringAfterLast('/') - val nameWithoutExt = filename.substringBeforeLast('.') - val parts = nameWithoutExt.split('_') - if (parts.size >= 2) { - val orientation = parts.last() - photos[orientation] = path - } + // Process images on IO thread as it may involve DB queries + withContext(dispatchers.io) { + val photoMap = mutableMapOf() + details.images.forEach { path -> + val uri = Uri.parse(path) + val filename = getFileName(uri) ?: path.substringAfterLast('/') + val nameWithoutExt = filename.substringBeforeLast('.') + val parts = nameWithoutExt.split('_') + if (parts.size >= 2) { + val orientation = parts.last() + photoMap[orientation] = path + } + } + withContext(dispatchers.main) { + photoMap.forEach { (k, v) -> photos[k] = v } + } } _videoUri.value = details.video.ifBlank { null } } @@ -86,6 +104,33 @@ class AddProfileViewModel( } } + private fun getFileName(uri: Uri): String? { + var result: String? = null + if (uri.scheme == "content") { + val cursor = context.contentResolver.query(uri, null, null, null, null) + try { + if (cursor != null && cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) { + result = cursor.getString(index) + } + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + cursor?.close() + } + } + if (result == null) { + result = uri.path + val cut = result?.lastIndexOf('/') + if (cut != null && cut != -1) { + result = result?.substring(cut + 1) + } + } + return result + } + fun addPhoto(orientation: String, uri: String) { photos[orientation] = uri } @@ -115,4 +160,11 @@ class AddProfileViewModel( profileEntryUseCase.setAnimalDetails(details) } } + + init { + // Try to auto-load when editing via saved state handle + val animalId: String? = savedStateHandle?.get("animalId") + // Call loadAnimal unconditionally to ensure ID generation for new profiles + loadAnimal(animalId) + } } diff --git a/app/src/main/java/com/example/livingai/pages/camera/CameraScreen.kt b/app/src/main/java/com/example/livingai/pages/camera/CameraScreen.kt index a5ecead..44c9f55 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/CameraScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/CameraScreen.kt @@ -1,9 +1,11 @@ package com.example.livingai.pages.camera +import android.content.pm.ActivityInfo import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.camera.view.LifecycleCameraController +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -25,14 +27,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.navigation.NavController -import org.koin.androidx.compose.koinViewModel import com.example.livingai.pages.components.CameraPreview import com.example.livingai.pages.components.PermissionWrapper import com.example.livingai.pages.navigation.Route +import com.example.livingai.utils.SetScreenOrientation +import com.example.livingai.utils.SilhouetteManager +import org.koin.androidx.compose.koinViewModel @Composable fun CameraScreen( @@ -41,6 +47,12 @@ fun CameraScreen( orientation: String? = null, animalId: String ) { + val orientationLock = when (orientation) { + "front", "back" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + SetScreenOrientation(orientationLock) + PermissionWrapper { val state by viewModel.state.collectAsState() val context = LocalContext.current @@ -99,29 +111,66 @@ fun CameraScreen( .fillMaxSize() .padding(paddingValues), ) { - CameraPreview( - modifier = Modifier.fillMaxSize(), - controller = controller, - onFrame = { bitmap -> - if (state.isAutoCaptureOn) { - viewModel.onEvent(CameraEvent.FrameReceived(bitmap)) + Box(modifier = Modifier.fillMaxSize()) { + CameraPreview( + modifier = Modifier.fillMaxSize(), + controller = controller, + onFrame = { bitmap, rotation -> + if (orientation != null) { + viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation)) + } + } + ) + + // Overlay silhouette if orientation provided + if (state.orientation != null) { + val silhouetteName = "${state.orientation}_silhouette" + val bmp = SilhouetteManager.getOriginal(silhouetteName) + if (bmp != null) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "overlay", + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + alpha = 0.5f + ) } } - ) + + // Overlay Segmentation Mask + state.segmentationMask?.let { mask -> + Image( + bitmap = mask.asImageBitmap(), + contentDescription = "segmentation overlay", + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.FillBounds, + alpha = 0.5f + ) + } + } + // Top Bar with Inference Result and Auto Capture Switch Row( modifier = Modifier .fillMaxWidth() .align(Alignment.TopCenter) .padding(16.dp), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("Auto Capture") - Switch( - checked = state.isAutoCaptureOn, - onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) } - ) + if (state.inferenceResult != null) { + Text(text = state.inferenceResult!!, color = androidx.compose.ui.graphics.Color.White) + } else { + Box(modifier = Modifier.weight(1f)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Auto Capture", color = androidx.compose.ui.graphics.Color.White) + Switch( + checked = state.isAutoCaptureOn, + onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) } + ) + } } } } diff --git a/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt b/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt index 9f84f94..4477a30 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/CameraViewModel.kt @@ -1,87 +1,126 @@ package com.example.livingai.pages.camera import android.graphics.Bitmap +import android.graphics.Matrix import android.net.Uri import androidx.camera.core.ImageProxy import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.livingai.data.ml.AIModelImpl +import com.example.livingai.domain.ml.AIModel import com.example.livingai.domain.repository.CameraRepository +import com.example.livingai.utils.SilhouetteManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class CameraViewModel( - private val cameraRepository: CameraRepository + private val cameraRepository: CameraRepository, + private val aiModel: AIModel ) : ViewModel() { - private val _state = MutableStateFlow(CameraState()) + private val _state = MutableStateFlow(CameraUiState()) val state = _state.asStateFlow() - private var currentAnimalId: String = "" - private var currentOrientation: String? = null + // mutex to prevent parallel captures + private val captureMutex = Mutex() fun onEvent(event: CameraEvent) { when (event) { - is CameraEvent.CaptureImage -> { - // Signal UI to capture? Or handle if passed image - } - is CameraEvent.ImageCaptured -> { - saveImage(event.imageProxy) - } - is CameraEvent.FrameReceived -> { - processFrame(event.bitmap) - } - is CameraEvent.ToggleAutoCapture -> { - _state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn) - } - is CameraEvent.ClearCapturedImage -> { - _state.value = _state.value.copy(capturedImage = null, capturedImageUri = null) - } - is CameraEvent.SetContext -> { - currentAnimalId = event.animalId - currentOrientation = event.orientation - } + is CameraEvent.CaptureImage -> captureImage() + is CameraEvent.ImageCaptured -> handleImageProxy(event.imageProxy) + is CameraEvent.FrameReceived -> handleFrame(event.bitmap, event.rotationDegrees) + is CameraEvent.ToggleAutoCapture -> toggleAutoCapture() + is CameraEvent.ClearCapturedImage -> clearCaptured() + is CameraEvent.SetContext -> setContext(event.animalId, event.orientation) } } - private fun saveImage(imageProxy: ImageProxy) { + private fun setContext(animalId: String, orientation: String?) { + _state.value = _state.value.copy(animalId = animalId, orientation = orientation) + } + + private fun toggleAutoCapture() { + _state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn) + } + + private fun clearCaptured() { + _state.value = _state.value.copy(capturedImage = null, capturedImageUri = null, segmentationMask = null) + } + + private fun captureImage() { + // UI should handle capture and send ImageCaptured event + } + + private fun handleImageProxy(proxy: ImageProxy) { + // convert to bitmap and then save via repository viewModelScope.launch { - try { - val bitmap = cameraRepository.captureImage(imageProxy) - val uriString = cameraRepository.saveImage(bitmap, currentAnimalId, currentOrientation) - _state.value = _state.value.copy(capturedImage = bitmap, capturedImageUri = Uri.parse(uriString)) - } catch (e: Exception) { - // Handle error - imageProxy.close() // Ensure closed on error if repository didn't - } + val bitmap = cameraRepository.captureImage(proxy) + val animalId = _state.value.animalId ?: "unknown" + val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation) + _state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString)) } } - private fun processFrame(bitmap: Bitmap) { + private fun handleFrame(bitmap: Bitmap, rotationDegrees: Int) { + val orientation = _state.value.orientation ?: return + viewModelScope.launch { - try { - val result = cameraRepository.processFrame(bitmap) - _state.value = _state.value.copy(inferenceResult = result) - } catch (e: Exception) { - // Handle error + if (captureMutex.tryLock()) { + try { + val result = aiModel.segmentImage(bitmap) + if (result != null) { + val (maskBitmap, maskBool) = result + + // Rotate the mask to match the display orientation + val rotatedMask = if (rotationDegrees != 0) { + val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } + Bitmap.createBitmap(maskBitmap, 0, 0, maskBitmap.width, maskBitmap.height, matrix, true) + } else { + maskBitmap + } + + _state.value = _state.value.copy(segmentationMask = rotatedMask) + + val savedMask = SilhouetteManager.getBitmask("${orientation}_silhouette") + if (savedMask != null) { + if (maskBool.size == savedMask.size) { + val jaccard = (aiModel as AIModelImpl).jaccardIndex(maskBool, savedMask) + + if (_state.value.isAutoCaptureOn && jaccard > 0.5) { + val animalId = _state.value.animalId ?: "unknown" + val uriString = cameraRepository.saveImage(bitmap, animalId, orientation) + _state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString)) + } + + _state.value = _state.value.copy(inferenceResult = "Jaccard: %.2f".format(jaccard)) + } + } + } + } finally { + captureMutex.unlock() + } } } } } -data class CameraState( - val isLoading: Boolean = false, - val isCameraReady: Boolean = false, +data class CameraUiState( + val animalId: String? = null, + val orientation: String? = null, val capturedImage: Any? = null, val capturedImageUri: Uri? = null, val inferenceResult: String? = null, - val isAutoCaptureOn: Boolean = false + val isAutoCaptureOn: Boolean = false, + val segmentationMask: Bitmap? = null ) sealed class CameraEvent { object CaptureImage : CameraEvent() data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent() - data class FrameReceived(val bitmap: Bitmap) : CameraEvent() + data class FrameReceived(val bitmap: Bitmap, val rotationDegrees: Int) : CameraEvent() object ToggleAutoCapture : CameraEvent() object ClearCapturedImage : CameraEvent() data class SetContext(val animalId: String, val orientation: String?) : CameraEvent() diff --git a/app/src/main/java/com/example/livingai/pages/camera/VideoRecordScreen.kt b/app/src/main/java/com/example/livingai/pages/camera/VideoRecordScreen.kt index eb18e58..9c1b369 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/VideoRecordScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/VideoRecordScreen.kt @@ -135,7 +135,7 @@ fun VideoRecordScreen( CameraPreview( modifier = Modifier.fillMaxSize(), controller = controller, - onFrame = {} + onFrame = { _, _ -> } ) } } diff --git a/app/src/main/java/com/example/livingai/pages/camera/ViewVideoScreen.kt b/app/src/main/java/com/example/livingai/pages/camera/ViewVideoScreen.kt index b78e93d..c25ccd2 100644 --- a/app/src/main/java/com/example/livingai/pages/camera/ViewVideoScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/camera/ViewVideoScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -13,16 +14,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -32,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.example.livingai.ui.theme.LivingAITheme +import kotlinx.coroutines.delay @Composable fun ViewVideoScreen( @@ -43,9 +49,22 @@ fun ViewVideoScreen( ) { var isPlaying by remember { mutableStateOf(false) } var videoView: VideoView? by remember { mutableStateOf(null) } - // Keep track if we have sought to the first frame to avoid resetting on recomposition repeatedly if not needed, - // though VideoView state management in Compose can be tricky. var isPrepared by remember { mutableStateOf(false) } + var currentPosition by remember { mutableIntStateOf(0) } + var duration by remember { mutableIntStateOf(0) } + + // Polling for video progress + LaunchedEffect(isPlaying, isPrepared) { + if (isPlaying && isPrepared) { + while (true) { + videoView?.let { + currentPosition = it.currentPosition + if (duration == 0) duration = it.duration + } + delay(100) + } + } + } LivingAITheme { Scaffold { padding -> @@ -57,7 +76,7 @@ fun ViewVideoScreen( setOnPreparedListener { mp -> mp.isLooping = true isPrepared = true - // Seek to 1ms to show thumbnail (first frame) + duration = mp.duration seekTo(1) } setOnCompletionListener { @@ -75,21 +94,18 @@ fun ViewVideoScreen( } } }, - modifier = Modifier - .fillMaxSize() - .clickable { - isPlaying = !isPlaying - } + modifier = Modifier.fillMaxSize() ) - // Play Button Overlay (Only show when paused) - if (!isPlaying) { - Box( - modifier = Modifier - .fillMaxSize() - .clickable { isPlaying = true }, // Clicking anywhere while paused starts play - contentAlignment = Alignment.Center - ) { + // Controls Overlay + Box( + modifier = Modifier + .fillMaxSize() + .background(if (!isPlaying) Color.Black.copy(alpha = 0.3f) else Color.Transparent) + .clickable { isPlaying = !isPlaying }, + contentAlignment = Alignment.Center + ) { + if (!isPlaying) { Icon( imageVector = Icons.Default.PlayArrow, contentDescription = "Play", @@ -100,41 +116,66 @@ fun ViewVideoScreen( .padding(8.dp) ) } - } else { - // Invisible clickable box to pause - Box( - modifier = Modifier - .fillMaxSize() - .clickable { isPlaying = false }, - contentAlignment = Alignment.Center - ) { - // No icon, just allows pausing + } + + // Bottom Control Bar + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.6f)) + .padding(16.dp) + ) { + // Progress Bar + if (duration > 0) { + LinearProgressIndicator( + progress = { if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatTime(currentPosition), + color = Color.White, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = formatTime(duration), + color = Color.White, + style = MaterialTheme.typography.bodySmall + ) + } + } + + if (shouldAllowRetake) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button(onClick = onRetake) { + Text("Retake") + } + Button(onClick = onAccept) { + Text("Accept") + } + } } } - if (shouldAllowRetake) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - Button(onClick = onRetake) { - Text("Retake") - } - Button(onClick = onAccept) { - Text("Accept") - } - } - } else { + if (!shouldAllowRetake) { IconButton( onClick = onBack, modifier = Modifier .align(Alignment.TopStart) .padding(16.dp) ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) } } } @@ -147,3 +188,9 @@ fun ViewVideoScreen( } } } + +fun formatTime(millis: Int): String { + val seconds = (millis / 1000) % 60 + val minutes = (millis / (1000 * 60)) % 60 + return String.format("%02d:%02d", minutes, seconds) +} diff --git a/app/src/main/java/com/example/livingai/pages/components/CameraPreview.kt b/app/src/main/java/com/example/livingai/pages/components/CameraPreview.kt index af6b568..65b029e 100644 --- a/app/src/main/java/com/example/livingai/pages/components/CameraPreview.kt +++ b/app/src/main/java/com/example/livingai/pages/components/CameraPreview.kt @@ -18,7 +18,7 @@ import java.util.concurrent.Executors fun CameraPreview( modifier: Modifier = Modifier, controller: LifecycleCameraController? = null, - onFrame: (Bitmap) -> Unit + onFrame: (Bitmap, Int) -> Unit ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -29,26 +29,19 @@ fun CameraPreview( LaunchedEffect(cameraController) { cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy -> val bitmap = imageProxy.toBitmap() - onFrame(bitmap) + val rotationDegrees = imageProxy.imageInfo.rotationDegrees + onFrame(bitmap, rotationDegrees) imageProxy.close() } } - // Ensure default setup if it was created internally, or even if passed externally we might want to enforce defaults - // But typically caller configures it if they pass it. - // However, for this component to work as a preview + analysis, we should ensure analysis is enabled. - if (controller == null) { - // Only set defaults if we created it LaunchedEffect(cameraController) { cameraController.setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE or LifecycleCameraController.VIDEO_CAPTURE) cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA cameraController.bindToLifecycle(lifecycleOwner) } } else { - // If passed externally, we still need to bind it if not already bound? - // Usually the caller binds it. But let's be safe or assume caller does it. - // Actually, let's bind it here to be sure, as this component represents the "Active Camera". LaunchedEffect(cameraController, lifecycleOwner) { cameraController.bindToLifecycle(lifecycleOwner) } @@ -69,4 +62,4 @@ fun CameraPreview( // Cleanup if needed } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/livingai/pages/components/CommonScaffold.kt b/app/src/main/java/com/example/livingai/pages/components/CommonScaffold.kt index 1a83277..8ec1377 100644 --- a/app/src/main/java/com/example/livingai/pages/components/CommonScaffold.kt +++ b/app/src/main/java/com/example/livingai/pages/components/CommonScaffold.kt @@ -6,10 +6,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -26,20 +29,22 @@ import com.example.livingai.pages.navigation.Route fun CommonScaffold( navController: NavController, title: String, + showFab: Boolean = false, + onFabClick: () -> Unit = {}, content: @Composable (PaddingValues) -> Unit ) { val bottomNavItems = listOf( BottomBarItem( - route = Route.OnBoardingScreen, - icon = Icons.Default.Build, - name = "Nav", - notifications = 1, + route = Route.SettingsScreen, + icon = Icons.Default.Settings, + name = "Settings", + notifications = 0, ), BottomBarItem( route = Route.HomeScreen, icon = Icons.Default.Home, name = "Home", - notifications = 5, + notifications = 0, ), BottomBarItem( route = Route.ListingsScreen, @@ -74,6 +79,17 @@ fun CommonScaffold( navController = navController, onClick = { navController.navigate(it.route) } ) + }, + floatingActionButton = { + if (showFab) { + FloatingActionButton( + onClick = onFabClick, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + Icon(Icons.Default.Add, contentDescription = "Add Profile") + } + } } ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt b/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt index bc3bd75..90fd141 100644 --- a/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt +++ b/app/src/main/java/com/example/livingai/pages/components/LabeledDropdown.kt @@ -26,11 +26,12 @@ fun LabeledDropdown( @StringRes labelRes: Int, options: List, selected: String?, - onSelected: (String) -> Unit + onSelected: (String) -> Unit, + modifier: Modifier = Modifier ) { var expanded by remember { mutableStateOf(false) } - Column { + Column(modifier = modifier) { ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = !expanded } diff --git a/app/src/main/java/com/example/livingai/pages/components/LivingAIBottomBar.kt b/app/src/main/java/com/example/livingai/pages/components/LivingAIBottomBar.kt index 53f827e..b34cb7f 100644 --- a/app/src/main/java/com/example/livingai/pages/components/LivingAIBottomBar.kt +++ b/app/src/main/java/com/example/livingai/pages/components/LivingAIBottomBar.kt @@ -15,12 +15,15 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.navigation.Route import com.example.livingai.ui.theme.LivingAITheme @@ -32,8 +35,7 @@ fun LivingAIBottomBar( onClick: (BottomBarItem) -> Unit, modifier: Modifier = Modifier ) { - val backStackEntry = navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry.value?.destination?.route + val backStackEntry by navController.currentBackStackEntryAsState() NavigationBar( modifier = modifier, @@ -42,9 +44,14 @@ fun LivingAIBottomBar( tonalElevation = Dimentions.BOTTOM_BAR_ELEVATION ) { navItems.forEach { item -> - val sel = ((currentRoute != null) && (item.route::class == currentRoute::class)) + val selected = backStackEntry?.destination?.hierarchy?.any { + // This is a safer check. It compares the string route pattern. + // It's less ideal than type-safe checking but avoids deserialization crashes. + it.route == item.route::class.simpleName + } == true + NavigationBarItem( - selected = sel, + selected = selected, onClick = { onClick(item) }, colors = NavigationBarItemDefaults.colors( selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, diff --git a/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt b/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt index ab60202..870e1ed 100644 --- a/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/home/HomeScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -23,12 +22,18 @@ import androidx.compose.ui.text.font.FontWeight import androidx.navigation.NavController import com.example.livingai.R import com.example.livingai.pages.commons.Dimentions +import com.example.livingai.pages.components.CommonScaffold import com.example.livingai.pages.navigation.Route @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(navController: NavController) { - Scaffold { innerPadding -> + CommonScaffold( + navController = navController, + title = stringResource(id = R.string.app_name), + showFab = false, + onFabClick = { } + ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() @@ -49,18 +54,16 @@ fun HomeScreen(navController: NavController) { ) Spacer(modifier = Modifier.height(Dimentions.MEDIUM_PADDING)) - HomeButton( - text = stringResource(id = R.string.top_bar_add_profile), - onClick = { navController.navigate(Route.AddProfileScreen()) } - ) HomeButton( text = stringResource(id = R.string.top_bar_listings), onClick = { navController.navigate(Route.ListingsScreen) } ) + HomeButton( - text = stringResource(id = R.string.top_bar_settings), - onClick = { navController.navigate(Route.SettingsScreen) } + text = stringResource(id = R.string.top_bar_add_profile), + onClick = { navController.navigate(Route.AddProfileScreen()) } ) + } } } @@ -78,4 +81,4 @@ fun HomeButton( ) { Text(text = text) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/livingai/pages/listings/ListingsScreen.kt b/app/src/main/java/com/example/livingai/pages/listings/ListingsScreen.kt index 2e03230..f8eadf6 100644 --- a/app/src/main/java/com/example/livingai/pages/listings/ListingsScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/listings/ListingsScreen.kt @@ -1,15 +1,38 @@ package com.example.livingai.pages.listings import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.example.livingai.R @@ -17,7 +40,9 @@ import com.example.livingai.domain.model.AnimalProfile import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.components.AnimalProfileCard import com.example.livingai.pages.components.CommonScaffold +import com.example.livingai.pages.components.LabeledDropdown import com.example.livingai.pages.navigation.Route +import kotlinx.coroutines.flow.collectLatest import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -27,29 +52,159 @@ fun ListingsScreen( viewModel: ListingsViewModel = koinViewModel() ) { val animalProfiles: LazyPagingItems = viewModel.animalProfiles.collectAsLazyPagingItems() + val searchQuery by viewModel.searchQuery.collectAsState() + val selectedSpecies by viewModel.selectedSpecies.collectAsState() + val selectedBreed by viewModel.selectedBreed.collectAsState() + val minAge by viewModel.minAge.collectAsState() + val maxAge by viewModel.maxAge.collectAsState() + + val speciesList = stringArrayResource(id = R.array.species_list).toList() + val breedList = stringArrayResource(id = R.array.cow_breed_list).toList() + + // Listen for events from the ViewModel to refresh the list + LaunchedEffect(Unit) { + viewModel.events.collectLatest { event -> + when (event) { + is ListingsEvent.Refresh -> { + animalProfiles.refresh() + } + } + } + } + + // Listen for results from the AddProfileScreen + val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle + LaunchedEffect(savedStateHandle) { + if (savedStateHandle?.get("refresh_listings") == true) { + animalProfiles.refresh() + savedStateHandle.remove("refresh_listings") + } + } CommonScaffold( navController = navController, - title = stringResource(R.string.top_bar_listings) + title = stringResource(R.string.top_bar_listings), + showFab = true, + onFabClick = { navController.navigate(Route.AddProfileScreen()) } ) { innerPadding -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - contentPadding = PaddingValues(Dimentions.SMALL_PADDING_TEXT), - verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_TEXT) + .padding(innerPadding) + .padding(Dimentions.SMALL_PADDING_TEXT) ) { - items(count = animalProfiles.itemCount) { index -> - val item = animalProfiles[index] - item?.let { profile -> - AnimalProfileCard( - animalProfile = profile, - onEdit = { - navController.navigate(Route.AddProfileScreen(animalId = profile.animalId, loadEntry = true)) + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { viewModel.searchQuery.value = it }, + label = { Text("Search") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Dimentions.SMALL_PADDING_TEXT), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + singleLine = true + ) + + // Filters + Column( + verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT), + modifier = Modifier.padding(bottom = Dimentions.MEDIUM_PADDING) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT), + modifier = Modifier.fillMaxWidth() + ) { + // Species Filter + LabeledDropdown( + labelRes = R.string.label_species, + options = listOf("All") + speciesList, + selected = selectedSpecies ?: "All", + onSelected = { + viewModel.selectedSpecies.value = if (it == "All") null else it }, - onRate = { navController.navigate(Route.RatingScreen(animalId = profile.animalId)) }, - onDelete = { viewModel.deleteAnimalProfile(profile.animalId) } + modifier = Modifier.weight(1f) ) + + // Breed Filter + LabeledDropdown( + labelRes = R.string.label_breed, + options = listOf("All") + breedList, + selected = selectedBreed ?: "All", + onSelected = { + viewModel.selectedBreed.value = if (it == "All") null else it + }, + modifier = Modifier.weight(1f) + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT), + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = minAge, + onValueChange = { viewModel.minAge.value = it }, + label = { Text("Min Age") }, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number) + ) + OutlinedTextField( + value = maxAge, + onValueChange = { viewModel.maxAge.value = it }, + label = { Text("Max Age") }, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number) + ) + } + } + + // Handle loading and empty states + when (animalProfiles.loadState.refresh) { + is LoadState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is LoadState.NotLoading -> { + if (animalProfiles.itemCount == 0) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)) + Text(text = "No results found", style = MaterialTheme.typography.bodyLarge) + } + } + } else { + LazyColumn( + contentPadding = PaddingValues(bottom = Dimentions.SMALL_PADDING_TEXT), + verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_TEXT) + ) { + items(count = animalProfiles.itemCount) { index -> + val item = animalProfiles[index] + item?.let { profile -> + AnimalProfileCard( + animalProfile = profile, + onEdit = { + navController.navigate(Route.AddProfileScreen(animalId = profile.animalId, loadEntry = true)) + }, + onRate = { navController.navigate(Route.RatingScreen(animalId = profile.animalId)) }, + onDelete = { viewModel.deleteAnimalProfile(profile.animalId) } + ) + } + } + } + } + } + is LoadState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = "Error loading data", color = MaterialTheme.colorScheme.error) + } } } } diff --git a/app/src/main/java/com/example/livingai/pages/listings/ListingsViewModel.kt b/app/src/main/java/com/example/livingai/pages/listings/ListingsViewModel.kt index 50c1a6a..f1e53e9 100644 --- a/app/src/main/java/com/example/livingai/pages/listings/ListingsViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/listings/ListingsViewModel.kt @@ -3,18 +3,79 @@ package com.example.livingai.pages.listings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn +import androidx.paging.filter +import com.example.livingai.domain.model.AnimalProfile import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +sealed class ListingsEvent { + object Refresh : ListingsEvent() +} + class ListingsViewModel( private val profileListingUseCase: ProfileListingUseCase ) : ViewModel() { - val animalProfiles = profileListingUseCase.getAnimalProfiles().cachedIn(viewModelScope) + var searchQuery = MutableStateFlow("") + var selectedSpecies = MutableStateFlow(null) + var selectedBreed = MutableStateFlow(null) + var minAge = MutableStateFlow("") + var maxAge = MutableStateFlow("") + + private val _events = Channel() + val events = _events.receiveAsFlow() + + private val filters = combine( + searchQuery, + selectedSpecies, + selectedBreed, + minAge, + maxAge + ) { query, species, breed, minAgeStr, maxAgeStr -> + FilterState(query, species, breed, minAgeStr, maxAgeStr) + } + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + val animalProfiles: Flow> = filters.flatMapLatest { filter -> + profileListingUseCase.getAnimalProfiles().map { pagingData -> + val min = filter.minAge.toIntOrNull() ?: 0 + val max = filter.maxAge.toIntOrNull() ?: Int.MAX_VALUE + + pagingData.filter { profile: AnimalProfile -> + val matchesQuery = profile.name.contains(filter.query, ignoreCase = true) || + profile.breed.contains(filter.query, ignoreCase = true) + + val matchesSpecies = filter.species == null || profile.species.equals(filter.species, ignoreCase = true) + val matchesBreed = filter.breed == null || profile.breed.equals(filter.breed, ignoreCase = true) + + val matchesAge = profile.age in min..max + + matchesQuery && matchesSpecies && matchesBreed && matchesAge + } + } + }.cachedIn(viewModelScope) fun deleteAnimalProfile(animalId: String) { viewModelScope.launch { profileListingUseCase.deleteAnimalProfile(animalId) + _events.send(ListingsEvent.Refresh) } } + + private data class FilterState( + val query: String, + val species: String?, + val breed: String?, + val minAge: String, + val maxAge: String + ) } diff --git a/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt b/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt index 10f5d04..e586682 100644 --- a/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/livingai/pages/navigation/NavGraph.kt @@ -19,6 +19,7 @@ import com.example.livingai.pages.listings.ListingsScreen import com.example.livingai.pages.onboarding.OnBoardingScreen import com.example.livingai.pages.onboarding.OnBoardingViewModel import com.example.livingai.pages.ratings.RatingScreen +import com.example.livingai.pages.ratings.RatingViewModel import com.example.livingai.pages.settings.SettingsScreen import org.koin.androidx.compose.koinViewModel @@ -56,12 +57,9 @@ fun NavGraph( val route: Route.AddProfileScreen = backStackEntry.toRoute() val viewModel: AddProfileViewModel = koinViewModel() val currentId by viewModel.currentAnimalId + val videoUri by viewModel.videoUri - LaunchedEffect(route.animalId, route.loadEntry) { - if (route.loadEntry) { - viewModel.loadAnimalDetails(route.animalId) - } - } + // Note: initialization is handled in ViewModel init block using SavedStateHandle // Handle new media from saved state handle val newImageUri = backStackEntry.savedStateHandle.get("newImageUri") @@ -89,8 +87,7 @@ fun NavGraph( viewModel = viewModel, onSave = { viewModel.saveAnimalDetails() - navController.previousBackStackEntry?.savedStateHandle?.set("refresh_listings", true) - navController.popBackStack() + navController.popBackStack(Route.HomeScreen, inclusive = false) }, onCancel = { navController.popBackStack() }, onTakePhoto = { orientation -> @@ -107,12 +104,23 @@ fun NavGraph( navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown")) } }, - onTakeVideo = { navController.navigate(Route.VideoRecordScreen(animalId = currentId ?: "unknown")) } + onTakeVideo = { + if (videoUri != null) { + navController.navigate(Route.ViewVideoScreen( + videoUri = videoUri!!, + shouldAllowRetake = true, + animalId = currentId ?: "unknown" + )) + } else { + navController.navigate(Route.VideoRecordScreen(animalId = currentId ?: "unknown")) + } + } ) } composable { - RatingScreen() + val viewModel: RatingViewModel = koinViewModel() + RatingScreen(viewModel = viewModel, navController = navController) } composable { diff --git a/app/src/main/java/com/example/livingai/pages/ratings/RatingScreen.kt b/app/src/main/java/com/example/livingai/pages/ratings/RatingScreen.kt index e5c6b2c..967ccba 100644 --- a/app/src/main/java/com/example/livingai/pages/ratings/RatingScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/ratings/RatingScreen.kt @@ -1,14 +1,14 @@ package com.example.livingai.pages.ratings import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -17,126 +17,144 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import com.example.livingai.R +import com.example.livingai.domain.model.AnimalRating import com.example.livingai.pages.commons.Dimentions -import com.example.livingai.pages.components.ImageThumbnailButton import com.example.livingai.pages.components.RatingScale -import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun RatingScreen( - navController: NavController? = null, // Nullable for preview or if passed from NavGraph - viewModel: RatingViewModel = koinViewModel() + viewModel: RatingViewModel, + navController: NavController? = null ) { val ratingState by viewModel.ratingState.collectAsState() - val animalImages by viewModel.animalImages.collectAsState() - Scaffold( - topBar = { - // You might want a TopAppBar here, reusing CommonScaffold or similar - Text( - text = stringResource(id = R.string.top_bar_ratings), - modifier = Modifier.padding(Dimentions.MEDIUM_PADDING) - ) + if (ratingState == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } - ) { innerPadding -> - ratingState?.let { rating -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = Dimentions.SMALL_PADDING_TEXT), - verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING) - ) { - // 1. Image Thumbnail - item { - if (animalImages.isNotEmpty()) { - ImageThumbnailButton( - image = animalImages.firstOrNull(), - onClick = { /* TODO: Handle click */ } - ) + return + } + + var rating = ratingState!! + + // Define rating fields declaratively to avoid repeated code + val ratingFields = remember { + listOf( + RatingField( + labelRes = R.string.label_overall_rating, + getter = { r -> r.overallRating }, + updater = { r, v -> r.copy(overallRating = v) } + ), + RatingField( + labelRes = R.string.label_health_rating, + getter = { r -> r.healthRating }, + updater = { r, v -> r.copy(healthRating = v) } + ), + RatingField( + labelRes = R.string.label_breed_rating, + getter = { r -> r.breedRating }, + updater = { r, v -> r.copy(breedRating = v) } + ), + + RatingField(R.string.label_stature, { r -> r.stature }, { r, v -> r.copy(stature = v) }), + RatingField(R.string.label_chest_width, { r -> r.chestWidth }, { r, v -> r.copy(chestWidth = v) }), + RatingField(R.string.label_body_depth, { r -> r.bodyDepth }, { r, v -> r.copy(bodyDepth = v) }), + RatingField(R.string.label_angularity, { r -> r.angularity }, { r, v -> r.copy(angularity = v) }), + RatingField(R.string.label_rump_angle, { r -> r.rumpAngle }, { r, v -> r.copy(rumpAngle = v) }), + RatingField(R.string.label_rump_width, { r -> r.rumpWidth }, { r, v -> r.copy(rumpWidth = v) }), + RatingField(R.string.label_rear_leg_set, { r -> r.rearLegSet }, { r, v -> r.copy(rearLegSet = v) }), + RatingField(R.string.label_rear_leg_rear_view, { r -> r.rearLegRearView }, { r, v -> r.copy(rearLegRearView = v) }), + RatingField(R.string.label_foot_angle, { r -> r.footAngle }, { r, v -> r.copy(footAngle = v) }), + + RatingField(R.string.label_fore_udder_attachment, { r -> r.foreUdderAttachment }, { r, v -> r.copy(foreUdderAttachment = v) }), + RatingField(R.string.label_rear_udder_height, { r -> r.rearUdderHeight }, { r, v -> r.copy(rearUdderHeight = v) }), + RatingField(R.string.label_central_ligament, { r -> r.centralLigament }, { r, v -> r.copy(centralLigament = v) }), + RatingField(R.string.label_udder_depth, { r -> r.udderDepth }, { r, v -> r.copy(udderDepth = v) }), + RatingField(R.string.label_front_teat_position, { r -> r.frontTeatPosition }, { r, v -> r.copy(frontTeatPosition = v) }), + RatingField(R.string.label_teat_length, { r -> r.teatLength }, { r, v -> r.copy(teatLength = v) }), + RatingField(R.string.label_rear_teat_position, { r -> r.rearTeatPosition }, { r, v -> r.copy(rearTeatPosition = v) }), + + RatingField(R.string.label_locomotion, { r -> r.locomotion }, { r, v -> r.copy(locomotion = v) }), + RatingField(R.string.label_body_condition_score, { r -> r.bodyConditionScore }, { r, v -> r.copy(bodyConditionScore = v) }), + RatingField(R.string.label_hock_development, { r -> r.hockDevelopment }, { r, v -> r.copy(hockDevelopment = v) }), + RatingField(R.string.label_bone_structure, { r -> r.boneStructure }, { r, v -> r.copy(boneStructure = v) }), + RatingField(R.string.label_rear_udder_width, { r -> r.rearUdderWidth }, { r, v -> r.copy(rearUdderWidth = v) }), + RatingField(R.string.label_teat_thickness, { r -> r.teatThickness }, { r, v -> r.copy(teatThickness = v) }), + RatingField(R.string.label_muscularity, { r -> r.muscularity }, { r, v -> r.copy(muscularity = v) }) + ) + } + + Scaffold { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + item { + OutlinedTextField( + value = rating.bodyConditionComments, + onValueChange = { + rating = rating.copy(bodyConditionComments = it) + viewModel.onRatingChange(rating) + }, + label = { Text(stringResource(id = R.string.label_rating_comments)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + } + + items(ratingFields.size) { idx -> + val field = ratingFields[idx] + RatingScale( + label = field.labelRes, + value = field.getter(rating), + maxValue = 10, + onValueChange = { newVal -> + rating = field.updater(rating, newVal) + viewModel.onRatingChange(rating) } - } + ) + } - // 2. Description / Comments Box - item { - OutlinedTextField( - value = rating.bodyConditionComments, - onValueChange = { viewModel.onRatingChange(rating.copy(bodyConditionComments = it)) }, - label = { Text(stringResource(id = R.string.label_rating_comments)) }, - modifier = Modifier.fillMaxWidth(), - minLines = 3 - ) - } - - // 3. Rating Components - item { RatingScale(R.string.label_overall_rating, rating.overallRating, 10) { viewModel.onRatingChange(rating.copy(overallRating = it)) } } - item { RatingScale(R.string.label_health_rating, rating.healthRating, 10) { viewModel.onRatingChange(rating.copy(healthRating = it)) } } - item { RatingScale(R.string.label_breed_rating, rating.breedRating, 10) { viewModel.onRatingChange(rating.copy(breedRating = it)) } } - - // Physical Attributes - item { RatingScale(R.string.label_stature, rating.stature, 10) { viewModel.onRatingChange(rating.copy(stature = it)) } } - item { RatingScale(R.string.label_chest_width, rating.chestWidth, 10) { viewModel.onRatingChange(rating.copy(chestWidth = it)) } } - item { RatingScale(R.string.label_body_depth, rating.bodyDepth, 10) { viewModel.onRatingChange(rating.copy(bodyDepth = it)) } } - item { RatingScale(R.string.label_angularity, rating.angularity, 10) { viewModel.onRatingChange(rating.copy(angularity = it)) } } - item { RatingScale(R.string.label_rump_angle, rating.rumpAngle, 10) { viewModel.onRatingChange(rating.copy(rumpAngle = it)) } } - item { RatingScale(R.string.label_rump_width, rating.rumpWidth, 10) { viewModel.onRatingChange(rating.copy(rumpWidth = it)) } } - item { RatingScale(R.string.label_rear_leg_set, rating.rearLegSet, 10) { viewModel.onRatingChange(rating.copy(rearLegSet = it)) } } - item { RatingScale(R.string.label_rear_leg_rear_view, rating.rearLegRearView, 10) { viewModel.onRatingChange(rating.copy(rearLegRearView = it)) } } - item { RatingScale(R.string.label_foot_angle, rating.footAngle, 10) { viewModel.onRatingChange(rating.copy(footAngle = it)) } } - - // Udder Attributes - item { RatingScale(R.string.label_fore_udder_attachment, rating.foreUdderAttachment, 10) { viewModel.onRatingChange(rating.copy(foreUdderAttachment = it)) } } - item { RatingScale(R.string.label_rear_udder_height, rating.rearUdderHeight, 10) { viewModel.onRatingChange(rating.copy(rearUdderHeight = it)) } } - item { RatingScale(R.string.label_central_ligament, rating.centralLigament, 10) { viewModel.onRatingChange(rating.copy(centralLigament = it)) } } - item { RatingScale(R.string.label_udder_depth, rating.udderDepth, 10) { viewModel.onRatingChange(rating.copy(udderDepth = it)) } } - item { RatingScale(R.string.label_front_teat_position, rating.frontTeatPosition, 10) { viewModel.onRatingChange(rating.copy(frontTeatPosition = it)) } } - item { RatingScale(R.string.label_teat_length, rating.teatLength, 10) { viewModel.onRatingChange(rating.copy(teatLength = it)) } } - item { RatingScale(R.string.label_rear_teat_position, rating.rearTeatPosition, 10) { viewModel.onRatingChange(rating.copy(rearTeatPosition = it)) } } - item { RatingScale(R.string.label_rear_udder_width, rating.rearUdderWidth, 10) { viewModel.onRatingChange(rating.copy(rearUdderWidth = it)) } } - item { RatingScale(R.string.label_teat_thickness, rating.teatThickness, 10) { viewModel.onRatingChange(rating.copy(teatThickness = it)) } } - - // Other Attributes - item { RatingScale(R.string.label_locomotion, rating.locomotion, 10) { viewModel.onRatingChange(rating.copy(locomotion = it)) } } - item { RatingScale(R.string.label_body_condition_score, rating.bodyConditionScore, 10) { viewModel.onRatingChange(rating.copy(bodyConditionScore = it)) } } - item { RatingScale(R.string.label_hock_development, rating.hockDevelopment, 10) { viewModel.onRatingChange(rating.copy(hockDevelopment = it)) } } - item { RatingScale(R.string.label_bone_structure, rating.boneStructure, 10) { viewModel.onRatingChange(rating.copy(boneStructure = it)) } } - item { RatingScale(R.string.label_muscularity, rating.muscularity, 10) { viewModel.onRatingChange(rating.copy(muscularity = it)) } } - - // 4. Buttons - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = Dimentions.MEDIUM_PADDING), - horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING) + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = Dimentions.MEDIUM_PADDING), + horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { navController?.popBackStack() }, + modifier = Modifier.weight(1f) ) { - OutlinedButton( - onClick = { navController?.popBackStack() }, - modifier = Modifier.weight(1f) - ) { - Text(text = stringResource(id = R.string.btn_cancel)) - } - Button( - onClick = { - viewModel.saveRatings() - navController?.popBackStack() - }, - modifier = Modifier.weight(1f) - ) { - Text(text = stringResource(id = R.string.btn_save_ratings)) - } + Text(text = stringResource(id = R.string.btn_cancel)) + } + + Button( + onClick = { + viewModel.saveRatings() + navController?.popBackStack() + }, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(id = R.string.btn_save_ratings)) } - } - - item { - Spacer(modifier = Modifier.height(Dimentions.LARGE_PADDING)) } } } } } + +private data class RatingField( + val labelRes: Int, + val getter: (AnimalRating) -> Int, + val updater: (AnimalRating, Int) -> AnimalRating +) diff --git a/app/src/main/java/com/example/livingai/pages/settings/SettingsScreen.kt b/app/src/main/java/com/example/livingai/pages/settings/SettingsScreen.kt index a0b16a3..de5edd0 100644 --- a/app/src/main/java/com/example/livingai/pages/settings/SettingsScreen.kt +++ b/app/src/main/java/com/example/livingai/pages/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.example.livingai.pages.settings +import android.app.Activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,13 +15,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import com.example.livingai.R import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.components.CommonScaffold -import com.example.livingai.pages.components.RadioGroup +import com.example.livingai.pages.components.LabeledDropdown import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -30,6 +32,8 @@ fun SettingsScreen( viewModel: SettingsViewModel = koinViewModel() ) { val settings by viewModel.settings.collectAsState() + val context = LocalContext.current + val languageEntries = stringArrayResource(id = R.array.language_entries) val languageValues = stringArrayResource(id = R.array.language_values) val languageMap = languageEntries.zip(languageValues).toMap() @@ -47,13 +51,17 @@ fun SettingsScreen( .padding(it) .padding(Dimentions.SMALL_PADDING_TEXT) ) { - RadioGroup( - titleRes = R.string.language, + LabeledDropdown( + labelRes = R.string.language, options = languageEntries.toList(), selected = selectedLanguageEntry, onSelected = { selectedEntry -> val selectedValue = languageMap[selectedEntry] ?: languageValues.first() viewModel.saveSettings(settings.copy(language = selectedValue)) + + // Restart activity to apply language change + val activity = context as? Activity + activity?.recreate() } ) diff --git a/app/src/main/java/com/example/livingai/pages/settings/SettingsViewModel.kt b/app/src/main/java/com/example/livingai/pages/settings/SettingsViewModel.kt index d8999e7..5474798 100644 --- a/app/src/main/java/com/example/livingai/pages/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/example/livingai/pages/settings/SettingsViewModel.kt @@ -1,22 +1,31 @@ package com.example.livingai.pages.settings +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.livingai.data.local.model.SettingsData import com.example.livingai.domain.repository.SettingsRepository +import com.example.livingai.utils.LocaleHelper import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() { +class SettingsViewModel( + private val settingsRepository: SettingsRepository, + private val appContext: Context +) : ViewModel() { private val _settings = MutableStateFlow(SettingsData("en", false)) val settings = _settings.asStateFlow() init { getSettings() + // apply locale initially + settingsRepository.getSettings().onEach { + LocaleHelper.applyLocale(appContext, it.language) + }.launchIn(viewModelScope) } private fun getSettings() { @@ -28,6 +37,8 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi fun saveSettings(settings: SettingsData) { viewModelScope.launch { settingsRepository.saveSettings(settings) + // apply locale immediately + LocaleHelper.applyLocale(appContext, settings.language) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/livingai/utils/CoroutineDispatchers.kt b/app/src/main/java/com/example/livingai/utils/CoroutineDispatchers.kt new file mode 100644 index 0000000..79ba290 --- /dev/null +++ b/app/src/main/java/com/example/livingai/utils/CoroutineDispatchers.kt @@ -0,0 +1,16 @@ +package com.example.livingai.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +interface CoroutineDispatchers { + val io: CoroutineDispatcher + val main: CoroutineDispatcher + val default: CoroutineDispatcher +} + +class DefaultCoroutineDispatchers : CoroutineDispatchers { + override val io = Dispatchers.IO + override val main = Dispatchers.Main + override val default = Dispatchers.Default +} diff --git a/app/src/main/java/com/example/livingai/utils/LocaleHelper.kt b/app/src/main/java/com/example/livingai/utils/LocaleHelper.kt new file mode 100644 index 0000000..927ceb4 --- /dev/null +++ b/app/src/main/java/com/example/livingai/utils/LocaleHelper.kt @@ -0,0 +1,24 @@ +package com.example.livingai.utils + +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import java.util.Locale + +object LocaleHelper { + fun applyLocale(context: Context, language: String): Context { + val locale = Locale(language) + Locale.setDefault(locale) + + val config = Configuration(context.resources.configuration) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + config.setLocale(locale) + return context.createConfigurationContext(config) + } else { + config.locale = locale + @Suppress("DEPRECATION") + context.resources.updateConfiguration(config, context.resources.displayMetrics) + return context + } + } +} diff --git a/app/src/main/java/com/example/livingai/utils/ScreenOrientation.kt b/app/src/main/java/com/example/livingai/utils/ScreenOrientation.kt new file mode 100644 index 0000000..371e891 --- /dev/null +++ b/app/src/main/java/com/example/livingai/utils/ScreenOrientation.kt @@ -0,0 +1,20 @@ +package com.example.livingai.utils + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +@Composable +fun SetScreenOrientation(orientation: Int) { + val context = LocalContext.current + DisposableEffect(Unit) { + val activity = context as Activity + val originalOrientation = activity.requestedOrientation + activity.requestedOrientation = orientation + onDispose { + // restore original orientation when view disappears + activity.requestedOrientation = originalOrientation + } + } +} diff --git a/app/src/main/java/com/example/livingai/utils/SilhouetteManager.kt b/app/src/main/java/com/example/livingai/utils/SilhouetteManager.kt new file mode 100644 index 0000000..edf049f --- /dev/null +++ b/app/src/main/java/com/example/livingai/utils/SilhouetteManager.kt @@ -0,0 +1,77 @@ +package com.example.livingai.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.annotation.DrawableRes +import androidx.core.graphics.createBitmap +import java.util.concurrent.ConcurrentHashMap + +object SilhouetteManager { + private val originals = ConcurrentHashMap() + private val inverted = ConcurrentHashMap() + private val bitmasks = ConcurrentHashMap() + + fun initialize(context: Context, names: List) { + names.forEach { name -> + val resId = context.resources.getIdentifier(name, "drawable", context.packageName) + if (resId != 0) { + val bmp = BitmapFactory.decodeResource(context.resources, resId) + originals[name] = bmp + val inv = invertBitmap(bmp) + inverted[name] = inv + bitmasks[name] = createBitmask(inv) + } + } + } + + fun getOriginal(name: String): Bitmap? = originals[name] + fun getInverted(name: String): Bitmap? = inverted[name] + fun getBitmask(name: String): BooleanArray? = bitmasks[name] + + private fun invertBitmap(src: Bitmap): Bitmap { + val bmOut = Bitmap.createBitmap(src.width, src.height, src.config ?: Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmOut) + val paint = Paint() + val colorMatrix = android.graphics.ColorMatrix( + floatArrayOf( + -1f, 0f, 0f, 0f, 255f, + 0f, -1f, 0f, 0f, 255f, + 0f, 0f, -1f, 0f, 255f, + 0f, 0f, 0f, 1f, 0f + ) + ) + paint.colorFilter = android.graphics.ColorMatrixColorFilter(colorMatrix) + canvas.drawBitmap(src, 0f, 0f, paint) + return bmOut + } + + private fun createBitmask(bitmap: Bitmap): BooleanArray { + val w = bitmap.width + val h = bitmap.height + val mask = BooleanArray(w * h) + val pixels = IntArray(w * h) + bitmap.getPixels(pixels, 0, w, 0, 0, w, h) + for (i in pixels.indices) { + val c = pixels[i] + val alpha = Color.alpha(c) + // simple threshold: non-transparent and not near-white (assuming inverted is black on transparent/white) + // The inverted logic makes black -> white, white -> black. + // Wait, if original is black silhouette on transparent: + // Inverted: black becomes white, transparent becomes... white? + // Let's check invertBitmap logic. + // Matrix: R' = 255 - R, G' = 255 - G, B' = 255 - B, A' = A. + // If original is Black (0,0,0,255) -> White (255,255,255,255). + // If original is Transparent (0,0,0,0) -> Transparent (0,0,0,0). + + // So "Inverted" means we have White silhouette on Transparent. + // We want bitmask where the silhouette is. + // So we look for non-transparent pixels. + mask[i] = alpha > 16 + } + return mask + } +} diff --git a/app/src/main/res/drawable/angleview_silhoutte.png b/app/src/main/res/drawable/angleview_silhoutte.png new file mode 100644 index 0000000..b58faef Binary files /dev/null and b/app/src/main/res/drawable/angleview_silhoutte.png differ diff --git a/app/src/main/res/drawable/back_silhoutte.png b/app/src/main/res/drawable/back_silhoutte.png new file mode 100644 index 0000000..1b629ca Binary files /dev/null and b/app/src/main/res/drawable/back_silhoutte.png differ diff --git a/app/src/main/res/drawable/front_silhoutte.png b/app/src/main/res/drawable/front_silhoutte.png new file mode 100644 index 0000000..6c42906 Binary files /dev/null and b/app/src/main/res/drawable/front_silhoutte.png differ diff --git a/app/src/main/res/drawable/left_silhoutte.png b/app/src/main/res/drawable/left_silhoutte.png new file mode 100644 index 0000000..d9b2b82 Binary files /dev/null and b/app/src/main/res/drawable/left_silhoutte.png differ diff --git a/app/src/main/res/drawable/leftangle_silhoutte.png b/app/src/main/res/drawable/leftangle_silhoutte.png new file mode 100644 index 0000000..13f021b Binary files /dev/null and b/app/src/main/res/drawable/leftangle_silhoutte.png differ diff --git a/app/src/main/res/drawable/right_silhoutte.png b/app/src/main/res/drawable/right_silhoutte.png new file mode 100644 index 0000000..e974e7a Binary files /dev/null and b/app/src/main/res/drawable/right_silhoutte.png differ diff --git a/app/src/main/res/drawable/rightangle_silhoutte.png b/app/src/main/res/drawable/rightangle_silhoutte.png new file mode 100644 index 0000000..8b56c0c Binary files /dev/null and b/app/src/main/res/drawable/rightangle_silhoutte.png differ