updated features
|
|
@ -86,6 +86,7 @@ dependencies {
|
||||||
// Coil
|
// Coil
|
||||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
implementation("io.coil-kt:coil-video:2.5.0")
|
implementation("io.coil-kt:coil-video:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||||
|
|
||||||
// Paging
|
// Paging
|
||||||
implementation("androidx.paging:paging-runtime:3.2.1")
|
implementation("androidx.paging:paging-runtime:3.2.1")
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
|
@ -15,11 +16,12 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import com.example.livingai.pages.home.HomeViewModel
|
import com.example.livingai.pages.home.HomeViewModel
|
||||||
import com.example.livingai.pages.navigation.NavGraph
|
import com.example.livingai.pages.navigation.NavGraph
|
||||||
|
import com.example.livingai.pages.navigation.Route
|
||||||
import com.example.livingai.ui.theme.LivingAITheme
|
import com.example.livingai.ui.theme.LivingAITheme
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
val viewModel by viewModel<HomeViewModel>()
|
private val viewModel by viewModel<HomeViewModel>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
@ -29,6 +31,10 @@ class MainActivity : ComponentActivity() {
|
||||||
viewModel.splashCondition.value
|
viewModel.splashCondition.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The splash screen is shown until the start destination is determined.
|
||||||
|
// If there's a delay, the splash screen will cover it.
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
LivingAITheme {
|
LivingAITheme {
|
||||||
enableEdgeToEdge(
|
enableEdgeToEdge(
|
||||||
|
|
@ -43,7 +49,13 @@ class MainActivity : ComponentActivity() {
|
||||||
)
|
)
|
||||||
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
|
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
|
||||||
val startDestination = viewModel.startDestination.value
|
val startDestination = viewModel.startDestination.value
|
||||||
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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ import java.io.*
|
||||||
|
|
||||||
class CSVDataSource(
|
class CSVDataSource(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val fileName: String
|
private val fileName: String,
|
||||||
|
private val dispatchers: com.example.livingai.utils.CoroutineDispatchers
|
||||||
) : DataSource {
|
) : DataSource {
|
||||||
|
|
||||||
private val folderName = "LivingAI"
|
private val folderName = "LivingAI"
|
||||||
|
|
@ -32,7 +33,7 @@ class CSVDataSource(
|
||||||
|
|
||||||
private var cachedUri: Uri? = null
|
private var cachedUri: Uri? = null
|
||||||
|
|
||||||
private suspend fun getCsvUri(): Uri = withContext(Dispatchers.IO) {
|
private suspend fun getCsvUri(): Uri = withContext(dispatchers.io) {
|
||||||
cachedUri?.let { return@withContext it }
|
cachedUri?.let { return@withContext it }
|
||||||
|
|
||||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
|
@ -105,7 +106,7 @@ class CSVDataSource(
|
||||||
|
|
||||||
private suspend fun readAllLines(): List<Array<String>> = mutex.withLock {
|
private suspend fun readAllLines(): List<Array<String>> = mutex.withLock {
|
||||||
val uri = getCsvUri()
|
val uri = getCsvUri()
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(dispatchers.io) {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
val reader = CSVReader(InputStreamReader(input))
|
val reader = CSVReader(InputStreamReader(input))
|
||||||
|
|
@ -123,7 +124,7 @@ class CSVDataSource(
|
||||||
|
|
||||||
private suspend fun writeAllLines(lines: List<Array<String>>) = mutex.withLock {
|
private suspend fun writeAllLines(lines: List<Array<String>>) = mutex.withLock {
|
||||||
val uri = getCsvUri()
|
val uri = getCsvUri()
|
||||||
withContext(Dispatchers.IO) {
|
withContext(dispatchers.io) {
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
|
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
|
||||||
val writer = CSVWriter(OutputStreamWriter(out))
|
val writer = CSVWriter(OutputStreamWriter(out))
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,90 @@
|
||||||
package com.example.livingai.data.ml
|
package com.example.livingai.data.ml
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
import com.example.livingai.domain.ml.AIModel
|
import com.example.livingai.domain.ml.AIModel
|
||||||
|
import com.google.mlkit.vision.common.InputImage
|
||||||
|
import com.google.mlkit.vision.segmentation.subject.SubjectSegmentation
|
||||||
|
import com.google.mlkit.vision.segmentation.subject.SubjectSegmenterOptions
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
// Define a constant color for the segmentation mask
|
||||||
|
private const val MASK_COLOR = 0x5500FF00 // Semi-transparent green
|
||||||
|
|
||||||
class AIModelImpl : AIModel {
|
class AIModelImpl : AIModel {
|
||||||
|
|
||||||
|
private val segmenter by lazy {
|
||||||
|
val options = SubjectSegmenterOptions.Builder()
|
||||||
|
.enableForegroundBitmap()
|
||||||
|
.build()
|
||||||
|
SubjectSegmentation.getClient(options)
|
||||||
|
}
|
||||||
|
|
||||||
override fun deriveInference(bitmap: Bitmap): String {
|
override fun deriveInference(bitmap: Bitmap): String {
|
||||||
// Placeholder for actual inference logic
|
|
||||||
return "Inference Result"
|
return "Inference Result"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>? {
|
||||||
|
return suspendCancellableCoroutine { cont ->
|
||||||
|
val image = InputImage.fromBitmap(bitmap, 0)
|
||||||
|
segmenter.process(image)
|
||||||
|
.addOnSuccessListener { result ->
|
||||||
|
val foregroundBitmap = result.foregroundBitmap
|
||||||
|
if (foregroundBitmap != null) {
|
||||||
|
val colorizedMask = createColorizedMask(foregroundBitmap)
|
||||||
|
val booleanMask = createBooleanMask(foregroundBitmap)
|
||||||
|
cont.resume(Pair(colorizedMask, booleanMask))
|
||||||
|
} else {
|
||||||
|
cont.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.addOnFailureListener { e ->
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createColorizedMask(maskBitmap: Bitmap): Bitmap {
|
||||||
|
val width = maskBitmap.width
|
||||||
|
val height = maskBitmap.height
|
||||||
|
val pixels = IntArray(width * height)
|
||||||
|
maskBitmap.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
|
||||||
|
for (i in pixels.indices) {
|
||||||
|
if (Color.alpha(pixels[i]) > 0) {
|
||||||
|
pixels[i] = MASK_COLOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBooleanMask(bitmap: Bitmap): BooleanArray {
|
||||||
|
val w = bitmap.width
|
||||||
|
val h = bitmap.height
|
||||||
|
val mask = BooleanArray(w * h)
|
||||||
|
val pixels = IntArray(w * h)
|
||||||
|
bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
|
||||||
|
for (i in pixels.indices) {
|
||||||
|
val alpha = Color.alpha(pixels[i])
|
||||||
|
mask[i] = alpha > 0 // Assuming foreground bitmap has transparent background
|
||||||
|
}
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
fun jaccardIndex(maskA: BooleanArray, maskB: BooleanArray): Double {
|
||||||
|
val len = max(maskA.size, maskB.size)
|
||||||
|
var inter = 0
|
||||||
|
var union = 0
|
||||||
|
for (i in 0 until len) {
|
||||||
|
val a = if (i < maskA.size) maskA[i] else false
|
||||||
|
val b = if (i < maskB.size) maskB[i] else false
|
||||||
|
if (a || b) union++
|
||||||
|
if (a && b) inter++
|
||||||
|
}
|
||||||
|
return if (union == 0) 0.0 else inter.toDouble() / union.toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.SvgDecoder
|
||||||
import com.example.livingai.data.local.CSVDataSource
|
import com.example.livingai.data.local.CSVDataSource
|
||||||
import com.example.livingai.data.manager.LocalUserManagerImpl
|
import com.example.livingai.data.manager.LocalUserManagerImpl
|
||||||
import com.example.livingai.data.ml.AIModelImpl
|
import com.example.livingai.data.ml.AIModelImpl
|
||||||
|
|
@ -42,6 +44,9 @@ import com.example.livingai.pages.onboarding.OnBoardingViewModel
|
||||||
import com.example.livingai.pages.ratings.RatingViewModel
|
import com.example.livingai.pages.ratings.RatingViewModel
|
||||||
import com.example.livingai.pages.settings.SettingsViewModel
|
import com.example.livingai.pages.settings.SettingsViewModel
|
||||||
import com.example.livingai.utils.Constants
|
import com.example.livingai.utils.Constants
|
||||||
|
import com.example.livingai.utils.CoroutineDispatchers
|
||||||
|
import com.example.livingai.utils.DefaultCoroutineDispatchers
|
||||||
|
import com.example.livingai.utils.SilhouetteManager
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
@ -59,14 +64,35 @@ val appModule = module {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coroutine dispatchers (for testability)
|
||||||
|
single<CoroutineDispatchers> { DefaultCoroutineDispatchers() }
|
||||||
|
|
||||||
// Data Source
|
// Data Source
|
||||||
single<DataSource> {
|
single<DataSource> {
|
||||||
CSVDataSource(
|
CSVDataSource(
|
||||||
context = androidContext(),
|
context = androidContext(),
|
||||||
fileName = Constants.ANIMAL_DATA_FILENAME
|
fileName = Constants.ANIMAL_DATA_FILENAME,
|
||||||
|
dispatchers = get()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coil ImageLoader singleton
|
||||||
|
single {
|
||||||
|
ImageLoader.Builder(androidContext())
|
||||||
|
.components {
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize silhouettes once
|
||||||
|
single(createdAtStart = true) {
|
||||||
|
val ctx: Context = androidContext()
|
||||||
|
// Names expected to be provided as drawable resource names like "front_silhoutte"
|
||||||
|
val names = listOf("front_silhouette", "side_silhouette", "rear_silhouette")
|
||||||
|
SilhouetteManager.initialize(ctx, names)
|
||||||
|
}
|
||||||
|
|
||||||
// ML Model
|
// ML Model
|
||||||
single<AIModel> { AIModelImpl() }
|
single<AIModel> { AIModelImpl() }
|
||||||
|
|
||||||
|
|
@ -104,10 +130,12 @@ val appModule = module {
|
||||||
// ViewModels
|
// ViewModels
|
||||||
viewModel { HomeViewModel(get()) }
|
viewModel { HomeViewModel(get()) }
|
||||||
viewModel { OnBoardingViewModel(get(), get()) }
|
viewModel { OnBoardingViewModel(get(), get()) }
|
||||||
viewModel { AddProfileViewModel(get()) }
|
viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) ->
|
||||||
|
AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle)
|
||||||
|
}
|
||||||
viewModel { ListingsViewModel(get()) }
|
viewModel { ListingsViewModel(get()) }
|
||||||
viewModel { SettingsViewModel(get()) }
|
viewModel { SettingsViewModel(get(), androidContext()) }
|
||||||
viewModel { RatingViewModel(get(), get(), get(), get()) }
|
viewModel { RatingViewModel(get(), get(), get(), get()) }
|
||||||
viewModel { CameraViewModel(get()) }
|
viewModel { CameraViewModel(get(), get()) }
|
||||||
viewModel { VideoViewModel() }
|
viewModel { VideoViewModel() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ import android.graphics.Bitmap
|
||||||
|
|
||||||
interface AIModel {
|
interface AIModel {
|
||||||
fun deriveInference(bitmap: Bitmap): String
|
fun deriveInference(bitmap: Bitmap): String
|
||||||
|
suspend fun segmentImage(bitmap: Bitmap): Pair<Bitmap, BooleanArray>?
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -49,6 +50,12 @@ fun AddProfileScreen(
|
||||||
onTakeVideo: () -> Unit
|
onTakeVideo: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// If opened for edit, attempt to load existing animal details
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val existing = viewModel.savedStateHandle?.get<String>("animalId")
|
||||||
|
if (existing != null) viewModel.loadAnimal(existing)
|
||||||
|
}
|
||||||
|
|
||||||
val speciesList = stringArrayResource(id = R.array.species_list).toList()
|
val speciesList = stringArrayResource(id = R.array.species_list).toList()
|
||||||
val breedList = stringArrayResource(id = R.array.cow_breed_list).toList()
|
val breedList = stringArrayResource(id = R.array.cow_breed_list).toList()
|
||||||
|
|
@ -59,10 +66,9 @@ fun AddProfileScreen(
|
||||||
stringResource(R.string.option_none)
|
stringResource(R.string.option_none)
|
||||||
)
|
)
|
||||||
|
|
||||||
val silhouette = Constants.silhouetteList.associate { item ->
|
val silhouette = Constants.silhouetteList.associateWith { item ->
|
||||||
val resId = context.resources.getIdentifier("label_${item}", "string", context.packageName)
|
val resId = context.resources.getIdentifier("label_${item}", "string", context.packageName)
|
||||||
|
if (resId != 0) resId else R.string.default_orientation_label
|
||||||
item to if (resId != 0) resId else R.string.default_orientation_label
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ViewModel state
|
// Use ViewModel state
|
||||||
|
|
@ -172,7 +178,7 @@ fun AddProfileScreen(
|
||||||
// Video Button
|
// Video Button
|
||||||
item {
|
item {
|
||||||
VideoThumbnailButton(
|
VideoThumbnailButton(
|
||||||
videoSource = videoUri, // Removed videoThumbnail fallback as it's not in VM
|
videoSource = videoUri,
|
||||||
onClick = onTakeVideo
|
onClick = onTakeVideo
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
package com.example.livingai.pages.addprofile
|
package com.example.livingai.pages.addprofile
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.livingai.domain.model.AnimalDetails
|
import com.example.livingai.domain.model.AnimalDetails
|
||||||
|
import com.example.livingai.domain.usecases.GetAnimalDetails
|
||||||
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
|
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
|
||||||
|
import com.example.livingai.utils.CoroutineDispatchers
|
||||||
import com.example.livingai.utils.IdGenerator
|
import com.example.livingai.utils.IdGenerator
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class AddProfileViewModel(
|
class AddProfileViewModel(
|
||||||
private val profileEntryUseCase: ProfileEntryUseCase
|
private val profileEntryUseCase: ProfileEntryUseCase,
|
||||||
|
private val getAnimalDetails: GetAnimalDetails,
|
||||||
|
private val dispatchers: CoroutineDispatchers,
|
||||||
|
private val context: Context,
|
||||||
|
val savedStateHandle: SavedStateHandle? = null
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _animalDetails = mutableStateOf<AnimalDetails?>(null)
|
private val _animalDetails = mutableStateOf<AnimalDetails?>(null)
|
||||||
val animalDetails: State<AnimalDetails?> = _animalDetails
|
val animalDetails: State<AnimalDetails?> = _animalDetails
|
||||||
|
|
||||||
|
|
@ -36,7 +47,7 @@ class AddProfileViewModel(
|
||||||
private val _videoUri = mutableStateOf<String?>(null)
|
private val _videoUri = mutableStateOf<String?>(null)
|
||||||
val videoUri: State<String?> = _videoUri
|
val videoUri: State<String?> = _videoUri
|
||||||
|
|
||||||
fun loadAnimalDetails(animalId: String?) {
|
fun loadAnimal(animalId: String?) {
|
||||||
if (animalId == null) {
|
if (animalId == null) {
|
||||||
val newId = IdGenerator.generateAnimalId()
|
val newId = IdGenerator.generateAnimalId()
|
||||||
_currentAnimalId.value = newId
|
_currentAnimalId.value = newId
|
||||||
|
|
@ -70,15 +81,22 @@ class AddProfileViewModel(
|
||||||
|
|
||||||
// Populate photos
|
// Populate photos
|
||||||
photos.clear()
|
photos.clear()
|
||||||
details.images.forEach { path ->
|
// Process images on IO thread as it may involve DB queries
|
||||||
// path: .../{id}_{orientation}.jpg
|
withContext(dispatchers.io) {
|
||||||
val filename = path.substringAfterLast('/')
|
val photoMap = mutableMapOf<String, String>()
|
||||||
val nameWithoutExt = filename.substringBeforeLast('.')
|
details.images.forEach { path ->
|
||||||
val parts = nameWithoutExt.split('_')
|
val uri = Uri.parse(path)
|
||||||
if (parts.size >= 2) {
|
val filename = getFileName(uri) ?: path.substringAfterLast('/')
|
||||||
val orientation = parts.last()
|
val nameWithoutExt = filename.substringBeforeLast('.')
|
||||||
photos[orientation] = path
|
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 }
|
_videoUri.value = details.video.ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +104,33 @@ class AddProfileViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getFileName(uri: Uri): String? {
|
||||||
|
var result: String? = null
|
||||||
|
if (uri.scheme == "content") {
|
||||||
|
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||||
|
try {
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (index >= 0) {
|
||||||
|
result = cursor.getString(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result == null) {
|
||||||
|
result = uri.path
|
||||||
|
val cut = result?.lastIndexOf('/')
|
||||||
|
if (cut != null && cut != -1) {
|
||||||
|
result = result?.substring(cut + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
fun addPhoto(orientation: String, uri: String) {
|
fun addPhoto(orientation: String, uri: String) {
|
||||||
photos[orientation] = uri
|
photos[orientation] = uri
|
||||||
}
|
}
|
||||||
|
|
@ -115,4 +160,11 @@ class AddProfileViewModel(
|
||||||
profileEntryUseCase.setAnimalDetails(details)
|
profileEntryUseCase.setAnimalDetails(details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Try to auto-load when editing via saved state handle
|
||||||
|
val animalId: String? = savedStateHandle?.get<String>("animalId")
|
||||||
|
// Call loadAnimal unconditionally to ensure ID generation for new profiles
|
||||||
|
loadAnimal(animalId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package com.example.livingai.pages.camera
|
package com.example.livingai.pages.camera
|
||||||
|
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
import androidx.camera.core.ImageCaptureException
|
import androidx.camera.core.ImageCaptureException
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import androidx.camera.view.LifecycleCameraController
|
import androidx.camera.view.LifecycleCameraController
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -25,14 +27,18 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import org.koin.androidx.compose.koinViewModel
|
|
||||||
import com.example.livingai.pages.components.CameraPreview
|
import com.example.livingai.pages.components.CameraPreview
|
||||||
import com.example.livingai.pages.components.PermissionWrapper
|
import com.example.livingai.pages.components.PermissionWrapper
|
||||||
import com.example.livingai.pages.navigation.Route
|
import com.example.livingai.pages.navigation.Route
|
||||||
|
import com.example.livingai.utils.SetScreenOrientation
|
||||||
|
import com.example.livingai.utils.SilhouetteManager
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CameraScreen(
|
fun CameraScreen(
|
||||||
|
|
@ -41,6 +47,12 @@ fun CameraScreen(
|
||||||
orientation: String? = null,
|
orientation: String? = null,
|
||||||
animalId: String
|
animalId: String
|
||||||
) {
|
) {
|
||||||
|
val orientationLock = when (orientation) {
|
||||||
|
"front", "back" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
}
|
||||||
|
SetScreenOrientation(orientationLock)
|
||||||
|
|
||||||
PermissionWrapper {
|
PermissionWrapper {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -99,29 +111,66 @@ fun CameraScreen(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(paddingValues),
|
||||||
) {
|
) {
|
||||||
CameraPreview(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
CameraPreview(
|
||||||
controller = controller,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onFrame = { bitmap ->
|
controller = controller,
|
||||||
if (state.isAutoCaptureOn) {
|
onFrame = { bitmap, rotation ->
|
||||||
viewModel.onEvent(CameraEvent.FrameReceived(bitmap))
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Auto Capture")
|
if (state.inferenceResult != null) {
|
||||||
Switch(
|
Text(text = state.inferenceResult!!, color = androidx.compose.ui.graphics.Color.White)
|
||||||
checked = state.isAutoCaptureOn,
|
} else {
|
||||||
onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) }
|
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) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,126 @@
|
||||||
package com.example.livingai.pages.camera
|
package com.example.livingai.pages.camera
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.camera.core.ImageProxy
|
import androidx.camera.core.ImageProxy
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.livingai.data.ml.AIModelImpl
|
||||||
|
import com.example.livingai.domain.ml.AIModel
|
||||||
import com.example.livingai.domain.repository.CameraRepository
|
import com.example.livingai.domain.repository.CameraRepository
|
||||||
|
import com.example.livingai.utils.SilhouetteManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
class CameraViewModel(
|
class CameraViewModel(
|
||||||
private val cameraRepository: CameraRepository
|
private val cameraRepository: CameraRepository,
|
||||||
|
private val aiModel: AIModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(CameraState())
|
private val _state = MutableStateFlow(CameraUiState())
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
private var currentAnimalId: String = ""
|
// mutex to prevent parallel captures
|
||||||
private var currentOrientation: String? = null
|
private val captureMutex = Mutex()
|
||||||
|
|
||||||
fun onEvent(event: CameraEvent) {
|
fun onEvent(event: CameraEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is CameraEvent.CaptureImage -> {
|
is CameraEvent.CaptureImage -> captureImage()
|
||||||
// Signal UI to capture? Or handle if passed image
|
is CameraEvent.ImageCaptured -> handleImageProxy(event.imageProxy)
|
||||||
}
|
is CameraEvent.FrameReceived -> handleFrame(event.bitmap, event.rotationDegrees)
|
||||||
is CameraEvent.ImageCaptured -> {
|
is CameraEvent.ToggleAutoCapture -> toggleAutoCapture()
|
||||||
saveImage(event.imageProxy)
|
is CameraEvent.ClearCapturedImage -> clearCaptured()
|
||||||
}
|
is CameraEvent.SetContext -> setContext(event.animalId, event.orientation)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
viewModelScope.launch {
|
||||||
try {
|
val bitmap = cameraRepository.captureImage(proxy)
|
||||||
val bitmap = cameraRepository.captureImage(imageProxy)
|
val animalId = _state.value.animalId ?: "unknown"
|
||||||
val uriString = cameraRepository.saveImage(bitmap, currentAnimalId, currentOrientation)
|
val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation)
|
||||||
_state.value = _state.value.copy(capturedImage = bitmap, capturedImageUri = Uri.parse(uriString))
|
_state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString))
|
||||||
} catch (e: Exception) {
|
|
||||||
// Handle error
|
|
||||||
imageProxy.close() // Ensure closed on error if repository didn't
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processFrame(bitmap: Bitmap) {
|
private fun handleFrame(bitmap: Bitmap, rotationDegrees: Int) {
|
||||||
|
val orientation = _state.value.orientation ?: return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
if (captureMutex.tryLock()) {
|
||||||
val result = cameraRepository.processFrame(bitmap)
|
try {
|
||||||
_state.value = _state.value.copy(inferenceResult = result)
|
val result = aiModel.segmentImage(bitmap)
|
||||||
} catch (e: Exception) {
|
if (result != null) {
|
||||||
// Handle error
|
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(
|
data class CameraUiState(
|
||||||
val isLoading: Boolean = false,
|
val animalId: String? = null,
|
||||||
val isCameraReady: Boolean = false,
|
val orientation: String? = null,
|
||||||
val capturedImage: Any? = null,
|
val capturedImage: Any? = null,
|
||||||
val capturedImageUri: Uri? = null,
|
val capturedImageUri: Uri? = null,
|
||||||
val inferenceResult: String? = null,
|
val inferenceResult: String? = null,
|
||||||
val isAutoCaptureOn: Boolean = false
|
val isAutoCaptureOn: Boolean = false,
|
||||||
|
val segmentationMask: Bitmap? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class CameraEvent {
|
sealed class CameraEvent {
|
||||||
object CaptureImage : CameraEvent()
|
object CaptureImage : CameraEvent()
|
||||||
data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent()
|
data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent()
|
||||||
data class FrameReceived(val bitmap: Bitmap) : CameraEvent()
|
data class FrameReceived(val bitmap: Bitmap, val rotationDegrees: Int) : CameraEvent()
|
||||||
object ToggleAutoCapture : CameraEvent()
|
object ToggleAutoCapture : CameraEvent()
|
||||||
object ClearCapturedImage : CameraEvent()
|
object ClearCapturedImage : CameraEvent()
|
||||||
data class SetContext(val animalId: String, val orientation: String?) : CameraEvent()
|
data class SetContext(val animalId: String, val orientation: String?) : CameraEvent()
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ fun VideoRecordScreen(
|
||||||
CameraPreview(
|
CameraPreview(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
controller = controller,
|
controller = controller,
|
||||||
onFrame = {}
|
onFrame = { _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -13,16 +14,20 @@ import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -32,6 +37,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import com.example.livingai.ui.theme.LivingAITheme
|
import com.example.livingai.ui.theme.LivingAITheme
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewVideoScreen(
|
fun ViewVideoScreen(
|
||||||
|
|
@ -43,9 +49,22 @@ fun ViewVideoScreen(
|
||||||
) {
|
) {
|
||||||
var isPlaying by remember { mutableStateOf(false) }
|
var isPlaying by remember { mutableStateOf(false) }
|
||||||
var videoView: VideoView? by remember { mutableStateOf(null) }
|
var videoView: VideoView? by remember { mutableStateOf(null) }
|
||||||
// Keep track if we have sought to the first frame to avoid resetting on recomposition repeatedly if not needed,
|
|
||||||
// though VideoView state management in Compose can be tricky.
|
|
||||||
var isPrepared by remember { mutableStateOf(false) }
|
var isPrepared by remember { mutableStateOf(false) }
|
||||||
|
var currentPosition by remember { mutableIntStateOf(0) }
|
||||||
|
var duration by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Polling for video progress
|
||||||
|
LaunchedEffect(isPlaying, isPrepared) {
|
||||||
|
if (isPlaying && isPrepared) {
|
||||||
|
while (true) {
|
||||||
|
videoView?.let {
|
||||||
|
currentPosition = it.currentPosition
|
||||||
|
if (duration == 0) duration = it.duration
|
||||||
|
}
|
||||||
|
delay(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LivingAITheme {
|
LivingAITheme {
|
||||||
Scaffold { padding ->
|
Scaffold { padding ->
|
||||||
|
|
@ -57,7 +76,7 @@ fun ViewVideoScreen(
|
||||||
setOnPreparedListener { mp ->
|
setOnPreparedListener { mp ->
|
||||||
mp.isLooping = true
|
mp.isLooping = true
|
||||||
isPrepared = true
|
isPrepared = true
|
||||||
// Seek to 1ms to show thumbnail (first frame)
|
duration = mp.duration
|
||||||
seekTo(1)
|
seekTo(1)
|
||||||
}
|
}
|
||||||
setOnCompletionListener {
|
setOnCompletionListener {
|
||||||
|
|
@ -75,21 +94,18 @@ fun ViewVideoScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.clickable {
|
|
||||||
isPlaying = !isPlaying
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Play Button Overlay (Only show when paused)
|
// Controls Overlay
|
||||||
if (!isPlaying) {
|
Box(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxSize()
|
||||||
.fillMaxSize()
|
.background(if (!isPlaying) Color.Black.copy(alpha = 0.3f) else Color.Transparent)
|
||||||
.clickable { isPlaying = true }, // Clicking anywhere while paused starts play
|
.clickable { isPlaying = !isPlaying },
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
if (!isPlaying) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.PlayArrow,
|
imageVector = Icons.Default.PlayArrow,
|
||||||
contentDescription = "Play",
|
contentDescription = "Play",
|
||||||
|
|
@ -100,41 +116,66 @@ fun ViewVideoScreen(
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Invisible clickable box to pause
|
|
||||||
Box(
|
// Bottom Control Bar
|
||||||
modifier = Modifier
|
Column(
|
||||||
.fillMaxSize()
|
modifier = Modifier
|
||||||
.clickable { isPlaying = false },
|
.align(Alignment.BottomCenter)
|
||||||
contentAlignment = Alignment.Center
|
.fillMaxWidth()
|
||||||
) {
|
.background(Color.Black.copy(alpha = 0.6f))
|
||||||
// No icon, just allows pausing
|
.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) {
|
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 {
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(Alignment.TopStart)
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,3 +188,9 @@ fun ViewVideoScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun formatTime(millis: Int): String {
|
||||||
|
val seconds = (millis / 1000) % 60
|
||||||
|
val minutes = (millis / (1000 * 60)) % 60
|
||||||
|
return String.format("%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import java.util.concurrent.Executors
|
||||||
fun CameraPreview(
|
fun CameraPreview(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
controller: LifecycleCameraController? = null,
|
controller: LifecycleCameraController? = null,
|
||||||
onFrame: (Bitmap) -> Unit
|
onFrame: (Bitmap, Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
@ -29,26 +29,19 @@ fun CameraPreview(
|
||||||
LaunchedEffect(cameraController) {
|
LaunchedEffect(cameraController) {
|
||||||
cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy ->
|
cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy ->
|
||||||
val bitmap = imageProxy.toBitmap()
|
val bitmap = imageProxy.toBitmap()
|
||||||
onFrame(bitmap)
|
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||||
|
onFrame(bitmap, rotationDegrees)
|
||||||
imageProxy.close()
|
imageProxy.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure default setup if it was created internally, or even if passed externally we might want to enforce defaults
|
|
||||||
// But typically caller configures it if they pass it.
|
|
||||||
// However, for this component to work as a preview + analysis, we should ensure analysis is enabled.
|
|
||||||
|
|
||||||
if (controller == null) {
|
if (controller == null) {
|
||||||
// Only set defaults if we created it
|
|
||||||
LaunchedEffect(cameraController) {
|
LaunchedEffect(cameraController) {
|
||||||
cameraController.setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE or LifecycleCameraController.VIDEO_CAPTURE)
|
cameraController.setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE or LifecycleCameraController.VIDEO_CAPTURE)
|
||||||
cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
cameraController.bindToLifecycle(lifecycleOwner)
|
cameraController.bindToLifecycle(lifecycleOwner)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If passed externally, we still need to bind it if not already bound?
|
|
||||||
// Usually the caller binds it. But let's be safe or assume caller does it.
|
|
||||||
// Actually, let's bind it here to be sure, as this component represents the "Active Camera".
|
|
||||||
LaunchedEffect(cameraController, lifecycleOwner) {
|
LaunchedEffect(cameraController, lifecycleOwner) {
|
||||||
cameraController.bindToLifecycle(lifecycleOwner)
|
cameraController.bindToLifecycle(lifecycleOwner)
|
||||||
}
|
}
|
||||||
|
|
@ -69,4 +62,4 @@ fun CameraPreview(
|
||||||
// Cleanup if needed
|
// Cleanup if needed
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
@ -26,20 +29,22 @@ import com.example.livingai.pages.navigation.Route
|
||||||
fun CommonScaffold(
|
fun CommonScaffold(
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
title: String,
|
title: String,
|
||||||
|
showFab: Boolean = false,
|
||||||
|
onFabClick: () -> Unit = {},
|
||||||
content: @Composable (PaddingValues) -> Unit
|
content: @Composable (PaddingValues) -> Unit
|
||||||
) {
|
) {
|
||||||
val bottomNavItems = listOf(
|
val bottomNavItems = listOf(
|
||||||
BottomBarItem(
|
BottomBarItem(
|
||||||
route = Route.OnBoardingScreen,
|
route = Route.SettingsScreen,
|
||||||
icon = Icons.Default.Build,
|
icon = Icons.Default.Settings,
|
||||||
name = "Nav",
|
name = "Settings",
|
||||||
notifications = 1,
|
notifications = 0,
|
||||||
),
|
),
|
||||||
BottomBarItem(
|
BottomBarItem(
|
||||||
route = Route.HomeScreen,
|
route = Route.HomeScreen,
|
||||||
icon = Icons.Default.Home,
|
icon = Icons.Default.Home,
|
||||||
name = "Home",
|
name = "Home",
|
||||||
notifications = 5,
|
notifications = 0,
|
||||||
),
|
),
|
||||||
BottomBarItem(
|
BottomBarItem(
|
||||||
route = Route.ListingsScreen,
|
route = Route.ListingsScreen,
|
||||||
|
|
@ -74,6 +79,17 @@ fun CommonScaffold(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
onClick = { navController.navigate(it.route) }
|
onClick = { navController.navigate(it.route) }
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (showFab) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onFabClick,
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add Profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,12 @@ fun LabeledDropdown(
|
||||||
@StringRes labelRes: Int,
|
@StringRes labelRes: Int,
|
||||||
options: List<String>,
|
options: List<String>,
|
||||||
selected: String?,
|
selected: String?,
|
||||||
onSelected: (String) -> Unit
|
onSelected: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column {
|
Column(modifier = modifier) {
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = !expanded }
|
onExpandedChange = { expanded = !expanded }
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,15 @@ import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.toRoute
|
||||||
import com.example.livingai.pages.commons.Dimentions
|
import com.example.livingai.pages.commons.Dimentions
|
||||||
import com.example.livingai.pages.navigation.Route
|
import com.example.livingai.pages.navigation.Route
|
||||||
import com.example.livingai.ui.theme.LivingAITheme
|
import com.example.livingai.ui.theme.LivingAITheme
|
||||||
|
|
@ -32,8 +35,7 @@ fun LivingAIBottomBar(
|
||||||
onClick: (BottomBarItem) -> Unit,
|
onClick: (BottomBarItem) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val backStackEntry = navController.currentBackStackEntryAsState()
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = backStackEntry.value?.destination?.route
|
|
||||||
|
|
||||||
NavigationBar(
|
NavigationBar(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -42,9 +44,14 @@ fun LivingAIBottomBar(
|
||||||
tonalElevation = Dimentions.BOTTOM_BAR_ELEVATION
|
tonalElevation = Dimentions.BOTTOM_BAR_ELEVATION
|
||||||
) {
|
) {
|
||||||
navItems.forEach { item ->
|
navItems.forEach { item ->
|
||||||
val sel = ((currentRoute != null) && (item.route::class == currentRoute::class))
|
val selected = backStackEntry?.destination?.hierarchy?.any {
|
||||||
|
// This is a safer check. It compares the string route pattern.
|
||||||
|
// It's less ideal than type-safe checking but avoids deserialization crashes.
|
||||||
|
it.route == item.route::class.simpleName
|
||||||
|
} == true
|
||||||
|
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = sel,
|
selected = selected,
|
||||||
onClick = { onClick(item) },
|
onClick = { onClick(item) },
|
||||||
colors = NavigationBarItemDefaults.colors(
|
colors = NavigationBarItemDefaults.colors(
|
||||||
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -23,12 +22,18 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.example.livingai.R
|
import com.example.livingai.R
|
||||||
import com.example.livingai.pages.commons.Dimentions
|
import com.example.livingai.pages.commons.Dimentions
|
||||||
|
import com.example.livingai.pages.components.CommonScaffold
|
||||||
import com.example.livingai.pages.navigation.Route
|
import com.example.livingai.pages.navigation.Route
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(navController: NavController) {
|
fun HomeScreen(navController: NavController) {
|
||||||
Scaffold { innerPadding ->
|
CommonScaffold(
|
||||||
|
navController = navController,
|
||||||
|
title = stringResource(id = R.string.app_name),
|
||||||
|
showFab = false,
|
||||||
|
onFabClick = { }
|
||||||
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -49,18 +54,16 @@ fun HomeScreen(navController: NavController) {
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(Dimentions.MEDIUM_PADDING))
|
Spacer(modifier = Modifier.height(Dimentions.MEDIUM_PADDING))
|
||||||
|
|
||||||
HomeButton(
|
|
||||||
text = stringResource(id = R.string.top_bar_add_profile),
|
|
||||||
onClick = { navController.navigate(Route.AddProfileScreen()) }
|
|
||||||
)
|
|
||||||
HomeButton(
|
HomeButton(
|
||||||
text = stringResource(id = R.string.top_bar_listings),
|
text = stringResource(id = R.string.top_bar_listings),
|
||||||
onClick = { navController.navigate(Route.ListingsScreen) }
|
onClick = { navController.navigate(Route.ListingsScreen) }
|
||||||
)
|
)
|
||||||
|
|
||||||
HomeButton(
|
HomeButton(
|
||||||
text = stringResource(id = R.string.top_bar_settings),
|
text = stringResource(id = R.string.top_bar_add_profile),
|
||||||
onClick = { navController.navigate(Route.SettingsScreen) }
|
onClick = { navController.navigate(Route.AddProfileScreen()) }
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -78,4 +81,4 @@ fun HomeButton(
|
||||||
) {
|
) {
|
||||||
Text(text = text)
|
Text(text = text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,38 @@
|
||||||
package com.example.livingai.pages.listings
|
package com.example.livingai.pages.listings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import com.example.livingai.R
|
import com.example.livingai.R
|
||||||
|
|
@ -17,7 +40,9 @@ import com.example.livingai.domain.model.AnimalProfile
|
||||||
import com.example.livingai.pages.commons.Dimentions
|
import com.example.livingai.pages.commons.Dimentions
|
||||||
import com.example.livingai.pages.components.AnimalProfileCard
|
import com.example.livingai.pages.components.AnimalProfileCard
|
||||||
import com.example.livingai.pages.components.CommonScaffold
|
import com.example.livingai.pages.components.CommonScaffold
|
||||||
|
import com.example.livingai.pages.components.LabeledDropdown
|
||||||
import com.example.livingai.pages.navigation.Route
|
import com.example.livingai.pages.navigation.Route
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -27,29 +52,159 @@ fun ListingsScreen(
|
||||||
viewModel: ListingsViewModel = koinViewModel()
|
viewModel: ListingsViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val animalProfiles: LazyPagingItems<AnimalProfile> = viewModel.animalProfiles.collectAsLazyPagingItems()
|
val animalProfiles: LazyPagingItems<AnimalProfile> = viewModel.animalProfiles.collectAsLazyPagingItems()
|
||||||
|
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||||
|
val selectedSpecies by viewModel.selectedSpecies.collectAsState()
|
||||||
|
val selectedBreed by viewModel.selectedBreed.collectAsState()
|
||||||
|
val minAge by viewModel.minAge.collectAsState()
|
||||||
|
val maxAge by viewModel.maxAge.collectAsState()
|
||||||
|
|
||||||
|
val speciesList = stringArrayResource(id = R.array.species_list).toList()
|
||||||
|
val breedList = stringArrayResource(id = R.array.cow_breed_list).toList()
|
||||||
|
|
||||||
|
// Listen for events from the ViewModel to refresh the list
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.events.collectLatest { event ->
|
||||||
|
when (event) {
|
||||||
|
is ListingsEvent.Refresh -> {
|
||||||
|
animalProfiles.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for results from the AddProfileScreen
|
||||||
|
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||||
|
LaunchedEffect(savedStateHandle) {
|
||||||
|
if (savedStateHandle?.get<Boolean>("refresh_listings") == true) {
|
||||||
|
animalProfiles.refresh()
|
||||||
|
savedStateHandle.remove<Boolean>("refresh_listings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CommonScaffold(
|
CommonScaffold(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
title = stringResource(R.string.top_bar_listings)
|
title = stringResource(R.string.top_bar_listings),
|
||||||
|
showFab = true,
|
||||||
|
onFabClick = { navController.navigate(Route.AddProfileScreen()) }
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding)
|
||||||
contentPadding = PaddingValues(Dimentions.SMALL_PADDING_TEXT),
|
.padding(Dimentions.SMALL_PADDING_TEXT)
|
||||||
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_TEXT)
|
|
||||||
) {
|
) {
|
||||||
items(count = animalProfiles.itemCount) { index ->
|
// Search Bar
|
||||||
val item = animalProfiles[index]
|
OutlinedTextField(
|
||||||
item?.let { profile ->
|
value = searchQuery,
|
||||||
AnimalProfileCard(
|
onValueChange = { viewModel.searchQuery.value = it },
|
||||||
animalProfile = profile,
|
label = { Text("Search") },
|
||||||
onEdit = {
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
navController.navigate(Route.AddProfileScreen(animalId = profile.animalId, loadEntry = true))
|
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)) },
|
modifier = Modifier.weight(1f)
|
||||||
onDelete = { viewModel.deleteAnimalProfile(profile.animalId) }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,79 @@ package com.example.livingai.pages.listings
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
|
import androidx.paging.filter
|
||||||
|
import com.example.livingai.domain.model.AnimalProfile
|
||||||
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase
|
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed class ListingsEvent {
|
||||||
|
object Refresh : ListingsEvent()
|
||||||
|
}
|
||||||
|
|
||||||
class ListingsViewModel(
|
class ListingsViewModel(
|
||||||
private val profileListingUseCase: ProfileListingUseCase
|
private val profileListingUseCase: ProfileListingUseCase
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val animalProfiles = profileListingUseCase.getAnimalProfiles().cachedIn(viewModelScope)
|
var searchQuery = MutableStateFlow("")
|
||||||
|
var selectedSpecies = MutableStateFlow<String?>(null)
|
||||||
|
var selectedBreed = MutableStateFlow<String?>(null)
|
||||||
|
var minAge = MutableStateFlow("")
|
||||||
|
var maxAge = MutableStateFlow("")
|
||||||
|
|
||||||
|
private val _events = Channel<ListingsEvent>()
|
||||||
|
val events = _events.receiveAsFlow()
|
||||||
|
|
||||||
|
private val filters = combine(
|
||||||
|
searchQuery,
|
||||||
|
selectedSpecies,
|
||||||
|
selectedBreed,
|
||||||
|
minAge,
|
||||||
|
maxAge
|
||||||
|
) { query, species, breed, minAgeStr, maxAgeStr ->
|
||||||
|
FilterState(query, species, breed, minAgeStr, maxAgeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
|
val animalProfiles: Flow<androidx.paging.PagingData<AnimalProfile>> = filters.flatMapLatest { filter ->
|
||||||
|
profileListingUseCase.getAnimalProfiles().map { pagingData ->
|
||||||
|
val min = filter.minAge.toIntOrNull() ?: 0
|
||||||
|
val max = filter.maxAge.toIntOrNull() ?: Int.MAX_VALUE
|
||||||
|
|
||||||
|
pagingData.filter { profile: AnimalProfile ->
|
||||||
|
val matchesQuery = profile.name.contains(filter.query, ignoreCase = true) ||
|
||||||
|
profile.breed.contains(filter.query, ignoreCase = true)
|
||||||
|
|
||||||
|
val matchesSpecies = filter.species == null || profile.species.equals(filter.species, ignoreCase = true)
|
||||||
|
val matchesBreed = filter.breed == null || profile.breed.equals(filter.breed, ignoreCase = true)
|
||||||
|
|
||||||
|
val matchesAge = profile.age in min..max
|
||||||
|
|
||||||
|
matchesQuery && matchesSpecies && matchesBreed && matchesAge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.cachedIn(viewModelScope)
|
||||||
|
|
||||||
fun deleteAnimalProfile(animalId: String) {
|
fun deleteAnimalProfile(animalId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
profileListingUseCase.deleteAnimalProfile(animalId)
|
profileListingUseCase.deleteAnimalProfile(animalId)
|
||||||
|
_events.send(ListingsEvent.Refresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class FilterState(
|
||||||
|
val query: String,
|
||||||
|
val species: String?,
|
||||||
|
val breed: String?,
|
||||||
|
val minAge: String,
|
||||||
|
val maxAge: String
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import com.example.livingai.pages.listings.ListingsScreen
|
||||||
import com.example.livingai.pages.onboarding.OnBoardingScreen
|
import com.example.livingai.pages.onboarding.OnBoardingScreen
|
||||||
import com.example.livingai.pages.onboarding.OnBoardingViewModel
|
import com.example.livingai.pages.onboarding.OnBoardingViewModel
|
||||||
import com.example.livingai.pages.ratings.RatingScreen
|
import com.example.livingai.pages.ratings.RatingScreen
|
||||||
|
import com.example.livingai.pages.ratings.RatingViewModel
|
||||||
import com.example.livingai.pages.settings.SettingsScreen
|
import com.example.livingai.pages.settings.SettingsScreen
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
|
@ -56,12 +57,9 @@ fun NavGraph(
|
||||||
val route: Route.AddProfileScreen = backStackEntry.toRoute()
|
val route: Route.AddProfileScreen = backStackEntry.toRoute()
|
||||||
val viewModel: AddProfileViewModel = koinViewModel()
|
val viewModel: AddProfileViewModel = koinViewModel()
|
||||||
val currentId by viewModel.currentAnimalId
|
val currentId by viewModel.currentAnimalId
|
||||||
|
val videoUri by viewModel.videoUri
|
||||||
|
|
||||||
LaunchedEffect(route.animalId, route.loadEntry) {
|
// Note: initialization is handled in ViewModel init block using SavedStateHandle
|
||||||
if (route.loadEntry) {
|
|
||||||
viewModel.loadAnimalDetails(route.animalId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle new media from saved state handle
|
// Handle new media from saved state handle
|
||||||
val newImageUri = backStackEntry.savedStateHandle.get<String>("newImageUri")
|
val newImageUri = backStackEntry.savedStateHandle.get<String>("newImageUri")
|
||||||
|
|
@ -89,8 +87,7 @@ fun NavGraph(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onSave = {
|
onSave = {
|
||||||
viewModel.saveAnimalDetails()
|
viewModel.saveAnimalDetails()
|
||||||
navController.previousBackStackEntry?.savedStateHandle?.set("refresh_listings", true)
|
navController.popBackStack(Route.HomeScreen, inclusive = false)
|
||||||
navController.popBackStack()
|
|
||||||
},
|
},
|
||||||
onCancel = { navController.popBackStack() },
|
onCancel = { navController.popBackStack() },
|
||||||
onTakePhoto = { orientation ->
|
onTakePhoto = { orientation ->
|
||||||
|
|
@ -107,12 +104,23 @@ fun NavGraph(
|
||||||
navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown"))
|
navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTakeVideo = { navController.navigate(Route.VideoRecordScreen(animalId = currentId ?: "unknown")) }
|
onTakeVideo = {
|
||||||
|
if (videoUri != null) {
|
||||||
|
navController.navigate(Route.ViewVideoScreen(
|
||||||
|
videoUri = videoUri!!,
|
||||||
|
shouldAllowRetake = true,
|
||||||
|
animalId = currentId ?: "unknown"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
navController.navigate(Route.VideoRecordScreen(animalId = currentId ?: "unknown"))
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Route.RatingScreen> {
|
composable<Route.RatingScreen> {
|
||||||
RatingScreen()
|
val viewModel: RatingViewModel = koinViewModel()
|
||||||
|
RatingScreen(viewModel = viewModel, navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable<Route.SettingsScreen> {
|
composable<Route.SettingsScreen> {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
package com.example.livingai.pages.ratings
|
package com.example.livingai.pages.ratings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
|
@ -17,126 +17,144 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.example.livingai.R
|
import com.example.livingai.R
|
||||||
|
import com.example.livingai.domain.model.AnimalRating
|
||||||
import com.example.livingai.pages.commons.Dimentions
|
import com.example.livingai.pages.commons.Dimentions
|
||||||
import com.example.livingai.pages.components.ImageThumbnailButton
|
|
||||||
import com.example.livingai.pages.components.RatingScale
|
import com.example.livingai.pages.components.RatingScale
|
||||||
import org.koin.androidx.compose.koinViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RatingScreen(
|
fun RatingScreen(
|
||||||
navController: NavController? = null, // Nullable for preview or if passed from NavGraph
|
viewModel: RatingViewModel,
|
||||||
viewModel: RatingViewModel = koinViewModel()
|
navController: NavController? = null
|
||||||
) {
|
) {
|
||||||
val ratingState by viewModel.ratingState.collectAsState()
|
val ratingState by viewModel.ratingState.collectAsState()
|
||||||
val animalImages by viewModel.animalImages.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(
|
if (ratingState == null) {
|
||||||
topBar = {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
// You might want a TopAppBar here, reusing CommonScaffold or similar
|
CircularProgressIndicator()
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.top_bar_ratings),
|
|
||||||
modifier = Modifier.padding(Dimentions.MEDIUM_PADDING)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
return
|
||||||
ratingState?.let { rating ->
|
}
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
var rating = ratingState!!
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
// Define rating fields declaratively to avoid repeated code
|
||||||
.padding(horizontal = Dimentions.SMALL_PADDING_TEXT),
|
val ratingFields = remember {
|
||||||
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING)
|
listOf(
|
||||||
) {
|
RatingField(
|
||||||
// 1. Image Thumbnail
|
labelRes = R.string.label_overall_rating,
|
||||||
item {
|
getter = { r -> r.overallRating },
|
||||||
if (animalImages.isNotEmpty()) {
|
updater = { r, v -> r.copy(overallRating = v) }
|
||||||
ImageThumbnailButton(
|
),
|
||||||
image = animalImages.firstOrNull(),
|
RatingField(
|
||||||
onClick = { /* TODO: Handle click */ }
|
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 {
|
||||||
item {
|
Row(
|
||||||
OutlinedTextField(
|
modifier = Modifier
|
||||||
value = rating.bodyConditionComments,
|
.fillMaxWidth()
|
||||||
onValueChange = { viewModel.onRatingChange(rating.copy(bodyConditionComments = it)) },
|
.padding(vertical = Dimentions.MEDIUM_PADDING),
|
||||||
label = { Text(stringResource(id = R.string.label_rating_comments)) },
|
horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
minLines = 3
|
) {
|
||||||
)
|
OutlinedButton(
|
||||||
}
|
onClick = { navController?.popBackStack() },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
// 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)
|
|
||||||
) {
|
) {
|
||||||
OutlinedButton(
|
Text(text = stringResource(id = R.string.btn_cancel))
|
||||||
onClick = { navController?.popBackStack() },
|
}
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
Button(
|
||||||
Text(text = stringResource(id = R.string.btn_cancel))
|
onClick = {
|
||||||
}
|
viewModel.saveRatings()
|
||||||
Button(
|
navController?.popBackStack()
|
||||||
onClick = {
|
},
|
||||||
viewModel.saveRatings()
|
modifier = Modifier.weight(1f)
|
||||||
navController?.popBackStack()
|
) {
|
||||||
},
|
Text(text = stringResource(id = R.string.btn_save_ratings))
|
||||||
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.example.livingai.pages.settings
|
package com.example.livingai.pages.settings
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -14,13 +15,14 @@ import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.example.livingai.R
|
import com.example.livingai.R
|
||||||
import com.example.livingai.pages.commons.Dimentions
|
import com.example.livingai.pages.commons.Dimentions
|
||||||
import com.example.livingai.pages.components.CommonScaffold
|
import com.example.livingai.pages.components.CommonScaffold
|
||||||
import com.example.livingai.pages.components.RadioGroup
|
import com.example.livingai.pages.components.LabeledDropdown
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -30,6 +32,8 @@ fun SettingsScreen(
|
||||||
viewModel: SettingsViewModel = koinViewModel()
|
viewModel: SettingsViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val settings by viewModel.settings.collectAsState()
|
val settings by viewModel.settings.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
val languageEntries = stringArrayResource(id = R.array.language_entries)
|
val languageEntries = stringArrayResource(id = R.array.language_entries)
|
||||||
val languageValues = stringArrayResource(id = R.array.language_values)
|
val languageValues = stringArrayResource(id = R.array.language_values)
|
||||||
val languageMap = languageEntries.zip(languageValues).toMap()
|
val languageMap = languageEntries.zip(languageValues).toMap()
|
||||||
|
|
@ -47,13 +51,17 @@ fun SettingsScreen(
|
||||||
.padding(it)
|
.padding(it)
|
||||||
.padding(Dimentions.SMALL_PADDING_TEXT)
|
.padding(Dimentions.SMALL_PADDING_TEXT)
|
||||||
) {
|
) {
|
||||||
RadioGroup(
|
LabeledDropdown(
|
||||||
titleRes = R.string.language,
|
labelRes = R.string.language,
|
||||||
options = languageEntries.toList(),
|
options = languageEntries.toList(),
|
||||||
selected = selectedLanguageEntry,
|
selected = selectedLanguageEntry,
|
||||||
onSelected = { selectedEntry ->
|
onSelected = { selectedEntry ->
|
||||||
val selectedValue = languageMap[selectedEntry] ?: languageValues.first()
|
val selectedValue = languageMap[selectedEntry] ?: languageValues.first()
|
||||||
viewModel.saveSettings(settings.copy(language = selectedValue))
|
viewModel.saveSettings(settings.copy(language = selectedValue))
|
||||||
|
|
||||||
|
// Restart activity to apply language change
|
||||||
|
val activity = context as? Activity
|
||||||
|
activity?.recreate()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,31 @@
|
||||||
package com.example.livingai.pages.settings
|
package com.example.livingai.pages.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.example.livingai.data.local.model.SettingsData
|
import com.example.livingai.data.local.model.SettingsData
|
||||||
import com.example.livingai.domain.repository.SettingsRepository
|
import com.example.livingai.domain.repository.SettingsRepository
|
||||||
|
import com.example.livingai.utils.LocaleHelper
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
|
class SettingsViewModel(
|
||||||
|
private val settingsRepository: SettingsRepository,
|
||||||
|
private val appContext: Context
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _settings = MutableStateFlow(SettingsData("en", false))
|
private val _settings = MutableStateFlow(SettingsData("en", false))
|
||||||
val settings = _settings.asStateFlow()
|
val settings = _settings.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
getSettings()
|
getSettings()
|
||||||
|
// apply locale initially
|
||||||
|
settingsRepository.getSettings().onEach {
|
||||||
|
LocaleHelper.applyLocale(appContext, it.language)
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getSettings() {
|
private fun getSettings() {
|
||||||
|
|
@ -28,6 +37,8 @@ class SettingsViewModel(private val settingsRepository: SettingsRepository) : Vi
|
||||||
fun saveSettings(settings: SettingsData) {
|
fun saveSettings(settings: SettingsData) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepository.saveSettings(settings)
|
settingsRepository.saveSettings(settings)
|
||||||
|
// apply locale immediately
|
||||||
|
LocaleHelper.applyLocale(appContext, settings.language)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |