updated features

This commit is contained in:
SaiD 2025-12-04 13:10:49 +05:30
parent e0d55e3087
commit ea5d7109d4
35 changed files with 1024 additions and 291 deletions

View File

@ -86,6 +86,7 @@ dependencies {
// Coil // Coil
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0") implementation("io.coil-kt:coil-video:2.5.0")
implementation("io.coil-kt:coil-svg:2.5.0")
// Paging // Paging
implementation("androidx.paging:paging-runtime:3.2.1") implementation("androidx.paging:paging-runtime:3.2.1")

View File

@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
@ -15,11 +16,12 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.example.livingai.pages.home.HomeViewModel import com.example.livingai.pages.home.HomeViewModel
import com.example.livingai.pages.navigation.NavGraph import com.example.livingai.pages.navigation.NavGraph
import com.example.livingai.pages.navigation.Route
import com.example.livingai.ui.theme.LivingAITheme import com.example.livingai.ui.theme.LivingAITheme
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
val viewModel by viewModel<HomeViewModel>() private val viewModel by viewModel<HomeViewModel>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -29,6 +31,10 @@ class MainActivity : ComponentActivity() {
viewModel.splashCondition.value 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 { setContent {
LivingAITheme { LivingAITheme {
enableEdgeToEdge( enableEdgeToEdge(
@ -43,7 +49,13 @@ class MainActivity : ComponentActivity() {
) )
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) { Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
val startDestination = viewModel.startDestination.value val startDestination = viewModel.startDestination.value
// Ensure startDestination is not null before rendering NavGraph
if (startDestination != null) {
NavGraph(startDestination = startDestination) NavGraph(startDestination = startDestination)
} else {
// Optional: Show a loading indicator if startDestination is null
// for an extended period, though splash screen should handle it.
}
} }
} }
} }

View File

@ -23,7 +23,8 @@ import java.io.*
class CSVDataSource( class CSVDataSource(
private val context: Context, private val context: Context,
private val fileName: String private val fileName: String,
private val dispatchers: com.example.livingai.utils.CoroutineDispatchers
) : DataSource { ) : DataSource {
private val folderName = "LivingAI" private val folderName = "LivingAI"
@ -32,7 +33,7 @@ class CSVDataSource(
private var cachedUri: Uri? = null 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 } cachedUri?.let { return@withContext it }
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@ -105,7 +106,7 @@ class CSVDataSource(
private suspend fun readAllLines(): List<Array<String>> = mutex.withLock { private suspend fun readAllLines(): List<Array<String>> = mutex.withLock {
val uri = getCsvUri() val uri = getCsvUri()
return withContext(Dispatchers.IO) { return withContext(dispatchers.io) {
try { try {
context.contentResolver.openInputStream(uri)?.use { input -> context.contentResolver.openInputStream(uri)?.use { input ->
val reader = CSVReader(InputStreamReader(input)) val reader = CSVReader(InputStreamReader(input))
@ -123,7 +124,7 @@ class CSVDataSource(
private suspend fun writeAllLines(lines: List<Array<String>>) = mutex.withLock { private suspend fun writeAllLines(lines: List<Array<String>>) = mutex.withLock {
val uri = getCsvUri() val uri = getCsvUri()
withContext(Dispatchers.IO) { withContext(dispatchers.io) {
try { try {
context.contentResolver.openOutputStream(uri, "wt")?.use { out -> context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
val writer = CSVWriter(OutputStreamWriter(out)) val writer = CSVWriter(OutputStreamWriter(out))

View File

@ -1,11 +1,90 @@
package com.example.livingai.data.ml package com.example.livingai.data.ml
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color
import com.example.livingai.domain.ml.AIModel 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 { class AIModelImpl : AIModel {
private val segmenter by lazy {
val options = SubjectSegmenterOptions.Builder()
.enableForegroundBitmap()
.build()
SubjectSegmentation.getClient(options)
}
override fun deriveInference(bitmap: Bitmap): String { override fun deriveInference(bitmap: Bitmap): String {
// Placeholder for actual inference logic
return "Inference Result" return "Inference Result"
} }
override suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>? {
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()
}
} }

View File

@ -4,6 +4,8 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import coil.ImageLoader
import coil.decode.SvgDecoder
import com.example.livingai.data.local.CSVDataSource import com.example.livingai.data.local.CSVDataSource
import com.example.livingai.data.manager.LocalUserManagerImpl import com.example.livingai.data.manager.LocalUserManagerImpl
import com.example.livingai.data.ml.AIModelImpl 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.ratings.RatingViewModel
import com.example.livingai.pages.settings.SettingsViewModel import com.example.livingai.pages.settings.SettingsViewModel
import com.example.livingai.utils.Constants 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.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
@ -59,14 +64,35 @@ val appModule = module {
) )
} }
// Coroutine dispatchers (for testability)
single<CoroutineDispatchers> { DefaultCoroutineDispatchers() }
// Data Source // Data Source
single<DataSource> { single<DataSource> {
CSVDataSource( CSVDataSource(
context = androidContext(), 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 // ML Model
single<AIModel> { AIModelImpl() } single<AIModel> { AIModelImpl() }
@ -104,10 +130,12 @@ val appModule = module {
// ViewModels // ViewModels
viewModel { HomeViewModel(get()) } viewModel { HomeViewModel(get()) }
viewModel { OnBoardingViewModel(get(), get()) } viewModel { OnBoardingViewModel(get(), get()) }
viewModel { AddProfileViewModel(get()) } viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) ->
AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle)
}
viewModel { ListingsViewModel(get()) } viewModel { ListingsViewModel(get()) }
viewModel { SettingsViewModel(get()) } viewModel { SettingsViewModel(get(), androidContext()) }
viewModel { RatingViewModel(get(), get(), get(), get()) } viewModel { RatingViewModel(get(), get(), get(), get()) }
viewModel { CameraViewModel(get()) } viewModel { CameraViewModel(get(), get()) }
viewModel { VideoViewModel() } viewModel { VideoViewModel() }
} }

View File

@ -4,4 +4,5 @@ import android.graphics.Bitmap
interface AIModel { interface AIModel {
fun deriveInference(bitmap: Bitmap): String fun deriveInference(bitmap: Bitmap): String
suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>?
} }

View File

@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -50,6 +51,12 @@ fun AddProfileScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
// If opened for edit, attempt to load existing animal details
LaunchedEffect(Unit) {
val existing = viewModel.savedStateHandle?.get<String>("animalId")
if (existing != null) viewModel.loadAnimal(existing)
}
val speciesList = stringArrayResource(id = R.array.species_list).toList() val speciesList = stringArrayResource(id = R.array.species_list).toList()
val breedList = stringArrayResource(id = R.array.cow_breed_list).toList() val breedList = stringArrayResource(id = R.array.cow_breed_list).toList()
@ -59,10 +66,9 @@ fun AddProfileScreen(
stringResource(R.string.option_none) 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) val resId = context.resources.getIdentifier("label_${item}", "string", context.packageName)
if (resId != 0) resId else R.string.default_orientation_label
item to if (resId != 0) resId else R.string.default_orientation_label
} }
// Use ViewModel state // Use ViewModel state
@ -172,7 +178,7 @@ fun AddProfileScreen(
// Video Button // Video Button
item { item {
VideoThumbnailButton( VideoThumbnailButton(
videoSource = videoUri, // Removed videoThumbnail fallback as it's not in VM videoSource = videoUri,
onClick = onTakeVideo onClick = onTakeVideo
) )
} }

View File

@ -1,19 +1,30 @@
package com.example.livingai.pages.addprofile 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.State
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.model.AnimalDetails 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.domain.usecases.ProfileEntry.ProfileEntryUseCase
import com.example.livingai.utils.CoroutineDispatchers
import com.example.livingai.utils.IdGenerator import com.example.livingai.utils.IdGenerator
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AddProfileViewModel( 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() { ) : ViewModel() {
private val _animalDetails = mutableStateOf<AnimalDetails?>(null) private val _animalDetails = mutableStateOf<AnimalDetails?>(null)
@ -36,7 +47,7 @@ class AddProfileViewModel(
private val _videoUri = mutableStateOf<String?>(null) private val _videoUri = mutableStateOf<String?>(null)
val videoUri: State<String?> = _videoUri val videoUri: State<String?> = _videoUri
fun loadAnimalDetails(animalId: String?) { fun loadAnimal(animalId: String?) {
if (animalId == null) { if (animalId == null) {
val newId = IdGenerator.generateAnimalId() val newId = IdGenerator.generateAnimalId()
_currentAnimalId.value = newId _currentAnimalId.value = newId
@ -70,14 +81,21 @@ class AddProfileViewModel(
// Populate photos // Populate photos
photos.clear() photos.clear()
// Process images on IO thread as it may involve DB queries
withContext(dispatchers.io) {
val photoMap = mutableMapOf<String, String>()
details.images.forEach { path -> details.images.forEach { path ->
// path: .../{id}_{orientation}.jpg val uri = Uri.parse(path)
val filename = path.substringAfterLast('/') val filename = getFileName(uri) ?: path.substringAfterLast('/')
val nameWithoutExt = filename.substringBeforeLast('.') val nameWithoutExt = filename.substringBeforeLast('.')
val parts = nameWithoutExt.split('_') val parts = nameWithoutExt.split('_')
if (parts.size >= 2) { if (parts.size >= 2) {
val orientation = parts.last() val orientation = parts.last()
photos[orientation] = path photoMap[orientation] = path
}
}
withContext(dispatchers.main) {
photoMap.forEach { (k, v) -> photos[k] = v }
} }
} }
_videoUri.value = details.video.ifBlank { null } _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) { fun addPhoto(orientation: String, uri: String) {
photos[orientation] = uri photos[orientation] = uri
} }
@ -115,4 +160,11 @@ class AddProfileViewModel(
profileEntryUseCase.setAnimalDetails(details) profileEntryUseCase.setAnimalDetails(details)
} }
} }
init {
// Try to auto-load when editing via saved state handle
val animalId: String? = savedStateHandle?.get<String>("animalId")
// Call loadAnimal unconditionally to ensure ID generation for new profiles
loadAnimal(animalId)
}
} }

View File

@ -1,9 +1,11 @@
package com.example.livingai.pages.camera package com.example.livingai.pages.camera
import android.content.pm.ActivityInfo
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController import androidx.camera.view.LifecycleCameraController
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -25,14 +27,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import org.koin.androidx.compose.koinViewModel
import com.example.livingai.pages.components.CameraPreview import com.example.livingai.pages.components.CameraPreview
import com.example.livingai.pages.components.PermissionWrapper import com.example.livingai.pages.components.PermissionWrapper
import com.example.livingai.pages.navigation.Route 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 @Composable
fun CameraScreen( fun CameraScreen(
@ -41,6 +47,12 @@ fun CameraScreen(
orientation: String? = null, orientation: String? = null,
animalId: String animalId: String
) { ) {
val orientationLock = when (orientation) {
"front", "back" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
SetScreenOrientation(orientationLock)
PermissionWrapper { PermissionWrapper {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
@ -99,25 +111,61 @@ fun CameraScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
controller = controller, controller = controller,
onFrame = { bitmap -> onFrame = { bitmap, rotation ->
if (state.isAutoCaptureOn) { if (orientation != null) {
viewModel.onEvent(CameraEvent.FrameReceived(bitmap)) 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("Auto Capture") 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( Switch(
checked = state.isAutoCaptureOn, checked = state.isAutoCaptureOn,
onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) } onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) }
@ -127,3 +175,4 @@ fun CameraScreen(
} }
} }
} }
}

View File

@ -1,87 +1,126 @@
package com.example.livingai.pages.camera package com.example.livingai.pages.camera
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix
import android.net.Uri import android.net.Uri
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.domain.repository.CameraRepository
import com.example.livingai.utils.SilhouetteManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class CameraViewModel( class CameraViewModel(
private val cameraRepository: CameraRepository private val cameraRepository: CameraRepository,
private val aiModel: AIModel
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(CameraState()) private val _state = MutableStateFlow(CameraUiState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var currentAnimalId: String = "" // mutex to prevent parallel captures
private var currentOrientation: String? = null private val captureMutex = Mutex()
fun onEvent(event: CameraEvent) { fun onEvent(event: CameraEvent) {
when (event) { when (event) {
is CameraEvent.CaptureImage -> { is CameraEvent.CaptureImage -> captureImage()
// Signal UI to capture? Or handle if passed image 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)
} }
is CameraEvent.ImageCaptured -> {
saveImage(event.imageProxy)
} }
is CameraEvent.FrameReceived -> {
processFrame(event.bitmap) private fun setContext(animalId: String, orientation: String?) {
_state.value = _state.value.copy(animalId = animalId, orientation = orientation)
} }
is CameraEvent.ToggleAutoCapture -> {
private fun toggleAutoCapture() {
_state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn) _state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn)
} }
is CameraEvent.ClearCapturedImage -> {
_state.value = _state.value.copy(capturedImage = null, capturedImageUri = null) private fun clearCaptured() {
} _state.value = _state.value.copy(capturedImage = null, capturedImageUri = null, segmentationMask = null)
is CameraEvent.SetContext -> {
currentAnimalId = event.animalId
currentOrientation = event.orientation
}
}
} }
private fun saveImage(imageProxy: ImageProxy) { 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 { viewModelScope.launch {
try { val bitmap = cameraRepository.captureImage(proxy)
val bitmap = cameraRepository.captureImage(imageProxy) val animalId = _state.value.animalId ?: "unknown"
val uriString = cameraRepository.saveImage(bitmap, currentAnimalId, currentOrientation) val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation)
_state.value = _state.value.copy(capturedImage = bitmap, capturedImageUri = Uri.parse(uriString)) _state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString))
} catch (e: Exception) {
// Handle error
imageProxy.close() // Ensure closed on error if repository didn't
}
} }
} }
private fun processFrame(bitmap: Bitmap) { private fun handleFrame(bitmap: Bitmap, rotationDegrees: Int) {
val orientation = _state.value.orientation ?: return
viewModelScope.launch { viewModelScope.launch {
if (captureMutex.tryLock()) {
try { try {
val result = cameraRepository.processFrame(bitmap) val result = aiModel.segmentImage(bitmap)
_state.value = _state.value.copy(inferenceResult = result) if (result != null) {
} catch (e: Exception) { val (maskBitmap, maskBool) = result
// Handle error
// 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( data class CameraUiState(
val isLoading: Boolean = false, val animalId: String? = null,
val isCameraReady: Boolean = false, val orientation: String? = null,
val capturedImage: Any? = null, val capturedImage: Any? = null,
val capturedImageUri: Uri? = null, val capturedImageUri: Uri? = null,
val inferenceResult: String? = null, val inferenceResult: String? = null,
val isAutoCaptureOn: Boolean = false val isAutoCaptureOn: Boolean = false,
val segmentationMask: Bitmap? = null
) )
sealed class CameraEvent { sealed class CameraEvent {
object CaptureImage : CameraEvent() object CaptureImage : CameraEvent()
data class ImageCaptured(val imageProxy: ImageProxy) : 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 ToggleAutoCapture : CameraEvent()
object ClearCapturedImage : CameraEvent() object ClearCapturedImage : CameraEvent()
data class SetContext(val animalId: String, val orientation: String?) : CameraEvent() data class SetContext(val animalId: String, val orientation: String?) : CameraEvent()

View File

@ -135,7 +135,7 @@ fun VideoRecordScreen(
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
controller = controller, controller = controller,
onFrame = {} onFrame = { _, _ -> }
) )
} }
} }

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.material.icons.filled.PlayArrow
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.example.livingai.ui.theme.LivingAITheme import com.example.livingai.ui.theme.LivingAITheme
import kotlinx.coroutines.delay
@Composable @Composable
fun ViewVideoScreen( fun ViewVideoScreen(
@ -43,9 +49,22 @@ fun ViewVideoScreen(
) { ) {
var isPlaying by remember { mutableStateOf(false) } var isPlaying by remember { mutableStateOf(false) }
var videoView: VideoView? by remember { mutableStateOf(null) } 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 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 { LivingAITheme {
Scaffold { padding -> Scaffold { padding ->
@ -57,7 +76,7 @@ fun ViewVideoScreen(
setOnPreparedListener { mp -> setOnPreparedListener { mp ->
mp.isLooping = true mp.isLooping = true
isPrepared = true isPrepared = true
// Seek to 1ms to show thumbnail (first frame) duration = mp.duration
seekTo(1) seekTo(1)
} }
setOnCompletionListener { setOnCompletionListener {
@ -75,21 +94,18 @@ fun ViewVideoScreen(
} }
} }
}, },
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.clickable {
isPlaying = !isPlaying
}
) )
// Play Button Overlay (Only show when paused) // Controls Overlay
if (!isPlaying) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable { isPlaying = true }, // Clicking anywhere while paused starts play .background(if (!isPlaying) Color.Black.copy(alpha = 0.3f) else Color.Transparent)
.clickable { isPlaying = !isPlaying },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (!isPlaying) {
Icon( Icon(
imageVector = Icons.Default.PlayArrow, imageVector = Icons.Default.PlayArrow,
contentDescription = "Play", contentDescription = "Play",
@ -100,24 +116,42 @@ fun ViewVideoScreen(
.padding(8.dp) .padding(8.dp)
) )
} }
} else { }
// Invisible clickable box to pause
Box( // Bottom Control Bar
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .align(Alignment.BottomCenter)
.clickable { isPlaying = false }, .fillMaxWidth()
contentAlignment = Alignment.Center .background(Color.Black.copy(alpha = 0.6f))
.padding(16.dp)
) { ) {
// No icon, just allows pausing // 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) { if (shouldAllowRetake) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Button(onClick = onRetake) { Button(onClick = onRetake) {
@ -127,14 +161,21 @@ fun ViewVideoScreen(
Text("Accept") Text("Accept")
} }
} }
} else { }
}
if (!shouldAllowRetake) {
IconButton( IconButton(
onClick = onBack, onClick = onBack,
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(16.dp) .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)
}

View File

@ -18,7 +18,7 @@ import java.util.concurrent.Executors
fun CameraPreview( fun CameraPreview(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
controller: LifecycleCameraController? = null, controller: LifecycleCameraController? = null,
onFrame: (Bitmap) -> Unit onFrame: (Bitmap, Int) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@ -29,26 +29,19 @@ fun CameraPreview(
LaunchedEffect(cameraController) { LaunchedEffect(cameraController) {
cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy -> cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy ->
val bitmap = imageProxy.toBitmap() val bitmap = imageProxy.toBitmap()
onFrame(bitmap) val rotationDegrees = imageProxy.imageInfo.rotationDegrees
onFrame(bitmap, rotationDegrees)
imageProxy.close() 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) { if (controller == null) {
// Only set defaults if we created it
LaunchedEffect(cameraController) { LaunchedEffect(cameraController) {
cameraController.setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE or LifecycleCameraController.VIDEO_CAPTURE) cameraController.setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE or LifecycleCameraController.VIDEO_CAPTURE)
cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraController.bindToLifecycle(lifecycleOwner) cameraController.bindToLifecycle(lifecycleOwner)
} }
} else { } 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) { LaunchedEffect(cameraController, lifecycleOwner) {
cameraController.bindToLifecycle(lifecycleOwner) cameraController.bindToLifecycle(lifecycleOwner)
} }

View File

@ -6,10 +6,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons 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.Home
import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -26,20 +29,22 @@ import com.example.livingai.pages.navigation.Route
fun CommonScaffold( fun CommonScaffold(
navController: NavController, navController: NavController,
title: String, title: String,
showFab: Boolean = false,
onFabClick: () -> Unit = {},
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
val bottomNavItems = listOf( val bottomNavItems = listOf(
BottomBarItem( BottomBarItem(
route = Route.OnBoardingScreen, route = Route.SettingsScreen,
icon = Icons.Default.Build, icon = Icons.Default.Settings,
name = "Nav", name = "Settings",
notifications = 1, notifications = 0,
), ),
BottomBarItem( BottomBarItem(
route = Route.HomeScreen, route = Route.HomeScreen,
icon = Icons.Default.Home, icon = Icons.Default.Home,
name = "Home", name = "Home",
notifications = 5, notifications = 0,
), ),
BottomBarItem( BottomBarItem(
route = Route.ListingsScreen, route = Route.ListingsScreen,
@ -74,6 +79,17 @@ fun CommonScaffold(
navController = navController, navController = navController,
onClick = { navController.navigate(it.route) } 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 -> ) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {

View File

@ -26,11 +26,12 @@ fun LabeledDropdown(
@StringRes labelRes: Int, @StringRes labelRes: Int,
options: List<String>, options: List<String>,
selected: String?, selected: String?,
onSelected: (String) -> Unit onSelected: (String) -> Unit,
modifier: Modifier = Modifier
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
Column { Column(modifier = modifier) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = !expanded } onExpandedChange = { expanded = !expanded }

View File

@ -15,12 +15,15 @@ import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.navigation.Route import com.example.livingai.pages.navigation.Route
import com.example.livingai.ui.theme.LivingAITheme import com.example.livingai.ui.theme.LivingAITheme
@ -32,8 +35,7 @@ fun LivingAIBottomBar(
onClick: (BottomBarItem) -> Unit, onClick: (BottomBarItem) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry.value?.destination?.route
NavigationBar( NavigationBar(
modifier = modifier, modifier = modifier,
@ -42,9 +44,14 @@ fun LivingAIBottomBar(
tonalElevation = Dimentions.BOTTOM_BAR_ELEVATION tonalElevation = Dimentions.BOTTOM_BAR_ELEVATION
) { ) {
navItems.forEach { item -> 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( NavigationBarItem(
selected = sel, selected = selected,
onClick = { onClick(item) }, onClick = { onClick(item) },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,

View File

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -23,12 +22,18 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController import androidx.navigation.NavController
import com.example.livingai.R import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.CommonScaffold
import com.example.livingai.pages.navigation.Route import com.example.livingai.pages.navigation.Route
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen(navController: NavController) { fun HomeScreen(navController: NavController) {
Scaffold { innerPadding -> CommonScaffold(
navController = navController,
title = stringResource(id = R.string.app_name),
showFab = false,
onFabClick = { }
) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -49,18 +54,16 @@ fun HomeScreen(navController: NavController) {
) )
Spacer(modifier = Modifier.height(Dimentions.MEDIUM_PADDING)) Spacer(modifier = Modifier.height(Dimentions.MEDIUM_PADDING))
HomeButton(
text = stringResource(id = R.string.top_bar_add_profile),
onClick = { navController.navigate(Route.AddProfileScreen()) }
)
HomeButton( HomeButton(
text = stringResource(id = R.string.top_bar_listings), text = stringResource(id = R.string.top_bar_listings),
onClick = { navController.navigate(Route.ListingsScreen) } onClick = { navController.navigate(Route.ListingsScreen) }
) )
HomeButton( HomeButton(
text = stringResource(id = R.string.top_bar_settings), text = stringResource(id = R.string.top_bar_add_profile),
onClick = { navController.navigate(Route.SettingsScreen) } onClick = { navController.navigate(Route.AddProfileScreen()) }
) )
} }
} }
} }

View File

@ -1,15 +1,38 @@
package com.example.livingai.pages.listings package com.example.livingai.pages.listings
import androidx.compose.foundation.layout.Arrangement 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.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.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.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.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource 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.navigation.NavController
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.example.livingai.R 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.commons.Dimentions
import com.example.livingai.pages.components.AnimalProfileCard import com.example.livingai.pages.components.AnimalProfileCard
import com.example.livingai.pages.components.CommonScaffold import com.example.livingai.pages.components.CommonScaffold
import com.example.livingai.pages.components.LabeledDropdown
import com.example.livingai.pages.navigation.Route import com.example.livingai.pages.navigation.Route
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -27,16 +52,134 @@ fun ListingsScreen(
viewModel: ListingsViewModel = koinViewModel() viewModel: ListingsViewModel = koinViewModel()
) { ) {
val animalProfiles: LazyPagingItems<AnimalProfile> = viewModel.animalProfiles.collectAsLazyPagingItems() val animalProfiles: LazyPagingItems<AnimalProfile> = 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<Boolean>("refresh_listings") == true) {
animalProfiles.refresh()
savedStateHandle.remove<Boolean>("refresh_listings")
}
}
CommonScaffold( CommonScaffold(
navController = navController, navController = navController,
title = stringResource(R.string.top_bar_listings) title = stringResource(R.string.top_bar_listings),
showFab = true,
onFabClick = { navController.navigate(Route.AddProfileScreen()) }
) { innerPadding -> ) { innerPadding ->
LazyColumn( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
contentPadding = PaddingValues(Dimentions.SMALL_PADDING_TEXT), .padding(Dimentions.SMALL_PADDING_TEXT)
) {
// 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
},
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) verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_TEXT)
) { ) {
items(count = animalProfiles.itemCount) { index -> items(count = animalProfiles.itemCount) { index ->
@ -55,3 +198,15 @@ fun ListingsScreen(
} }
} }
} }
is LoadState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = "Error loading data", color = MaterialTheme.colorScheme.error)
}
}
}
}
}
}

View File

@ -3,18 +3,79 @@ package com.example.livingai.pages.listings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.filter
import com.example.livingai.domain.model.AnimalProfile
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase 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 import kotlinx.coroutines.launch
sealed class ListingsEvent {
object Refresh : ListingsEvent()
}
class ListingsViewModel( class ListingsViewModel(
private val profileListingUseCase: ProfileListingUseCase private val profileListingUseCase: ProfileListingUseCase
) : ViewModel() { ) : ViewModel() {
val animalProfiles = profileListingUseCase.getAnimalProfiles().cachedIn(viewModelScope) var searchQuery = MutableStateFlow("")
var selectedSpecies = MutableStateFlow<String?>(null)
var selectedBreed = MutableStateFlow<String?>(null)
var minAge = MutableStateFlow("")
var maxAge = MutableStateFlow("")
private val _events = Channel<ListingsEvent>()
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<androidx.paging.PagingData<AnimalProfile>> = 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) { fun deleteAnimalProfile(animalId: String) {
viewModelScope.launch { viewModelScope.launch {
profileListingUseCase.deleteAnimalProfile(animalId) 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
)
} }

View File

@ -19,6 +19,7 @@ import com.example.livingai.pages.listings.ListingsScreen
import com.example.livingai.pages.onboarding.OnBoardingScreen import com.example.livingai.pages.onboarding.OnBoardingScreen
import com.example.livingai.pages.onboarding.OnBoardingViewModel import com.example.livingai.pages.onboarding.OnBoardingViewModel
import com.example.livingai.pages.ratings.RatingScreen import com.example.livingai.pages.ratings.RatingScreen
import com.example.livingai.pages.ratings.RatingViewModel
import com.example.livingai.pages.settings.SettingsScreen import com.example.livingai.pages.settings.SettingsScreen
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@ -56,12 +57,9 @@ fun NavGraph(
val route: Route.AddProfileScreen = backStackEntry.toRoute() val route: Route.AddProfileScreen = backStackEntry.toRoute()
val viewModel: AddProfileViewModel = koinViewModel() val viewModel: AddProfileViewModel = koinViewModel()
val currentId by viewModel.currentAnimalId val currentId by viewModel.currentAnimalId
val videoUri by viewModel.videoUri
LaunchedEffect(route.animalId, route.loadEntry) { // Note: initialization is handled in ViewModel init block using SavedStateHandle
if (route.loadEntry) {
viewModel.loadAnimalDetails(route.animalId)
}
}
// Handle new media from saved state handle // Handle new media from saved state handle
val newImageUri = backStackEntry.savedStateHandle.get<String>("newImageUri") val newImageUri = backStackEntry.savedStateHandle.get<String>("newImageUri")
@ -89,8 +87,7 @@ fun NavGraph(
viewModel = viewModel, viewModel = viewModel,
onSave = { onSave = {
viewModel.saveAnimalDetails() viewModel.saveAnimalDetails()
navController.previousBackStackEntry?.savedStateHandle?.set("refresh_listings", true) navController.popBackStack(Route.HomeScreen, inclusive = false)
navController.popBackStack()
}, },
onCancel = { navController.popBackStack() }, onCancel = { navController.popBackStack() },
onTakePhoto = { orientation -> onTakePhoto = { orientation ->
@ -107,12 +104,23 @@ fun NavGraph(
navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown")) 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<Route.RatingScreen> { composable<Route.RatingScreen> {
RatingScreen() val viewModel: RatingViewModel = koinViewModel()
RatingScreen(viewModel = viewModel, navController = navController)
} }
composable<Route.SettingsScreen> { composable<Route.SettingsScreen> {

View File

@ -1,14 +1,14 @@
package com.example.livingai.pages.ratings package com.example.livingai.pages.ratings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@ -17,103 +17,119 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController import androidx.navigation.NavController
import com.example.livingai.R import com.example.livingai.R
import com.example.livingai.domain.model.AnimalRating
import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.ImageThumbnailButton
import com.example.livingai.pages.components.RatingScale import com.example.livingai.pages.components.RatingScale
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RatingScreen( fun RatingScreen(
navController: NavController? = null, // Nullable for preview or if passed from NavGraph viewModel: RatingViewModel,
viewModel: RatingViewModel = koinViewModel() navController: NavController? = null
) { ) {
val ratingState by viewModel.ratingState.collectAsState() val ratingState by viewModel.ratingState.collectAsState()
val animalImages by viewModel.animalImages.collectAsState()
Scaffold( if (ratingState == null) {
topBar = { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// You might want a TopAppBar here, reusing CommonScaffold or similar CircularProgressIndicator()
Text( }
text = stringResource(id = R.string.top_bar_ratings), return
modifier = Modifier.padding(Dimentions.MEDIUM_PADDING) }
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) })
) )
} }
) { innerPadding ->
ratingState?.let { rating -> Scaffold { paddingValues ->
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(paddingValues)
.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 */ }
)
}
}
// 2. Description / Comments Box
item { item {
OutlinedTextField( OutlinedTextField(
value = rating.bodyConditionComments, value = rating.bodyConditionComments,
onValueChange = { viewModel.onRatingChange(rating.copy(bodyConditionComments = it)) }, onValueChange = {
rating = rating.copy(bodyConditionComments = it)
viewModel.onRatingChange(rating)
},
label = { Text(stringResource(id = R.string.label_rating_comments)) }, label = { Text(stringResource(id = R.string.label_rating_comments)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
minLines = 3 minLines = 3
) )
} }
// 3. Rating Components items(ratingFields.size) { idx ->
item { RatingScale(R.string.label_overall_rating, rating.overallRating, 10) { viewModel.onRatingChange(rating.copy(overallRating = it)) } } val field = ratingFields[idx]
item { RatingScale(R.string.label_health_rating, rating.healthRating, 10) { viewModel.onRatingChange(rating.copy(healthRating = it)) } } RatingScale(
item { RatingScale(R.string.label_breed_rating, rating.breedRating, 10) { viewModel.onRatingChange(rating.copy(breedRating = it)) } } label = field.labelRes,
value = field.getter(rating),
maxValue = 10,
onValueChange = { newVal ->
rating = field.updater(rating, newVal)
viewModel.onRatingChange(rating)
}
)
}
// 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 { item {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = Dimentions.MEDIUM_PADDING), .padding(vertical = Dimentions.MEDIUM_PADDING),
horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING) horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING),
verticalAlignment = Alignment.CenterVertically
) { ) {
OutlinedButton( OutlinedButton(
onClick = { navController?.popBackStack() }, onClick = { navController?.popBackStack() },
@ -121,6 +137,7 @@ fun RatingScreen(
) { ) {
Text(text = stringResource(id = R.string.btn_cancel)) Text(text = stringResource(id = R.string.btn_cancel))
} }
Button( Button(
onClick = { onClick = {
viewModel.saveRatings() viewModel.saveRatings()
@ -132,11 +149,12 @@ fun RatingScreen(
} }
} }
} }
}
}
}
item { private data class RatingField(
Spacer(modifier = Modifier.height(Dimentions.LARGE_PADDING)) val labelRes: Int,
} val getter: (AnimalRating) -> Int,
} val updater: (AnimalRating, Int) -> AnimalRating
} )
}
}

View File

@ -1,5 +1,6 @@
package com.example.livingai.pages.settings package com.example.livingai.pages.settings
import android.app.Activity
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -14,13 +15,14 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController import androidx.navigation.NavController
import com.example.livingai.R import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.CommonScaffold 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 import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -30,6 +32,8 @@ fun SettingsScreen(
viewModel: SettingsViewModel = koinViewModel() viewModel: SettingsViewModel = koinViewModel()
) { ) {
val settings by viewModel.settings.collectAsState() val settings by viewModel.settings.collectAsState()
val context = LocalContext.current
val languageEntries = stringArrayResource(id = R.array.language_entries) val languageEntries = stringArrayResource(id = R.array.language_entries)
val languageValues = stringArrayResource(id = R.array.language_values) val languageValues = stringArrayResource(id = R.array.language_values)
val languageMap = languageEntries.zip(languageValues).toMap() val languageMap = languageEntries.zip(languageValues).toMap()
@ -47,13 +51,17 @@ fun SettingsScreen(
.padding(it) .padding(it)
.padding(Dimentions.SMALL_PADDING_TEXT) .padding(Dimentions.SMALL_PADDING_TEXT)
) { ) {
RadioGroup( LabeledDropdown(
titleRes = R.string.language, labelRes = R.string.language,
options = languageEntries.toList(), options = languageEntries.toList(),
selected = selectedLanguageEntry, selected = selectedLanguageEntry,
onSelected = { selectedEntry -> onSelected = { selectedEntry ->
val selectedValue = languageMap[selectedEntry] ?: languageValues.first() val selectedValue = languageMap[selectedEntry] ?: languageValues.first()
viewModel.saveSettings(settings.copy(language = selectedValue)) viewModel.saveSettings(settings.copy(language = selectedValue))
// Restart activity to apply language change
val activity = context as? Activity
activity?.recreate()
} }
) )

View File

@ -1,22 +1,31 @@
package com.example.livingai.pages.settings package com.example.livingai.pages.settings
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.livingai.data.local.model.SettingsData import com.example.livingai.data.local.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository import com.example.livingai.domain.repository.SettingsRepository
import com.example.livingai.utils.LocaleHelper
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch 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)) private val _settings = MutableStateFlow(SettingsData("en", false))
val settings = _settings.asStateFlow() val settings = _settings.asStateFlow()
init { init {
getSettings() getSettings()
// apply locale initially
settingsRepository.getSettings().onEach {
LocaleHelper.applyLocale(appContext, it.language)
}.launchIn(viewModelScope)
} }
private fun getSettings() { private fun getSettings() {
@ -28,6 +37,8 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi
fun saveSettings(settings: SettingsData) { fun saveSettings(settings: SettingsData) {
viewModelScope.launch { viewModelScope.launch {
settingsRepository.saveSettings(settings) settingsRepository.saveSettings(settings)
// apply locale immediately
LocaleHelper.applyLocale(appContext, settings.language)
} }
} }
} }

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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<String, Bitmap>()
private val inverted = ConcurrentHashMap<String, Bitmap>()
private val bitmasks = ConcurrentHashMap<String, BooleanArray>()
fun initialize(context: Context, names: List<String>) {
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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB