camera changes

This commit is contained in:
SaiD 2025-12-04 23:57:57 +05:30
parent ea5d7109d4
commit 19eddcf496
35 changed files with 702 additions and 265 deletions

View File

@ -45,6 +45,7 @@ android {
dependencies { dependencies {
implementation(libs.androidx.paging.common) implementation(libs.androidx.paging.common)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.window)
val cameraxVersion = "1.5.0-alpha03" val cameraxVersion = "1.5.0-alpha03"
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.cardview:cardview:1.0.0") implementation("androidx.cardview:cardview:1.0.0")

View File

@ -3,25 +3,32 @@ package com.example.livingai
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.example.livingai.domain.usecases.AppDataUseCases
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 com.example.livingai.utils.LocaleHelper
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel by viewModel<HomeViewModel>() private val viewModel by viewModel<HomeViewModel>()
private val appDataUseCases: AppDataUseCases by inject()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -32,10 +39,19 @@ class MainActivity : ComponentActivity() {
} }
} }
// The splash screen is shown until the start destination is determined.
// If there's a delay, the splash screen will cover it.
setContent { setContent {
val settings by appDataUseCases.getSettings().collectAsState(initial = null)
val context = LocalContext.current
// Update locale and provide it through CompositionLocalProvider
val localizedContext = settings?.let {
LocaleHelper.applyLocale(context, it.language)
} ?: context
CompositionLocalProvider(
LocalContext provides localizedContext,
LocalActivityResultRegistryOwner provides this
) {
LivingAITheme { LivingAITheme {
enableEdgeToEdge( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto( statusBarStyle = SystemBarStyle.auto(
@ -61,3 +77,4 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
}

View File

@ -0,0 +1,60 @@
package com.example.livingai.data.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.example.livingai.domain.model.SettingsData
import com.example.livingai.domain.repository.AppDataRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
class AppDataRepositoryImpl(private val dataStore: DataStore<Preferences>) : AppDataRepository {
private object PreferencesKeys {
val APP_ENTRY = booleanPreferencesKey("app_entry")
val LANGUAGE = stringPreferencesKey("language")
val IS_AUTO_CAPTURE_ON = booleanPreferencesKey("is_auto_capture_on")
val JACCARD_THRESHOLD = floatPreferencesKey("jaccard_threshold")
}
override fun getSettings(): Flow<SettingsData> {
return dataStore.data.catch {
if (it is IOException) {
emit(emptyPreferences())
} else {
throw it
}
}.map {
val language = it[PreferencesKeys.LANGUAGE] ?: "en"
val isAutoCaptureOn = it[PreferencesKeys.IS_AUTO_CAPTURE_ON] ?: false
val jaccardThreshold = it[PreferencesKeys.JACCARD_THRESHOLD] ?: 50f
SettingsData(language, isAutoCaptureOn, jaccardThreshold)
}
}
override suspend fun saveSettings(settings: SettingsData) {
dataStore.edit {
it[PreferencesKeys.LANGUAGE] = settings.language
it[PreferencesKeys.IS_AUTO_CAPTURE_ON] = settings.isAutoCaptureOn
it[PreferencesKeys.JACCARD_THRESHOLD] = settings.jaccardThreshold
}
}
override suspend fun saveAppEntry() {
dataStore.edit { settings ->
settings[PreferencesKeys.APP_ENTRY] = true
}
}
override fun readAppEntry(): Flow<Boolean> {
return dataStore.data.map { preferences ->
preferences[PreferencesKeys.APP_ENTRY] ?: false
}
}
}

View File

@ -4,35 +4,36 @@ 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 androidx.window.layout.WindowMetricsCalculator
import coil.ImageLoader import coil.ImageLoader
import coil.decode.SvgDecoder 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.ml.AIModelImpl import com.example.livingai.data.ml.AIModelImpl
import com.example.livingai.data.repository.SettingsRepositoryImpl import com.example.livingai.data.repository.AppDataRepositoryImpl
import com.example.livingai.data.repository.business.AnimalDetailsRepositoryImpl import com.example.livingai.data.repository.business.AnimalDetailsRepositoryImpl
import com.example.livingai.data.repository.business.AnimalProfileRepositoryImpl import com.example.livingai.data.repository.business.AnimalProfileRepositoryImpl
import com.example.livingai.data.repository.business.AnimalRatingRepositoryImpl import com.example.livingai.data.repository.business.AnimalRatingRepositoryImpl
import com.example.livingai.data.repository.media.CameraRepositoryImpl import com.example.livingai.data.repository.media.CameraRepositoryImpl
import com.example.livingai.data.repository.media.VideoRepositoryImpl import com.example.livingai.data.repository.media.VideoRepositoryImpl
import com.example.livingai.domain.manager.LocalUserManager
import com.example.livingai.domain.ml.AIModel import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.repository.AppDataRepository
import com.example.livingai.domain.repository.CameraRepository import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.domain.repository.SettingsRepository
import com.example.livingai.domain.repository.VideoRepository import com.example.livingai.domain.repository.VideoRepository
import com.example.livingai.domain.repository.business.AnimalDetailsRepository import com.example.livingai.domain.repository.business.AnimalDetailsRepository
import com.example.livingai.domain.repository.business.AnimalProfileRepository import com.example.livingai.domain.repository.business.AnimalProfileRepository
import com.example.livingai.domain.repository.business.AnimalRatingRepository import com.example.livingai.domain.repository.business.AnimalRatingRepository
import com.example.livingai.domain.repository.business.DataSource import com.example.livingai.domain.repository.business.DataSource
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.domain.usecases.DeleteAnimalProfile import com.example.livingai.domain.usecases.DeleteAnimalProfile
import com.example.livingai.domain.usecases.GetAnimalDetails import com.example.livingai.domain.usecases.GetAnimalDetails
import com.example.livingai.domain.usecases.GetAnimalProfiles import com.example.livingai.domain.usecases.GetAnimalProfiles
import com.example.livingai.domain.usecases.GetAnimalRatings import com.example.livingai.domain.usecases.GetAnimalRatings
import com.example.livingai.domain.usecases.GetSettingsUseCase
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase
import com.example.livingai.domain.usecases.ReadAppEntry import com.example.livingai.domain.usecases.ReadAppEntryUseCase
import com.example.livingai.domain.usecases.SaveAppEntry import com.example.livingai.domain.usecases.SaveAppEntryUseCase
import com.example.livingai.domain.usecases.SaveSettingsUseCase
import com.example.livingai.domain.usecases.SetAnimalDetails import com.example.livingai.domain.usecases.SetAnimalDetails
import com.example.livingai.domain.usecases.SetAnimalRatings import com.example.livingai.domain.usecases.SetAnimalRatings
import com.example.livingai.pages.addprofile.AddProfileViewModel import com.example.livingai.pages.addprofile.AddProfileViewModel
@ -46,6 +47,7 @@ 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.CoroutineDispatchers
import com.example.livingai.utils.DefaultCoroutineDispatchers import com.example.livingai.utils.DefaultCoroutineDispatchers
import com.example.livingai.utils.ScreenDimensions
import com.example.livingai.utils.SilhouetteManager 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
@ -54,13 +56,16 @@ import org.koin.dsl.module
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = Constants.USER_SETTINGS) private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = Constants.USER_SETTINGS)
val appModule = module { val appModule = module {
single<LocalUserManager> { LocalUserManagerImpl(get()) }
single<DataStore<Preferences>> { androidContext().dataStore } single<DataStore<Preferences>> { androidContext().dataStore }
single<AppDataRepository> { AppDataRepositoryImpl(get()) }
single { single {
AppEntryUseCases( AppDataUseCases(
readAppEntry = ReadAppEntry(get()), getSettings = GetSettingsUseCase(get()),
saveAppEntry = SaveAppEntry(get()) saveSettings = SaveSettingsUseCase(get()),
readAppEntry = ReadAppEntryUseCase(get()),
saveAppEntry = SaveAppEntryUseCase(get())
) )
} }
@ -86,11 +91,16 @@ val appModule = module {
} }
// Initialize silhouettes once // Initialize silhouettes once
single(createdAtStart = true) { single<ScreenDimensions>(createdAtStart = true) {
val ctx: Context = androidContext() val ctx: Context = androidContext()
// Names expected to be provided as drawable resource names like "front_silhoutte" val metrics = WindowMetricsCalculator.getOrCreate()
val names = listOf("front_silhouette", "side_silhouette", "rear_silhouette") .computeCurrentWindowMetrics(ctx)
SilhouetteManager.initialize(ctx, names)
val bounds = metrics.bounds
val screenWidth = bounds.width()
val screenHeight = bounds.height()
SilhouetteManager.initialize(ctx, screenWidth, screenHeight)
ScreenDimensions(screenWidth, screenHeight)
} }
// ML Model // ML Model
@ -100,7 +110,6 @@ val appModule = module {
single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) } single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) }
single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) } single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) }
single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) } single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) }
single<SettingsRepository> { SettingsRepositoryImpl(get()) }
single<CameraRepository> { CameraRepositoryImpl(get(), androidContext()) } single<CameraRepository> { CameraRepositoryImpl(get(), androidContext()) }
single<VideoRepository> { VideoRepositoryImpl(get()) } single<VideoRepository> { VideoRepositoryImpl(get()) }
@ -129,13 +138,13 @@ val appModule = module {
// ViewModels // ViewModels
viewModel { HomeViewModel(get()) } viewModel { HomeViewModel(get()) }
viewModel { OnBoardingViewModel(get(), get()) } viewModel { OnBoardingViewModel(get()) }
viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) -> viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) ->
AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle) AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle)
} }
viewModel { ListingsViewModel(get()) } viewModel { ListingsViewModel(get()) }
viewModel { SettingsViewModel(get(), androidContext()) } viewModel { SettingsViewModel(get()) }
viewModel { RatingViewModel(get(), get(), get(), get()) } viewModel { RatingViewModel(get(), get(), get(), get()) }
viewModel { CameraViewModel(get(), get()) } viewModel { CameraViewModel(get(), get(), get(), get()) }
viewModel { VideoViewModel() } viewModel { VideoViewModel() }
} }

View File

@ -5,5 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class SettingsData( data class SettingsData(
val language: String = "en", val language: String = "en",
val isAutoCaptureOn: Boolean = false val isAutoCaptureOn: Boolean = false,
val jaccardThreshold: Float = 50f,
val distanceMethod: String = "Jaccard"
) )

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.repository
import com.example.livingai.domain.model.SettingsData
import kotlinx.coroutines.flow.Flow
interface AppDataRepository {
fun getSettings(): Flow<SettingsData>
suspend fun saveSettings(settings: SettingsData)
suspend fun saveAppEntry()
fun readAppEntry(): Flow<Boolean>
}

View File

@ -0,0 +1,8 @@
package com.example.livingai.domain.usecases
data class AppDataUseCases(
val getSettings: GetSettingsUseCase,
val saveSettings: SaveSettingsUseCase,
val readAppEntry: ReadAppEntryUseCase,
val saveAppEntry: SaveAppEntryUseCase
)

View File

@ -0,0 +1,10 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.AppDataRepository
import javax.inject.Inject
class GetSettingsUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
operator fun invoke() = appDataRepository.getSettings()
}

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.AppDataRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class ReadAppEntryUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
operator fun invoke(): Flow<Boolean> = appDataRepository.readAppEntry()
}

View File

@ -0,0 +1,10 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.AppDataRepository
import javax.inject.Inject
class SaveAppEntryUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
suspend operator fun invoke() = appDataRepository.saveAppEntry()
}

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.model.SettingsData
import com.example.livingai.domain.repository.AppDataRepository
import javax.inject.Inject
class SaveSettingsUseCase @Inject constructor(
private val appDataRepository: AppDataRepository
) {
suspend operator fun invoke(settings: SettingsData) = appDataRepository.saveSettings(settings)
}

View File

@ -6,20 +6,15 @@ 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.Image
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Camera
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -30,14 +25,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale 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.core.content.ContextCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.core.content.ContextCompat
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.SetScreenOrientation
import com.example.livingai.utils.SilhouetteManager
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
@ -47,12 +40,22 @@ fun CameraScreen(
orientation: String? = null, orientation: String? = null,
animalId: String animalId: String
) { ) {
val orientationLock = when (orientation) { val isLandscape = when (orientation) {
"front", "back" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT "front", "back" -> false
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else -> true
}
val orientationLock = if (isLandscape) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} }
SetScreenOrientation(orientationLock) SetScreenOrientation(orientationLock)
LaunchedEffect(animalId, orientation) {
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation))
}
PermissionWrapper { PermissionWrapper {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
@ -63,8 +66,27 @@ fun CameraScreen(
} }
} }
LaunchedEffect(animalId, orientation) { fun takePhoto() {
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation)) val executor = ContextCompat.getMainExecutor(context)
controller.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
viewModel.onEvent(CameraEvent.ImageCaptured(image))
}
override fun onError(exception: ImageCaptureException) {
// Handle error, e.g., log it or show a message
}
}
)
}
LaunchedEffect(state.shouldAutoCapture) {
if (state.shouldAutoCapture) {
takePhoto()
viewModel.onEvent(CameraEvent.AutoCaptureTriggered)
}
} }
LaunchedEffect(state.capturedImageUri) { LaunchedEffect(state.capturedImageUri) {
@ -78,100 +100,67 @@ fun CameraScreen(
animalId = animalId animalId = animalId
) )
) )
viewModel.onEvent(CameraEvent.ClearCapturedImage) // Clear after navigation viewModel.onEvent(CameraEvent.ClearCapturedImage)
} }
} }
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
if (!state.isAutoCaptureOn) { FloatingActionButton(onClick = ::takePhoto) {
FloatingActionButton(onClick = {
val executor = ContextCompat.getMainExecutor(context)
controller.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
viewModel.onEvent(CameraEvent.ImageCaptured(image))
}
override fun onError(exception: ImageCaptureException) {
// Handle error
}
}
)
}) {
Icon(Icons.Default.Camera, contentDescription = "Capture Image") Icon(Icons.Default.Camera, contentDescription = "Capture Image")
} }
}
}, },
floatingActionButtonPosition = FabPosition.Center floatingActionButtonPosition = FabPosition.Center
) { paddingValues -> ) { paddingValues ->
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize() ) {
.padding(paddingValues), Box(
modifier = Modifier.fillMaxSize()
) { ) {
Box(modifier = Modifier.fillMaxSize()) {
CameraPreview( CameraPreview(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
controller = controller, controller = controller,
onFrame = { bitmap, rotation -> onFrame = { bitmap, rotation ->
if (orientation != null) {
viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation)) viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation))
} }
}
) )
// Overlay silhouette if orientation provided // The ML segmentation mask
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 -> state.segmentationMask?.let { mask ->
Image( Image(
bitmap = mask.asImageBitmap(), bitmap = mask.asImageBitmap(),
contentDescription = "segmentation overlay", contentDescription = "Segmentation Overlay",
modifier = Modifier.matchParentSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.FillBounds, contentScale = ContentScale.FillBounds,
alpha = 0.5f alpha = 0.5f
) )
} }
state.silhouetteMask?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Silhouette Overlay",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
alpha = 0.4f
)
} }
// Top Bar with Inference Result and Auto Capture Switch state.savedMaskBitmap?.let {
Row( Image(
modifier = Modifier bitmap = it.asImageBitmap(),
.fillMaxWidth() contentDescription = "Silhouette Overlay",
.align(Alignment.TopCenter) modifier = Modifier.fillMaxSize(),
.padding(16.dp), contentScale = ContentScale.Fit,
horizontalArrangement = Arrangement.SpaceBetween, alpha = 0.4f
verticalAlignment = Alignment.CenterVertically
) {
if (state.inferenceResult != null) {
Text(text = state.inferenceResult!!, color = androidx.compose.ui.graphics.Color.White)
} else {
Box(modifier = Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Auto Capture", color = androidx.compose.ui.graphics.Color.White)
Switch(
checked = state.isAutoCaptureOn,
onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) }
) )
} }
} }
if (state.isCapturing) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
} }
} }
} }

View File

@ -6,101 +6,144 @@ 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.ml.AIModel
import com.example.livingai.domain.repository.CameraRepository import com.example.livingai.domain.repository.CameraRepository
import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.utils.ScreenDimensions
import com.example.livingai.utils.SilhouetteManager import com.example.livingai.utils.SilhouetteManager
import com.example.livingai.utils.calculateDistance
import com.example.livingai.utils.fitImageToCrop
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 java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.sync.withLock import kotlin.math.roundToInt
class CameraViewModel( class CameraViewModel(
private val cameraRepository: CameraRepository, private val cameraRepository: CameraRepository,
private val aiModel: AIModel private val aiModel: AIModel,
private val screenDims: ScreenDimensions,
private val appDataUseCases: AppDataUseCases
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(CameraUiState()) private val _state = MutableStateFlow(CameraUiState())
val state = _state.asStateFlow() val state = _state.asStateFlow()
// mutex to prevent parallel captures private val isProcessingFrame = AtomicBoolean(false)
private val captureMutex = Mutex()
init {
viewModelScope.launch {
appDataUseCases.getSettings().collect { settings ->
_state.value = _state.value.copy(
isAutoCaptureEnabled = settings.isAutoCaptureOn,
matchThreshold = settings.jaccardThreshold.roundToInt(),
distanceMethod = settings.distanceMethod
)
}
}
}
fun onEvent(event: CameraEvent) { fun onEvent(event: CameraEvent) {
when (event) { when (event) {
is CameraEvent.CaptureImage -> captureImage()
is CameraEvent.ImageCaptured -> handleImageProxy(event.imageProxy) is CameraEvent.ImageCaptured -> handleImageProxy(event.imageProxy)
is CameraEvent.FrameReceived -> handleFrame(event.bitmap, event.rotationDegrees) is CameraEvent.FrameReceived -> handleFrame(event.bitmap, event.rotationDegrees)
is CameraEvent.ToggleAutoCapture -> toggleAutoCapture()
is CameraEvent.ClearCapturedImage -> clearCaptured() is CameraEvent.ClearCapturedImage -> clearCaptured()
is CameraEvent.SetContext -> setContext(event.animalId, event.orientation) is CameraEvent.SetContext -> setContext(event.animalId, event.orientation)
is CameraEvent.AutoCaptureTriggered -> {
_state.value = _state.value.copy(shouldAutoCapture = false, isCapturing = true)
}
} }
} }
private fun setContext(animalId: String, orientation: String?) { private fun setContext(animalId: String, orientation: String?) {
_state.value = _state.value.copy(animalId = animalId, orientation = orientation) val silhouetteMask = orientation?.let { SilhouetteManager.getOriginal(it) }
} val savedMask = orientation?.let { SilhouetteManager.getInvertedPurple(it) }
private fun toggleAutoCapture() { _state.value = _state.value.copy(
_state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn) animalId = animalId,
orientation = orientation,
silhouetteMask = silhouetteMask,
savedMaskBitmap = savedMask
)
} }
private fun clearCaptured() { private fun clearCaptured() {
_state.value = _state.value.copy(capturedImage = null, capturedImageUri = null, segmentationMask = null) _state.value = _state.value.copy(
} capturedImageUri = null,
segmentationMask = null
private fun captureImage() { )
// UI should handle capture and send ImageCaptured event
} }
private fun handleImageProxy(proxy: ImageProxy) { private fun handleImageProxy(proxy: ImageProxy) {
// convert to bitmap and then save via repository
viewModelScope.launch { viewModelScope.launch {
val bitmap = cameraRepository.captureImage(proxy) val bitmap = cameraRepository.captureImage(proxy)
val animalId = _state.value.animalId ?: "unknown" val animalId = _state.value.animalId ?: "unknown"
val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation) val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation)
_state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString)) _state.value = _state.value.copy(
capturedImageUri = Uri.parse(uriString),
isCapturing = false // Reset capturing flag
)
} }
} }
private fun handleFrame(bitmap: Bitmap, rotationDegrees: Int) { private fun handleFrame(bitmap: Bitmap, rotationDegrees: Int) {
val orientation = _state.value.orientation ?: return if (_state.value.isCapturing || _state.value.shouldAutoCapture) {
return
}
if (isProcessingFrame.compareAndSet(false, true)) {
viewModelScope.launch { viewModelScope.launch {
if (captureMutex.tryLock()) {
try { try {
val result = aiModel.segmentImage(bitmap) val result = aiModel.segmentImage(bitmap)
if (result != null) { if (result != null) {
val (maskBitmap, maskBool) = result val (maskBitmap, _) = result
// Rotate the mask to match the display orientation
val rotatedMask = if (rotationDegrees != 0) { val rotatedMask = if (rotationDegrees != 0) {
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
Bitmap.createBitmap(maskBitmap, 0, 0, maskBitmap.width, maskBitmap.height, matrix, true) Bitmap.createBitmap(
maskBitmap,
0,
0,
maskBitmap.width,
maskBitmap.height,
matrix,
true
)
} else { } else {
maskBitmap maskBitmap
} }
_state.value = _state.value.copy(segmentationMask = rotatedMask) val output = if(_state.value.orientation == "front" || _state.value.orientation == "back")
fitImageToCrop(rotatedMask, screenDims.screenWidth, screenDims.screenHeight)
else
fitImageToCrop(rotatedMask, screenDims.screenHeight, screenDims.screenWidth)
val savedMask = SilhouetteManager.getBitmask("${orientation}_silhouette") _state.value = _state.value.copy(
if (savedMask != null) { segmentationMask = output
if (maskBool.size == savedMask.size) { )
val jaccard = (aiModel as AIModelImpl).jaccardIndex(maskBool, savedMask)
if (_state.value.isAutoCaptureOn && jaccard > 0.5) { if (_state.value.isAutoCaptureEnabled &&
val animalId = _state.value.animalId ?: "unknown" _state.value.savedMaskBitmap != null &&
val uriString = cameraRepository.saveImage(bitmap, animalId, orientation) output != null
_state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString)) ) {
} val isValidCapture = calculateDistance(
_state.value.distanceMethod,
_state.value.savedMaskBitmap!!,
output,
_state.value.matchThreshold
)
_state.value = _state.value.copy(inferenceResult = "Jaccard: %.2f".format(jaccard)) if (isValidCapture) {
_state.value = _state.value.copy(shouldAutoCapture = true)
} }
} }
} else {
_state.value = _state.value.copy(
segmentationMask = null
)
} }
} finally { } finally {
captureMutex.unlock() isProcessingFrame.set(false)
} }
} }
} }
@ -110,18 +153,21 @@ class CameraViewModel(
data class CameraUiState( data class CameraUiState(
val animalId: String? = null, val animalId: String? = null,
val orientation: String? = null, val orientation: String? = null,
val capturedImage: Any? = null,
val capturedImageUri: Uri? = null, val capturedImageUri: Uri? = null,
val inferenceResult: String? = null, val segmentationMask: Bitmap? = null,
val isAutoCaptureOn: Boolean = false, val savedMaskBitmap: Bitmap? = null,
val segmentationMask: Bitmap? = null val silhouetteMask: Bitmap? = null,
val isCapturing: Boolean = false,
val isAutoCaptureEnabled: Boolean = false,
val matchThreshold: Int = 50,
val distanceMethod: String = "Jaccard",
val shouldAutoCapture: Boolean = false
) )
sealed class CameraEvent { sealed class CameraEvent {
object CaptureImage : CameraEvent()
data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent() data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent()
data class FrameReceived(val bitmap: Bitmap, val rotationDegrees: Int) : CameraEvent() data class FrameReceived(val bitmap: Bitmap, val rotationDegrees: Int) : 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()
object AutoCaptureTriggered : CameraEvent()
} }

View File

@ -3,6 +3,7 @@ package com.example.livingai.pages.camera
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentValues import android.content.ContentValues
import android.content.pm.ActivityInfo
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
@ -12,14 +13,20 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.CameraController import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.video.AudioConfig import androidx.camera.view.video.AudioConfig
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -31,12 +38,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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 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.ui.theme.RecordingRed
import com.example.livingai.utils.SetScreenOrientation
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.io.File import java.io.File
@ -47,6 +58,8 @@ fun VideoRecordScreen(
navController: NavController, navController: NavController,
animalId: String animalId: String
) { ) {
SetScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
val context = LocalContext.current val context = LocalContext.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var recording by remember { mutableStateOf<Recording?>(null) } var recording by remember { mutableStateOf<Recording?>(null) }
@ -116,7 +129,8 @@ fun VideoRecordScreen(
} }
} }
} }
} },
containerColor = if (state.isRecording) RecordingRed else FloatingActionButtonDefaults.containerColor
) { ) {
Icon( Icon(
imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.Videocam, imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.Videocam,
@ -127,9 +141,7 @@ fun VideoRecordScreen(
floatingActionButtonPosition = FabPosition.Center floatingActionButtonPosition = FabPosition.Center
) { paddingValues -> ) { paddingValues ->
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CameraPreview( CameraPreview(
@ -137,6 +149,29 @@ fun VideoRecordScreen(
controller = controller, controller = controller,
onFrame = { _, _ -> } onFrame = { _, _ -> }
) )
// Overlay
Column(
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
)
Spacer(
modifier = Modifier
.height(300.dp) // Constant height for the transparent area
.fillMaxWidth()
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
)
}
} }
} }
} }

View File

@ -4,14 +4,14 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.pages.navigation.Route import com.example.livingai.pages.navigation.Route
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
class HomeViewModel( class HomeViewModel(
private val appEntryUseCases: AppEntryUseCases private val appDataUseCases: AppDataUseCases
): ViewModel() { ): ViewModel() {
private val _splashCondition = mutableStateOf(true) private val _splashCondition = mutableStateOf(true)
val splashCondition: State<Boolean> = _splashCondition val splashCondition: State<Boolean> = _splashCondition
@ -20,7 +20,7 @@ class HomeViewModel(
val startDestination: State<Route> = _startDestination val startDestination: State<Route> = _startDestination
init { init {
appEntryUseCases.readAppEntry().onEach { shouldStartFromHomeScreen -> appDataUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
if(shouldStartFromHomeScreen){ if(shouldStartFromHomeScreen){
_startDestination.value = Route.HomeNavigation _startDestination.value = Route.HomeNavigation
}else{ }else{

View File

@ -2,14 +2,12 @@ package com.example.livingai.pages.onboarding
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.domain.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository import com.example.livingai.domain.usecases.AppDataUseCases
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class OnBoardingViewModel( class OnBoardingViewModel(
private val appEntryUseCases: AppEntryUseCases, private val appDataUseCases: AppDataUseCases
private val settingsRepository: SettingsRepository
): ViewModel() { ): ViewModel() {
fun onEvent(event: OnBoardingEvents) { fun onEvent(event: OnBoardingEvents) {
when(event) { when(event) {
@ -24,7 +22,7 @@ class OnBoardingViewModel(
private fun saveAppEntry() { private fun saveAppEntry() {
viewModelScope.launch { viewModelScope.launch {
appEntryUseCases.saveAppEntry() appDataUseCases.saveAppEntry()
} }
} }
@ -32,7 +30,7 @@ class OnBoardingViewModel(
viewModelScope.launch { viewModelScope.launch {
// Preserving default for other settings or we could fetch existing if needed // Preserving default for other settings or we could fetch existing if needed
// For onboarding, assuming defaults for others is fine. // For onboarding, assuming defaults for others is fine.
settingsRepository.saveSettings(SettingsData(language = language, isAutoCaptureOn = false)) appDataUseCases.saveSettings(SettingsData(language = language, isAutoCaptureOn = false))
} }
} }
} }

View File

@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -24,6 +26,7 @@ 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.LabeledDropdown import com.example.livingai.pages.components.LabeledDropdown
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -41,6 +44,8 @@ fun SettingsScreen(
// Find the display name for the currently selected language value // Find the display name for the currently selected language value
val selectedLanguageEntry = languageMap.entries.find { it.value == settings.language }?.key ?: languageEntries.first() val selectedLanguageEntry = languageMap.entries.find { it.value == settings.language }?.key ?: languageEntries.first()
val distanceMethods = listOf("Jaccard", "Euclidean", "Hamming")
CommonScaffold( CommonScaffold(
navController = navController, navController = navController,
title = stringResource(id = R.string.top_bar_settings) title = stringResource(id = R.string.top_bar_settings)
@ -80,6 +85,41 @@ fun SettingsScreen(
} }
) )
} }
// Jaccard Threshold Slider and Distance Method
if (settings.isAutoCaptureOn) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
) {
LabeledDropdown(
labelRes = R.string.distance_method,
options = distanceMethods,
selected = settings.distanceMethod,
onSelected = { selectedMethod ->
viewModel.saveSettings(settings.copy(distanceMethod = selectedMethod))
},
modifier = Modifier.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "Match Threshold")
Text(text = "${settings.jaccardThreshold.roundToInt()}%")
}
Slider(
value = settings.jaccardThreshold,
onValueChange = { newValue ->
viewModel.saveSettings(settings.copy(jaccardThreshold = newValue))
},
valueRange = 1f..100f,
steps = 99
)
}
}
} }
} }
} }

View File

@ -1,11 +1,9 @@
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.domain.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository import com.example.livingai.domain.usecases.AppDataUseCases
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
@ -13,8 +11,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SettingsViewModel( class SettingsViewModel(
private val settingsRepository: SettingsRepository, private val appDataUseCases: AppDataUseCases
private val appContext: Context
) : ViewModel() { ) : ViewModel() {
private val _settings = MutableStateFlow(SettingsData("en", false)) private val _settings = MutableStateFlow(SettingsData("en", false))
@ -22,23 +19,17 @@ class SettingsViewModel(
init { init {
getSettings() getSettings()
// apply locale initially
settingsRepository.getSettings().onEach {
LocaleHelper.applyLocale(appContext, it.language)
}.launchIn(viewModelScope)
} }
private fun getSettings() { private fun getSettings() {
settingsRepository.getSettings().onEach { appDataUseCases.getSettings().onEach {
_settings.value = it _settings.value = it
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
} }
fun saveSettings(settings: SettingsData) { fun saveSettings(settings: SettingsData) {
viewModelScope.launch { viewModelScope.launch {
settingsRepository.saveSettings(settings) appDataUseCases.saveSettings(settings)
// apply locale immediately
LocaleHelper.applyLocale(appContext, settings.language)
} }
} }
} }

View File

@ -88,3 +88,6 @@ val alternate_container_contentCol = md_theme_light_onTertiaryContainer
val primary_backgroundCol = md_theme_light_background val primary_backgroundCol = md_theme_light_background
val secondary_backgroundCol = md_theme_light_surface val secondary_backgroundCol = md_theme_light_surface
val alternate_backgroundCol = md_theme_light_surfaceVariant val alternate_backgroundCol = md_theme_light_surfaceVariant
// Video Recording
val RecordingRed = Color(0xFFFF0000)

View File

@ -14,4 +14,6 @@ object Constants {
"rightangle", "rightangle",
"angleview" "angleview"
) )
const val JACCARD_THRESHOLD = 85
} }

View File

@ -0,0 +1,98 @@
package com.example.livingai.utils
import android.graphics.Bitmap
import kotlin.math.sqrt
fun calculateHammingDistance(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
if (mask1.width != mask2.width || mask1.height != mask2.height) return false
val width = mask1.width
val height = mask1.height
val p1 = IntArray(width * height)
val p2 = IntArray(width * height)
mask1.getPixels(p1, 0, width, 0, 0, width, height)
mask2.getPixels(p2, 0, width, 0, 0, width, height)
var distance = 0
for (i in p1.indices) {
val a = (p1[i] ushr 24) > 0
val b = (p2[i] ushr 24) > 0
if (a != b) distance++
}
val total = width * height
val allowed = total * (100 - thresholdPercent) / 100
return distance <= allowed
}
fun calculateEuclideanDistance(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
if (mask1.width != mask2.width || mask1.height != mask2.height) return false
val width = mask1.width
val height = mask1.height
val p1 = IntArray(width * height)
val p2 = IntArray(width * height)
mask1.getPixels(p1, 0, width, 0, 0, width, height)
mask2.getPixels(p2, 0, width, 0, 0, width, height)
var sum = 0L
for (i in p1.indices) {
val v1 = if ((p1[i] ushr 24) > 0) 255 else 0
val v2 = if ((p2[i] ushr 24) > 0) 255 else 0
val diff = v1 - v2
sum += diff * diff
}
val dist = sqrt(sum.toDouble())
val max = sqrt((width * height).toDouble()) * 255.0
val allowed = max * (100 - thresholdPercent) / 100.0
return dist <= allowed
}
fun calculateJaccardSimilarity(mask1: Bitmap, mask2: Bitmap, thresholdPercent: Int): Boolean {
if (mask1.width != mask2.width || mask1.height != mask2.height) return false
val width = mask1.width
val height = mask1.height
val p1 = IntArray(width * height)
val p2 = IntArray(width * height)
mask1.getPixels(p1, 0, width, 0, 0, width, height)
mask2.getPixels(p2, 0, width, 0, 0, width, height)
var intersection = 0
var union = 0
for (i in p1.indices) {
val a = ((p1[i] ushr 24) and 0xFF) > 0
val b = ((p2[i] ushr 24) and 0xFF) > 0
if (a && b) intersection++
if (a || b) union++
}
if (union == 0) return false
val score = (intersection.toDouble() / union.toDouble()) * 100.0
return score >= thresholdPercent
}
fun calculateDistance(
method: String,
mask1: Bitmap,
mask2: Bitmap,
thresholdPercent: Int
): Boolean {
return when (method) {
"Hamming" -> calculateHammingDistance(mask1, mask2, thresholdPercent)
"Euclidean" -> calculateEuclideanDistance(mask1, mask2, thresholdPercent)
"Jaccard" -> calculateJaccardSimilarity(mask1, mask2, thresholdPercent)
else -> false
}
}

View File

@ -0,0 +1,68 @@
package com.example.livingai.utils
import android.graphics.Bitmap
import android.graphics.Canvas
fun fitCenterToScreen(
bitmap: Bitmap,
screenW: Int,
screenH: Int
): Bitmap {
val srcW = bitmap.width
val srcH = bitmap.height
if (srcW == 0 || srcH == 0) return bitmap
val scale = minOf(
screenW.toFloat() / srcW,
screenH.toFloat() / srcH
)
val newW = (srcW * scale).toInt()
val newH = (srcH * scale).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, newW, newH, true)
val output = Bitmap.createBitmap(screenW, screenH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val left = (screenW - newW) / 2f
val top = (screenH - newH) / 2f
canvas.drawBitmap(scaled, left, top, null)
return output
}
fun fitImageToCrop(
bitmap: Bitmap,
screenW: Int,
screenH: Int
): Bitmap {
val srcW = bitmap.width
val srcH = bitmap.height
if (srcW == 0 || srcH == 0) return bitmap
val scale = maxOf(
screenW.toFloat() / srcW,
screenH.toFloat() / srcH
)
val newW = (srcW * scale).toInt()
val newH = (srcH * scale).toInt()
val scaled = Bitmap.createScaledBitmap(bitmap, newW, newH, true)
val output = Bitmap.createBitmap(screenW, screenH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val left = (screenW - newW) / 2f
val top = (screenH - newH) / 2f
canvas.drawBitmap(scaled, left, top, null)
return output
}

View File

@ -0,0 +1,6 @@
package com.example.livingai.utils
data class ScreenDimensions(
val screenWidth: Int,
val screenHeight: Int
)

View File

@ -1,6 +1,8 @@
package com.example.livingai.utils package com.example.livingai.utils
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -9,12 +11,25 @@ import androidx.compose.ui.platform.LocalContext
fun SetScreenOrientation(orientation: Int) { fun SetScreenOrientation(orientation: Int) {
val context = LocalContext.current val context = LocalContext.current
DisposableEffect(Unit) { DisposableEffect(Unit) {
val activity = context as Activity val activity = context.findActivity()
if (activity != null) {
val originalOrientation = activity.requestedOrientation val originalOrientation = activity.requestedOrientation
activity.requestedOrientation = orientation activity.requestedOrientation = orientation
onDispose { onDispose {
// restore original orientation when view disappears // restore original orientation when view disappears
activity.requestedOrientation = originalOrientation activity.requestedOrientation = originalOrientation
} }
} else {
onDispose { }
} }
} }
}
fun Context.findActivity(): Activity? {
var context = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}

View File

@ -5,73 +5,65 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.util.Log
import androidx.annotation.DrawableRes import com.example.livingai.R
import androidx.core.graphics.createBitmap
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
object SilhouetteManager { object SilhouetteManager {
private val originals = ConcurrentHashMap<String, Bitmap>() private val originals = ConcurrentHashMap<String, Bitmap>()
private val inverted = ConcurrentHashMap<String, Bitmap>() private val invertedPurple = ConcurrentHashMap<String, Bitmap>()
private val bitmasks = ConcurrentHashMap<String, BooleanArray>()
fun initialize(context: Context, names: List<String>) { fun initialize(context: Context, width: Int, height: Int) {
names.forEach { name -> val resources = context.resources
val resId = context.resources.getIdentifier(name, "drawable", context.packageName) val silhouetteList = mapOf(
if (resId != 0) { "front" to R.drawable.front_silhouette,
val bmp = BitmapFactory.decodeResource(context.resources, resId) "back" to R.drawable.back_silhouette,
"left" to R.drawable.left_silhouette,
"right" to R.drawable.right_silhouette,
"leftangle" to R.drawable.leftangle_silhouette,
"rightangle" to R.drawable.rightangle_silhouette,
"angleview" to R.drawable.angleview_silhouette
)
silhouetteList.entries.toList().forEach { (name, resId) ->
val bmp = BitmapFactory.decodeResource(resources, resId)
originals[name] = bmp originals[name] = bmp
val inv = invertBitmap(bmp) Log.d("Silhouette", "Dims: ${width} x ${height}")
inverted[name] = inv if (name == "front" || name == "back")
bitmasks[name] = createBitmask(inv) invertedPurple[name] = createInvertedPurpleBitmap(bmp, width, height)
} else
invertedPurple[name] = createInvertedPurpleBitmap(bmp, height, width)
Log.d("Silhouette", "Dims Mask: ${invertedPurple[name]?.width} x ${invertedPurple[name]?.height}")
} }
} }
fun getOriginal(name: String): Bitmap? = originals[name] fun getOriginal(name: String): Bitmap? = originals[name]
fun getInverted(name: String): Bitmap? = inverted[name] fun getInvertedPurple(name: String): Bitmap? = invertedPurple[name]
fun getBitmask(name: String): BooleanArray? = bitmasks[name]
private fun invertBitmap(src: Bitmap): Bitmap { private fun createInvertedPurpleBitmap(
val bmOut = Bitmap.createBitmap(src.width, src.height, src.config ?: Bitmap.Config.ARGB_8888) src: Bitmap,
val canvas = Canvas(bmOut) targetWidth: Int,
val paint = Paint() targetHeight: Int
val colorMatrix = android.graphics.ColorMatrix( ): Bitmap {
floatArrayOf(
-1f, 0f, 0f, 0f, 255f, val width = src.width
0f, -1f, 0f, 0f, 255f, val height = src.height
0f, 0f, -1f, 0f, 255f,
0f, 0f, 0f, 1f, 0f val pixels = IntArray(width * height)
) src.getPixels(pixels, 0, width, 0, 0, width, height)
)
paint.colorFilter = android.graphics.ColorMatrixColorFilter(colorMatrix) val purple = Color.argb(255, 128, 0, 128)
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) { for (i in pixels.indices) {
val c = pixels[i] val alpha = pixels[i] ushr 24
val alpha = Color.alpha(c) pixels[i] = if (alpha == 0) purple else 0x00000000
// 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. val inverted = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
// We want bitmask where the silhouette is.
// So we look for non-transparent pixels. return fitCenterToScreen(inverted, targetWidth, targetHeight)
mask[i] = alpha > 16
}
return mask
} }
} }

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -82,4 +82,6 @@
<string name="label_leftangle">Left Angle View</string> <string name="label_leftangle">Left Angle View</string>
<string name="label_rightangle">Right Angle View</string> <string name="label_rightangle">Right Angle View</string>
<string name="label_angleview">Angle View</string> <string name="label_angleview">Angle View</string>
<string name="distance_method">Distance Method</string>
</resources> </resources>

View File

@ -16,6 +16,7 @@ pagingCommon = "3.3.6"
ui = "1.9.5" ui = "1.9.5"
kotlinxSerialization = "1.6.3" kotlinxSerialization = "1.6.3"
koin = "4.1.1" koin = "4.1.1"
window = "1.5.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -42,6 +43,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }