camera changes
|
|
@ -45,6 +45,7 @@ android {
|
|||
dependencies {
|
||||
implementation(libs.androidx.paging.common)
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.window)
|
||||
val cameraxVersion = "1.5.0-alpha03"
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.cardview:cardview:1.0.0")
|
||||
|
|
|
|||
|
|
@ -3,25 +3,32 @@ package com.example.livingai
|
|||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||
import com.example.livingai.pages.home.HomeViewModel
|
||||
import com.example.livingai.pages.navigation.NavGraph
|
||||
import com.example.livingai.pages.navigation.Route
|
||||
import com.example.livingai.ui.theme.LivingAITheme
|
||||
import com.example.livingai.utils.LocaleHelper
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel by viewModel<HomeViewModel>()
|
||||
private val appDataUseCases: AppDataUseCases by inject()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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 {
|
||||
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 {
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(
|
||||
|
|
@ -60,4 +76,5 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,35 +4,36 @@ import android.content.Context
|
|||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import androidx.window.layout.WindowMetricsCalculator
|
||||
import coil.ImageLoader
|
||||
import coil.decode.SvgDecoder
|
||||
import com.example.livingai.data.local.CSVDataSource
|
||||
import com.example.livingai.data.manager.LocalUserManagerImpl
|
||||
import com.example.livingai.data.ml.AIModelImpl
|
||||
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.AnimalProfileRepositoryImpl
|
||||
import com.example.livingai.data.repository.business.AnimalRatingRepositoryImpl
|
||||
import com.example.livingai.data.repository.media.CameraRepositoryImpl
|
||||
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.repository.AppDataRepository
|
||||
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.business.AnimalDetailsRepository
|
||||
import com.example.livingai.domain.repository.business.AnimalProfileRepository
|
||||
import com.example.livingai.domain.repository.business.AnimalRatingRepository
|
||||
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.GetAnimalDetails
|
||||
import com.example.livingai.domain.usecases.GetAnimalProfiles
|
||||
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.ProfileListing.ProfileListingUseCase
|
||||
import com.example.livingai.domain.usecases.ReadAppEntry
|
||||
import com.example.livingai.domain.usecases.SaveAppEntry
|
||||
import com.example.livingai.domain.usecases.ReadAppEntryUseCase
|
||||
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.SetAnimalRatings
|
||||
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.CoroutineDispatchers
|
||||
import com.example.livingai.utils.DefaultCoroutineDispatchers
|
||||
import com.example.livingai.utils.ScreenDimensions
|
||||
import com.example.livingai.utils.SilhouetteManager
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
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)
|
||||
|
||||
val appModule = module {
|
||||
single<LocalUserManager> { LocalUserManagerImpl(get()) }
|
||||
single<DataStore<Preferences>> { androidContext().dataStore }
|
||||
|
||||
single<AppDataRepository> { AppDataRepositoryImpl(get()) }
|
||||
|
||||
single {
|
||||
AppEntryUseCases(
|
||||
readAppEntry = ReadAppEntry(get()),
|
||||
saveAppEntry = SaveAppEntry(get())
|
||||
AppDataUseCases(
|
||||
getSettings = GetSettingsUseCase(get()),
|
||||
saveSettings = SaveSettingsUseCase(get()),
|
||||
readAppEntry = ReadAppEntryUseCase(get()),
|
||||
saveAppEntry = SaveAppEntryUseCase(get())
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -86,11 +91,16 @@ val appModule = module {
|
|||
}
|
||||
|
||||
// Initialize silhouettes once
|
||||
single(createdAtStart = true) {
|
||||
single<ScreenDimensions>(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)
|
||||
val metrics = WindowMetricsCalculator.getOrCreate()
|
||||
.computeCurrentWindowMetrics(ctx)
|
||||
|
||||
val bounds = metrics.bounds
|
||||
val screenWidth = bounds.width()
|
||||
val screenHeight = bounds.height()
|
||||
SilhouetteManager.initialize(ctx, screenWidth, screenHeight)
|
||||
ScreenDimensions(screenWidth, screenHeight)
|
||||
}
|
||||
|
||||
// ML Model
|
||||
|
|
@ -100,7 +110,6 @@ val appModule = module {
|
|||
single<AnimalProfileRepository> { AnimalProfileRepositoryImpl(get()) }
|
||||
single<AnimalDetailsRepository> { AnimalDetailsRepositoryImpl(get()) }
|
||||
single<AnimalRatingRepository> { AnimalRatingRepositoryImpl(get()) }
|
||||
single<SettingsRepository> { SettingsRepositoryImpl(get()) }
|
||||
single<CameraRepository> { CameraRepositoryImpl(get(), androidContext()) }
|
||||
single<VideoRepository> { VideoRepositoryImpl(get()) }
|
||||
|
||||
|
|
@ -129,13 +138,13 @@ val appModule = module {
|
|||
|
||||
// ViewModels
|
||||
viewModel { HomeViewModel(get()) }
|
||||
viewModel { OnBoardingViewModel(get(), get()) }
|
||||
viewModel { OnBoardingViewModel(get()) }
|
||||
viewModel { (savedStateHandle: androidx.lifecycle.SavedStateHandle?) ->
|
||||
AddProfileViewModel(get(), get(), get(), androidContext(), savedStateHandle)
|
||||
}
|
||||
viewModel { ListingsViewModel(get()) }
|
||||
viewModel { SettingsViewModel(get(), androidContext()) }
|
||||
viewModel { SettingsViewModel(get()) }
|
||||
viewModel { RatingViewModel(get(), get(), get(), get()) }
|
||||
viewModel { CameraViewModel(get(), get()) }
|
||||
viewModel { CameraViewModel(get(), get(), get(), get()) }
|
||||
viewModel { VideoViewModel() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@ import kotlinx.serialization.Serializable
|
|||
@Serializable
|
||||
data class SettingsData(
|
||||
val language: String = "en",
|
||||
val isAutoCaptureOn: Boolean = false
|
||||
val isAutoCaptureOn: Boolean = false,
|
||||
val jaccardThreshold: Float = 50f,
|
||||
val distanceMethod: String = "Jaccard"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -6,20 +6,15 @@ import androidx.camera.core.ImageCaptureException
|
|||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.filled.Camera
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -30,14 +25,12 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.livingai.pages.components.CameraPreview
|
||||
import com.example.livingai.pages.components.PermissionWrapper
|
||||
import com.example.livingai.pages.navigation.Route
|
||||
import com.example.livingai.utils.SetScreenOrientation
|
||||
import com.example.livingai.utils.SilhouetteManager
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
|
|
@ -47,12 +40,22 @@ fun CameraScreen(
|
|||
orientation: String? = null,
|
||||
animalId: String
|
||||
) {
|
||||
val orientationLock = when (orientation) {
|
||||
"front", "back" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
val isLandscape = when (orientation) {
|
||||
"front", "back" -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
val orientationLock = if (isLandscape) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
SetScreenOrientation(orientationLock)
|
||||
|
||||
LaunchedEffect(animalId, orientation) {
|
||||
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation))
|
||||
}
|
||||
|
||||
PermissionWrapper {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
|
@ -63,8 +66,27 @@ fun CameraScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(animalId, orientation) {
|
||||
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation))
|
||||
fun takePhoto() {
|
||||
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) {
|
||||
|
|
@ -78,99 +100,66 @@ fun CameraScreen(
|
|||
animalId = animalId
|
||||
)
|
||||
)
|
||||
viewModel.onEvent(CameraEvent.ClearCapturedImage) // Clear after navigation
|
||||
viewModel.onEvent(CameraEvent.ClearCapturedImage)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
if (!state.isAutoCaptureOn) {
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
}) {
|
||||
FloatingActionButton(onClick = ::takePhoto) {
|
||||
Icon(Icons.Default.Camera, contentDescription = "Capture Image")
|
||||
}
|
||||
}
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.Center
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CameraPreview(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
controller = controller,
|
||||
onFrame = { bitmap, rotation ->
|
||||
if (orientation != null) {
|
||||
viewModel.onEvent(CameraEvent.FrameReceived(bitmap, rotation))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Overlay silhouette if orientation provided
|
||||
if (state.orientation != null) {
|
||||
val silhouetteName = "${state.orientation}_silhouette"
|
||||
val bmp = SilhouetteManager.getOriginal(silhouetteName)
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "overlay",
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alpha = 0.5f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay Segmentation Mask
|
||||
// The ML segmentation mask
|
||||
state.segmentationMask?.let { mask ->
|
||||
Image(
|
||||
bitmap = mask.asImageBitmap(),
|
||||
contentDescription = "segmentation overlay",
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentDescription = "Segmentation Overlay",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
alpha = 0.5f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Top Bar with Inference Result and Auto Capture Switch
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
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) }
|
||||
state.silhouetteMask?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "Silhouette Overlay",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
alpha = 0.4f
|
||||
)
|
||||
}
|
||||
|
||||
state.savedMaskBitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "Silhouette Overlay",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
alpha = 0.4f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isCapturing) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,101 +6,144 @@ import android.net.Uri
|
|||
import androidx.camera.core.ImageProxy
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.livingai.data.ml.AIModelImpl
|
||||
import com.example.livingai.domain.ml.AIModel
|
||||
import com.example.livingai.domain.repository.CameraRepository
|
||||
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||
import com.example.livingai.utils.ScreenDimensions
|
||||
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.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class CameraViewModel(
|
||||
private val cameraRepository: CameraRepository,
|
||||
private val aiModel: AIModel
|
||||
private val aiModel: AIModel,
|
||||
private val screenDims: ScreenDimensions,
|
||||
private val appDataUseCases: AppDataUseCases
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CameraUiState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
// mutex to prevent parallel captures
|
||||
private val captureMutex = Mutex()
|
||||
private val isProcessingFrame = AtomicBoolean(false)
|
||||
|
||||
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) {
|
||||
when (event) {
|
||||
is CameraEvent.CaptureImage -> captureImage()
|
||||
is CameraEvent.ImageCaptured -> handleImageProxy(event.imageProxy)
|
||||
is CameraEvent.FrameReceived -> handleFrame(event.bitmap, event.rotationDegrees)
|
||||
is CameraEvent.ToggleAutoCapture -> toggleAutoCapture()
|
||||
is CameraEvent.ClearCapturedImage -> clearCaptured()
|
||||
is CameraEvent.SetContext -> setContext(event.animalId, event.orientation)
|
||||
is CameraEvent.AutoCaptureTriggered -> {
|
||||
_state.value = _state.value.copy(shouldAutoCapture = false, isCapturing = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(isAutoCaptureOn = !_state.value.isAutoCaptureOn)
|
||||
_state.value = _state.value.copy(
|
||||
animalId = animalId,
|
||||
orientation = orientation,
|
||||
silhouetteMask = silhouetteMask,
|
||||
savedMaskBitmap = savedMask
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
_state.value = _state.value.copy(
|
||||
capturedImageUri = null,
|
||||
segmentationMask = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleImageProxy(proxy: ImageProxy) {
|
||||
// convert to bitmap and then save via repository
|
||||
viewModelScope.launch {
|
||||
val bitmap = cameraRepository.captureImage(proxy)
|
||||
val animalId = _state.value.animalId ?: "unknown"
|
||||
val uriString = cameraRepository.saveImage(bitmap, animalId, _state.value.orientation)
|
||||
_state.value = _state.value.copy(capturedImageUri = Uri.parse(uriString))
|
||||
_state.value = _state.value.copy(
|
||||
capturedImageUri = Uri.parse(uriString),
|
||||
isCapturing = false // Reset capturing flag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (captureMutex.tryLock()) {
|
||||
try {
|
||||
val result = aiModel.segmentImage(bitmap)
|
||||
if (result != null) {
|
||||
val (maskBitmap, maskBool) = result
|
||||
val (maskBitmap, _) = 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)
|
||||
Bitmap.createBitmap(
|
||||
maskBitmap,
|
||||
0,
|
||||
0,
|
||||
maskBitmap.width,
|
||||
maskBitmap.height,
|
||||
matrix,
|
||||
true
|
||||
)
|
||||
} else {
|
||||
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")
|
||||
if (savedMask != null) {
|
||||
if (maskBool.size == savedMask.size) {
|
||||
val jaccard = (aiModel as AIModelImpl).jaccardIndex(maskBool, savedMask)
|
||||
_state.value = _state.value.copy(
|
||||
segmentationMask = output
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
if (_state.value.isAutoCaptureEnabled &&
|
||||
_state.value.savedMaskBitmap != null &&
|
||||
output != null
|
||||
) {
|
||||
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 {
|
||||
captureMutex.unlock()
|
||||
isProcessingFrame.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -110,18 +153,21 @@ class CameraViewModel(
|
|||
data class CameraUiState(
|
||||
val animalId: String? = null,
|
||||
val orientation: String? = null,
|
||||
val capturedImage: Any? = null,
|
||||
val capturedImageUri: Uri? = null,
|
||||
val inferenceResult: String? = null,
|
||||
val isAutoCaptureOn: Boolean = false,
|
||||
val segmentationMask: Bitmap? = null
|
||||
val segmentationMask: Bitmap? = null,
|
||||
val savedMaskBitmap: 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 {
|
||||
object CaptureImage : CameraEvent()
|
||||
data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent()
|
||||
data class FrameReceived(val bitmap: Bitmap, val rotationDegrees: Int) : CameraEvent()
|
||||
object ToggleAutoCapture : CameraEvent()
|
||||
object ClearCapturedImage : CameraEvent()
|
||||
data class SetContext(val animalId: String, val orientation: String?) : CameraEvent()
|
||||
object AutoCaptureTriggered : CameraEvent()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.example.livingai.pages.camera
|
|||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentValues
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import androidx.camera.core.CameraSelector
|
||||
|
|
@ -12,14 +13,20 @@ import androidx.camera.video.VideoRecordEvent
|
|||
import androidx.camera.view.CameraController
|
||||
import androidx.camera.view.LifecycleCameraController
|
||||
import androidx.camera.view.video.AudioConfig
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Videocam
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -31,12 +38,16 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import com.example.livingai.pages.components.CameraPreview
|
||||
import com.example.livingai.pages.components.PermissionWrapper
|
||||
import com.example.livingai.pages.navigation.Route
|
||||
import com.example.livingai.ui.theme.RecordingRed
|
||||
import com.example.livingai.utils.SetScreenOrientation
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import java.io.File
|
||||
|
||||
|
|
@ -47,6 +58,8 @@ fun VideoRecordScreen(
|
|||
navController: NavController,
|
||||
animalId: String
|
||||
) {
|
||||
SetScreenOrientation(orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
|
||||
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsState()
|
||||
var recording by remember { mutableStateOf<Recording?>(null) }
|
||||
|
|
@ -116,7 +129,8 @@ fun VideoRecordScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = if (state.isRecording) RecordingRed else FloatingActionButtonDefaults.containerColor
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.Videocam,
|
||||
|
|
@ -127,9 +141,7 @@ fun VideoRecordScreen(
|
|||
floatingActionButtonPosition = FabPosition.Center
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CameraPreview(
|
||||
|
|
@ -137,6 +149,29 @@ fun VideoRecordScreen(
|
|||
controller = controller,
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import androidx.compose.runtime.State
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
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 kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
class HomeViewModel(
|
||||
private val appEntryUseCases: AppEntryUseCases
|
||||
private val appDataUseCases: AppDataUseCases
|
||||
): ViewModel() {
|
||||
private val _splashCondition = mutableStateOf(true)
|
||||
val splashCondition: State<Boolean> = _splashCondition
|
||||
|
|
@ -20,7 +20,7 @@ class HomeViewModel(
|
|||
val startDestination: State<Route> = _startDestination
|
||||
|
||||
init {
|
||||
appEntryUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
|
||||
appDataUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
|
||||
if(shouldStartFromHomeScreen){
|
||||
_startDestination.value = Route.HomeNavigation
|
||||
}else{
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ package com.example.livingai.pages.onboarding
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.livingai.data.local.model.SettingsData
|
||||
import com.example.livingai.domain.repository.SettingsRepository
|
||||
import com.example.livingai.domain.usecases.AppEntry.AppEntryUseCases
|
||||
import com.example.livingai.domain.model.SettingsData
|
||||
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OnBoardingViewModel(
|
||||
private val appEntryUseCases: AppEntryUseCases,
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val appDataUseCases: AppDataUseCases
|
||||
): ViewModel() {
|
||||
fun onEvent(event: OnBoardingEvents) {
|
||||
when(event) {
|
||||
|
|
@ -24,7 +22,7 @@ class OnBoardingViewModel(
|
|||
|
||||
private fun saveAppEntry() {
|
||||
viewModelScope.launch {
|
||||
appEntryUseCases.saveAppEntry()
|
||||
appDataUseCases.saveAppEntry()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +30,7 @@ class OnBoardingViewModel(
|
|||
viewModelScope.launch {
|
||||
// Preserving default for other settings or we could fetch existing if needed
|
||||
// For onboarding, assuming defaults for others is fine.
|
||||
settingsRepository.saveSettings(SettingsData(language = language, isAutoCaptureOn = false))
|
||||
appDataUseCases.saveSettings(SettingsData(language = language, isAutoCaptureOn = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
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.LabeledDropdown
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -41,6 +44,8 @@ fun SettingsScreen(
|
|||
// Find the display name for the currently selected language value
|
||||
val selectedLanguageEntry = languageMap.entries.find { it.value == settings.language }?.key ?: languageEntries.first()
|
||||
|
||||
val distanceMethods = listOf("Jaccard", "Euclidean", "Hamming")
|
||||
|
||||
CommonScaffold(
|
||||
navController = navController,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
package com.example.livingai.pages.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.livingai.data.local.model.SettingsData
|
||||
import com.example.livingai.domain.repository.SettingsRepository
|
||||
import com.example.livingai.utils.LocaleHelper
|
||||
import com.example.livingai.domain.model.SettingsData
|
||||
import com.example.livingai.domain.usecases.AppDataUseCases
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
@ -13,8 +11,7 @@ import kotlinx.coroutines.flow.onEach
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
class SettingsViewModel(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val appContext: Context
|
||||
private val appDataUseCases: AppDataUseCases
|
||||
) : ViewModel() {
|
||||
|
||||
private val _settings = MutableStateFlow(SettingsData("en", false))
|
||||
|
|
@ -22,23 +19,17 @@ class SettingsViewModel(
|
|||
|
||||
init {
|
||||
getSettings()
|
||||
// apply locale initially
|
||||
settingsRepository.getSettings().onEach {
|
||||
LocaleHelper.applyLocale(appContext, it.language)
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun getSettings() {
|
||||
settingsRepository.getSettings().onEach {
|
||||
appDataUseCases.getSettings().onEach {
|
||||
_settings.value = it
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun saveSettings(settings: SettingsData) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.saveSettings(settings)
|
||||
// apply locale immediately
|
||||
LocaleHelper.applyLocale(appContext, settings.language)
|
||||
appDataUseCases.saveSettings(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,3 +88,6 @@ val alternate_container_contentCol = md_theme_light_onTertiaryContainer
|
|||
val primary_backgroundCol = md_theme_light_background
|
||||
val secondary_backgroundCol = md_theme_light_surface
|
||||
val alternate_backgroundCol = md_theme_light_surfaceVariant
|
||||
|
||||
// Video Recording
|
||||
val RecordingRed = Color(0xFFFF0000)
|
||||
|
|
|
|||
|
|
@ -14,4 +14,6 @@ object Constants {
|
|||
"rightangle",
|
||||
"angleview"
|
||||
)
|
||||
|
||||
const val JACCARD_THRESHOLD = 85
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.example.livingai.utils
|
||||
|
||||
data class ScreenDimensions(
|
||||
val screenWidth: Int,
|
||||
val screenHeight: Int
|
||||
)
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.example.livingai.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -9,12 +11,25 @@ import androidx.compose.ui.platform.LocalContext
|
|||
fun SetScreenOrientation(orientation: Int) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as Activity
|
||||
val activity = context.findActivity()
|
||||
if (activity != null) {
|
||||
val originalOrientation = activity.requestedOrientation
|
||||
activity.requestedOrientation = orientation
|
||||
onDispose {
|
||||
// restore original orientation when view disappears
|
||||
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
|
||||
}
|
||||
|
|
@ -5,73 +5,65 @@ 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 android.util.Log
|
||||
import com.example.livingai.R
|
||||
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>()
|
||||
private val invertedPurple = ConcurrentHashMap<String, Bitmap>()
|
||||
|
||||
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)
|
||||
fun initialize(context: Context, width: Int, height: Int) {
|
||||
val resources = context.resources
|
||||
val silhouetteList = mapOf(
|
||||
"front" to R.drawable.front_silhouette,
|
||||
"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
|
||||
val inv = invertBitmap(bmp)
|
||||
inverted[name] = inv
|
||||
bitmasks[name] = createBitmask(inv)
|
||||
}
|
||||
Log.d("Silhouette", "Dims: ${width} x ${height}")
|
||||
if (name == "front" || name == "back")
|
||||
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 getInverted(name: String): Bitmap? = inverted[name]
|
||||
fun getBitmask(name: String): BooleanArray? = bitmasks[name]
|
||||
fun getInvertedPurple(name: String): Bitmap? = invertedPurple[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 createInvertedPurpleBitmap(
|
||||
src: Bitmap,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int
|
||||
): Bitmap {
|
||||
|
||||
val width = src.width
|
||||
val height = src.height
|
||||
|
||||
val pixels = IntArray(width * height)
|
||||
src.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
|
||||
val purple = Color.argb(255, 128, 0, 128)
|
||||
|
||||
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).
|
||||
val alpha = pixels[i] ushr 24
|
||||
pixels[i] = if (alpha == 0) purple else 0x00000000
|
||||
}
|
||||
|
||||
// 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
|
||||
val inverted = Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
|
||||
|
||||
return fitCenterToScreen(inverted, targetWidth, targetHeight)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
|
@ -82,4 +82,6 @@
|
|||
<string name="label_leftangle">Left Angle View</string>
|
||||
<string name="label_rightangle">Right Angle View</string>
|
||||
<string name="label_angleview">Angle View</string>
|
||||
|
||||
<string name="distance_method">Distance Method</string>
|
||||
</resources>
|
||||
|
|
@ -16,6 +16,7 @@ pagingCommon = "3.3.6"
|
|||
ui = "1.9.5"
|
||||
kotlinxSerialization = "1.6.3"
|
||||
koin = "4.1.1"
|
||||
window = "1.5.1"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
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" }
|
||||
androidx-window = { group = "androidx.window", name = "window", version.ref = "window" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||