updated features
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ import android.graphics.Bitmap
|
|||
|
||||
interface AIModel {
|
||||
fun deriveInference(bitmap: Bitmap): String
|
||||
suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>?
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -50,6 +51,12 @@ fun AddProfileScreen(
|
|||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,30 @@
|
|||
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)
|
||||
|
|
@ -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,14 +81,21 @@ class AddProfileViewModel(
|
|||
|
||||
// Populate photos
|
||||
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 ->
|
||||
// path: .../{id}_{orientation}.jpg
|
||||
val filename = path.substringAfterLast('/')
|
||||
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()
|
||||
photos[orientation] = path
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,25 +111,61 @@ fun CameraScreen(
|
|||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CameraPreview(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
controller = controller,
|
||||
onFrame = { bitmap ->
|
||||
if (state.isAutoCaptureOn) {
|
||||
viewModel.onEvent(CameraEvent.FrameReceived(bitmap))
|
||||
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")
|
||||
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) }
|
||||
|
|
@ -127,3 +175,4 @@ fun CameraScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
is CameraEvent.ClearCapturedImage -> {
|
||||
_state.value = _state.value.copy(capturedImage = null, capturedImageUri = null)
|
||||
}
|
||||
is CameraEvent.SetContext -> {
|
||||
currentAnimalId = event.animalId
|
||||
currentOrientation = event.orientation
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCaptured() {
|
||||
_state.value = _state.value.copy(capturedImage = null, capturedImageUri = null, segmentationMask = null)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
if (captureMutex.tryLock()) {
|
||||
try {
|
||||
val result = cameraRepository.processFrame(bitmap)
|
||||
_state.value = _state.value.copy(inferenceResult = result)
|
||||
} catch (e: Exception) {
|
||||
// Handle error
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ fun VideoRecordScreen(
|
|||
CameraPreview(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
controller = controller,
|
||||
onFrame = {}
|
||||
onFrame = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// Controls Overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.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
|
||||
) {
|
||||
if (!isPlaying) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
|
|
@ -100,24 +116,42 @@ fun ViewVideoScreen(
|
|||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Invisible clickable box to pause
|
||||
Box(
|
||||
}
|
||||
|
||||
// Bottom Control Bar
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable { isPlaying = false },
|
||||
contentAlignment = Alignment.Center
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.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) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Button(onClick = onRetake) {
|
||||
|
|
@ -127,14 +161,21 @@ fun ViewVideoScreen(
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,16 +52,134 @@ 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),
|
||||
.padding(innerPadding)
|
||||
.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)
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,103 +17,119 @@ 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()
|
||||
}
|
||||
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) })
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
ratingState?.let { rating ->
|
||||
|
||||
Scaffold { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = Dimentions.SMALL_PADDING_TEXT),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// 1. Image Thumbnail
|
||||
item {
|
||||
if (animalImages.isNotEmpty()) {
|
||||
ImageThumbnailButton(
|
||||
image = animalImages.firstOrNull(),
|
||||
onClick = { /* TODO: Handle click */ }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Description / Comments Box
|
||||
item {
|
||||
OutlinedTextField(
|
||||
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)) },
|
||||
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)) } }
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { navController?.popBackStack() },
|
||||
|
|
@ -121,6 +137,7 @@ fun RatingScreen(
|
|||
) {
|
||||
Text(text = stringResource(id = R.string.btn_cancel))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveRatings()
|
||||
|
|
@ -132,11 +149,12 @@ fun RatingScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(Dimentions.LARGE_PADDING))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private data class RatingField(
|
||||
val labelRes: Int,
|
||||
val getter: (AnimalRating) -> Int,
|
||||
val updater: (AnimalRating, Int) -> AnimalRating
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 33 KiB |