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
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")

View File

@ -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<HomeViewModel>()
private val viewModel by viewModel<HomeViewModel>()
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.
}
}
}
}

View File

@ -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<Array<String>> = 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<Array<String>>) = mutex.withLock {
val uri = getCsvUri()
withContext(Dispatchers.IO) {
withContext(dispatchers.io) {
try {
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
val writer = CSVWriter(OutputStreamWriter(out))

View File

@ -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"
}
}
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.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<CoroutineDispatchers> { DefaultCoroutineDispatchers() }
// Data Source
single<DataSource> {
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<AIModel> { 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() }
}

View File

@ -4,4 +4,5 @@ import android.graphics.Bitmap
interface AIModel {
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.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<String>("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
)
}

View File

@ -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<AnimalDetails?>(null)
val animalDetails: State<AnimalDetails?> = _animalDetails
@ -36,7 +47,7 @@ class AddProfileViewModel(
private val _videoUri = mutableStateOf<String?>(null)
val videoUri: State<String?> = _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<String, String>()
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<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
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) }
)
}
}
}
}

View File

@ -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()

View File

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

View File

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

View File

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

View File

@ -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()) {

View File

@ -26,11 +26,12 @@ fun LabeledDropdown(
@StringRes labelRes: Int,
options: List<String>,
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 }

View File

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

View File

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

View File

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

View File

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

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.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<String>("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<Route.RatingScreen> {
RatingScreen()
val viewModel: RatingViewModel = koinViewModel()
RatingScreen(viewModel = viewModel, navController = navController)
}
composable<Route.SettingsScreen> {

View File

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

View File

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

View File

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

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