first commit

This commit is contained in:
SaiD 2025-12-04 09:42:35 +05:30
commit e0d55e3087
140 changed files with 5859 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

19
.idea/gradle.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

10
.idea/migrations.xml Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml Normal file
View File

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/studiobot.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

Binary file not shown.

View File

@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

102
app/build.gradle.kts Normal file
View File

@ -0,0 +1,102 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlinx.serialization)
}
android {
namespace = "com.example.livingai"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.example.livingai"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.paging.common)
implementation(libs.androidx.ui)
val cameraxVersion = "1.5.0-alpha03"
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.cardview:cardview:1.0.0")
implementation("com.google.android.material:material:1.12.0")
//Splash Api
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.camera:camera-core:${cameraxVersion}")
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
implementation("androidx.camera:camera-view:${cameraxVersion}")
implementation("androidx.camera:camera-video:${cameraxVersion}")
implementation("androidx.camera:camera-mlkit-vision:${cameraxVersion}")
implementation("com.google.mlkit:object-detection:17.0.2")
implementation("com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1")
//Koin
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
//Navigation (Nav3 / Type-Safe Navigation)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.datastore.preferences)
// Icons
implementation("androidx.compose.material:material-icons-extended:1.7.5")
// OpenCSV
implementation("com.opencsv:opencsv:5.7.1")
// Coil
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
// Paging
implementation("androidx.paging:paging-runtime:3.2.1")
implementation("androidx.paging:paging-compose:3.2.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.example.livingai
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.livingai", appContext.packageName)
}
}

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- Permissions for Scoped Storage on Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<application
android:name=".LivingAIApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/LivingAI.Starting.Theme">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/LivingAI.Starting.Theme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,19 @@
package com.example.livingai
import android.app.Application
import com.example.livingai.di.appModule
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.core.context.startKoin
class LivingAIApplication: Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@LivingAIApplication)
modules(appModule)
}
}
}

View File

@ -0,0 +1,51 @@
package com.example.livingai
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
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.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.example.livingai.pages.home.HomeViewModel
import com.example.livingai.pages.navigation.NavGraph
import com.example.livingai.ui.theme.LivingAITheme
import org.koin.androidx.viewmodel.ext.android.viewModel
class MainActivity : ComponentActivity() {
val viewModel by viewModel<HomeViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
installSplashScreen().apply {
setKeepOnScreenCondition {
viewModel.splashCondition.value
}
}
setContent {
LivingAITheme {
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb()
),
navigationBarStyle = SystemBarStyle.auto(
lightScrim = Color.Transparent.toArgb(),
darkScrim = Color.Transparent.toArgb()
)
)
Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.background)) {
val startDestination = viewModel.startDestination.value
NavGraph(startDestination = startDestination)
}
}
}
}
}

View File

@ -0,0 +1,48 @@
package com.example.livingai.data.local
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.example.livingai.domain.model.AnimalProfile
class AnimalDataPagingSource(
private val dataSource: CSVDataSource
) : PagingSource<Int, AnimalProfile>() {
override fun getRefreshKey(state: PagingState<Int, AnimalProfile>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AnimalProfile> {
val page = params.key ?: 0
return try {
// Since CSVDataSource reads all lines at once currently (simulated simple DB),
// we will paginate the list in memory.
val allProfiles = dataSource.getAllAnimalProfiles()
val start = page * params.loadSize
val end = minOf(start + params.loadSize, allProfiles.size)
if (start >= allProfiles.size) {
return LoadResult.Page(
data = emptyList(),
prevKey = if (page > 0) page - 1 else null,
nextKey = null
)
}
val pagedData = allProfiles.subList(start, end)
LoadResult.Page(
data = pagedData,
prevKey = if (page > 0) page - 1 else null,
nextKey = if (end < allProfiles.size) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}

View File

@ -0,0 +1,412 @@
package com.example.livingai.data.local
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.example.livingai.domain.model.*
import com.example.livingai.domain.repository.business.DataSource
import com.opencsv.CSVReader
import com.opencsv.CSVWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.*
class CSVDataSource(
private val context: Context,
private val fileName: String
) : DataSource {
private val folderName = "LivingAI"
private val mutex = Mutex()
private var cachedUri: Uri? = null
private suspend fun getCsvUri(): Uri = withContext(Dispatchers.IO) {
cachedUri?.let { return@withContext it }
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
queryOrCreateCsvQ()
} else {
legacyGetOrCreateFile()
}
cachedUri = uri
return@withContext uri
}
private fun queryOrCreateCsvQ(): Uri {
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val projection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.DISPLAY_NAME,
MediaStore.Files.FileColumns.RELATIVE_PATH
)
val cursor = context.contentResolver.query(
collection,
projection,
"${MediaStore.Files.FileColumns.DISPLAY_NAME}=?",
arrayOf(fileName),
null
)
cursor?.use {
val idCol = it.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val pathCol = it.getColumnIndexOrThrow(MediaStore.Files.FileColumns.RELATIVE_PATH)
while (it.moveToNext()) {
val relPath = it.getString(pathCol) ?: ""
if (relPath.contains(folderName)) {
val id = it.getLong(idCol)
return ContentUris.withAppendedId(collection, id)
}
}
}
// Create file if not found
val values = ContentValues().apply {
put(MediaStore.Files.FileColumns.DISPLAY_NAME, fileName)
put(MediaStore.Files.FileColumns.MIME_TYPE, "text/csv")
put(
MediaStore.Files.FileColumns.RELATIVE_PATH,
"${Environment.DIRECTORY_DOCUMENTS}/$folderName/"
)
}
return context.contentResolver.insert(collection, values)!!.also { uri ->
writeHeader(uri)
}
}
private fun legacyGetOrCreateFile(): Uri {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val sub = File(dir, folderName)
if (!sub.exists()) sub.mkdirs()
val file = File(sub, fileName)
if (!file.exists()) {
file.createNewFile()
writeHeaderLegacy(file)
}
return Uri.fromFile(file)
}
private suspend fun readAllLines(): List<Array<String>> = mutex.withLock {
val uri = getCsvUri()
return withContext(Dispatchers.IO) {
try {
context.contentResolver.openInputStream(uri)?.use { input ->
val reader = CSVReader(InputStreamReader(input))
val lines = reader.readAll()
reader.close()
if (lines.isNotEmpty() && lines[0].contentEquals(HEADER)) lines.drop(1)
else lines
} ?: emptyList()
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
}
private suspend fun writeAllLines(lines: List<Array<String>>) = mutex.withLock {
val uri = getCsvUri()
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
val writer = CSVWriter(OutputStreamWriter(out))
writer.writeNext(HEADER)
writer.writeAll(lines)
writer.close()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun writeHeader(uri: Uri) {
context.contentResolver.openOutputStream(uri, "wt")?.use { out ->
CSVWriter(OutputStreamWriter(out)).use { it.writeNext(HEADER) }
}
}
private fun writeHeaderLegacy(file: File) {
CSVWriter(FileWriter(file)).use { it.writeNext(HEADER) }
}
// --------------------------------------------------------------------------------------------
// 3) PUBLIC API IMPLEMENTATION
// --------------------------------------------------------------------------------------------
override fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>> =
Pager(PagingConfig(pageSize = 20)) {
AnimalDataPagingSource(this)
}.flow
suspend fun getAllAnimalProfiles(): List<AnimalProfile> {
return readAllLines().mapNotNull(::parseAnimalProfile)
}
override fun getAnimalDetails(animalId: String): Flow<AnimalDetails?> = flow {
emit(parseAnimalDetails(readAllLines().find { it.getOrNull(INDEX_ID) == animalId }))
}.flowOn(Dispatchers.IO)
override fun getAnimalRatings(animalId: String): Flow<AnimalRating?> = flow {
emit(parseAnimalRating(readAllLines().find { it.getOrNull(INDEX_ID) == animalId }))
}.flowOn(Dispatchers.IO)
override suspend fun setAnimalProfile(p: AnimalProfile) {
val lines = readAllLines().toMutableList()
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == p.animalId }
if (i != -1) lines[i] = updateProfile(lines[i], p)
else lines.add(createProfile(p))
writeAllLines(lines)
}
override suspend fun setAnimalDetails(d: AnimalDetails) {
val lines = readAllLines().toMutableList()
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == d.animalId }
if (i != -1) lines[i] = updateDetails(lines[i], d)
else lines.add(createDetails(d))
writeAllLines(lines)
}
override suspend fun setAnimalRatings(r: AnimalRating) {
val lines = readAllLines().toMutableList()
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == r.animalId }
if (i != -1) lines[i] = updateRating(lines[i], r)
else lines.add(createRating(r))
writeAllLines(lines)
}
override suspend fun deleteAnimalProfile(animalId: String) {
val lines = readAllLines().toMutableList()
val i = lines.indexOfFirst { it.getOrNull(INDEX_ID) == animalId }
if (i != -1) {
lines.removeAt(i)
writeAllLines(lines)
}
}
// --------------------------------------------------------------------------------------------
// 4) PARSERS + SERIALIZERS (exact same behavior you had)
// --------------------------------------------------------------------------------------------
private fun empty(): Array<String> = Array(TOTAL_COLUMNS) { "" }
private fun parseAnimalProfile(row: Array<String>?): AnimalProfile? {
row ?: return null
val id = row.getOrNull(INDEX_ID)?.takeIf { it.isNotBlank() } ?: return null
return AnimalProfile(
animalId = id,
name = row[INDEX_NAME],
species = row[INDEX_SPECIES],
breed = row[INDEX_BREED],
sex = row[INDEX_SEX],
weight = row[INDEX_WEIGHT].toIntOrNull() ?: 0,
age = row[INDEX_AGE].toIntOrNull() ?: 0,
imageUrls = row[INDEX_IMAGES].split(";").filter { it.isNotBlank() },
overallRating = row[INDEX_RATING_OVERALL].toIntOrNull()
)
}
private fun parseAnimalDetails(row: Array<String>?): AnimalDetails? {
row ?: return null
val id = row.getOrNull(INDEX_ID)?.takeIf { it.isNotBlank() } ?: return null
return AnimalDetails(
animalId = id,
name = row[INDEX_NAME],
species = row[INDEX_SPECIES],
breed = row[INDEX_BREED],
sex = row[INDEX_SEX],
weight = row[INDEX_WEIGHT].toIntOrNull() ?: 0,
age = row[INDEX_AGE].toIntOrNull() ?: 0,
milkYield = row[INDEX_MILK].toIntOrNull() ?: 0,
calvingNumber = row[INDEX_CALVING].toIntOrNull() ?: 0,
reproductiveStatus = row[INDEX_REPRO],
description = row[INDEX_DESC],
images = row[INDEX_IMAGES].split(";").filter { it.isNotBlank() },
video = row[INDEX_VIDEO]
)
}
private fun parseAnimalRating(row: Array<String>?): AnimalRating? {
row ?: return null
val id = row.getOrNull(INDEX_ID)?.takeIf { it.isNotBlank() } ?: return null
return AnimalRating(
animalId = id,
overallRating = row[INDEX_RATING_OVERALL].toIntOrNull() ?: 0,
healthRating = row[INDEX_RATING_HEALTH].toIntOrNull() ?: 0,
breedRating = row[INDEX_RATING_BREED].toIntOrNull() ?: 0,
stature = row[INDEX_RATING_STATURE].toIntOrNull() ?: 0,
chestWidth = row[INDEX_RATING_CHEST].toIntOrNull() ?: 0,
bodyDepth = row[INDEX_RATING_BODY_DEPTH].toIntOrNull() ?: 0,
angularity = row[INDEX_RATING_ANGULARITY].toIntOrNull() ?: 0,
rumpAngle = row[INDEX_RATING_RUMP_ANGLE].toIntOrNull() ?: 0,
rumpWidth = row[INDEX_RATING_RUMP_WIDTH].toIntOrNull() ?: 0,
rearLegSet = row[INDEX_RATING_REAR_LEG_SET].toIntOrNull() ?: 0,
rearLegRearView = row[INDEX_RATING_REAR_LEG_REAR].toIntOrNull() ?: 0,
footAngle = row[INDEX_RATING_FOOT_ANGLE].toIntOrNull() ?: 0,
foreUdderAttachment = row[INDEX_RATING_FORE_UDDER].toIntOrNull() ?: 0,
rearUdderHeight = row[INDEX_RATING_REAR_UDDER_HEIGHT].toIntOrNull() ?: 0,
centralLigament = row[INDEX_RATING_CENTRAL_LIG].toIntOrNull() ?: 0,
udderDepth = row[INDEX_RATING_UDDER_DEPTH].toIntOrNull() ?: 0,
frontTeatPosition = row[INDEX_RATING_FRONT_TEAT].toIntOrNull() ?: 0,
teatLength = row[INDEX_RATING_TEAT_LEN].toIntOrNull() ?: 0,
rearTeatPosition = row[INDEX_RATING_REAR_TEAT].toIntOrNull() ?: 0,
locomotion = row[INDEX_RATING_LOCOMOTION].toIntOrNull() ?: 0,
bodyConditionScore = row[INDEX_RATING_BCS].toIntOrNull() ?: 0,
hockDevelopment = row[INDEX_RATING_HOCK].toIntOrNull() ?: 0,
boneStructure = row[INDEX_RATING_BONE].toIntOrNull() ?: 0,
rearUdderWidth = row[INDEX_RATING_REAR_UDDER_WIDTH].toIntOrNull() ?: 0,
teatThickness = row[INDEX_RATING_TEAT_THICKNESS].toIntOrNull() ?: 0,
muscularity = row[INDEX_RATING_MUSCULARITY].toIntOrNull() ?: 0,
bodyConditionComments = row[INDEX_RATING_BODY_COND_COMMENTS]
)
}
private fun updateProfile(row: Array<String>, p: AnimalProfile): Array<String> {
row[INDEX_ID] = p.animalId
row[INDEX_NAME] = p.name
row[INDEX_SPECIES] = p.species
row[INDEX_BREED] = p.breed
row[INDEX_SEX] = p.sex
row[INDEX_WEIGHT] = p.weight.toString()
row[INDEX_AGE] = p.age.toString()
row[INDEX_IMAGES] = p.imageUrls.joinToString(";")
return row
}
private fun updateDetails(row: Array<String>, d: AnimalDetails): Array<String> {
row[INDEX_ID] = d.animalId
row[INDEX_NAME] = d.name
row[INDEX_SPECIES] = d.species
row[INDEX_BREED] = d.breed
row[INDEX_SEX] = d.sex
row[INDEX_WEIGHT] = d.weight.toString()
row[INDEX_AGE] = d.age.toString()
row[INDEX_MILK] = d.milkYield.toString()
row[INDEX_CALVING] = d.calvingNumber.toString()
row[INDEX_REPRO] = d.reproductiveStatus
row[INDEX_DESC] = d.description
row[INDEX_IMAGES] = d.images.joinToString(";")
row[INDEX_VIDEO] = d.video
return row
}
private fun updateRating(row: Array<String>, r: AnimalRating): Array<String> {
row[INDEX_ID] = r.animalId
row[INDEX_RATING_OVERALL] = r.overallRating.toString()
row[INDEX_RATING_HEALTH] = r.healthRating.toString()
row[INDEX_RATING_BREED] = r.breedRating.toString()
row[INDEX_RATING_STATURE] = r.stature.toString()
row[INDEX_RATING_CHEST] = r.chestWidth.toString()
row[INDEX_RATING_BODY_DEPTH] = r.bodyDepth.toString()
row[INDEX_RATING_ANGULARITY] = r.angularity.toString()
row[INDEX_RATING_RUMP_ANGLE] = r.rumpAngle.toString()
row[INDEX_RATING_RUMP_WIDTH] = r.rumpWidth.toString()
row[INDEX_RATING_REAR_LEG_SET] = r.rearLegSet.toString()
row[INDEX_RATING_REAR_LEG_REAR] = r.rearLegRearView.toString()
row[INDEX_RATING_FOOT_ANGLE] = r.footAngle.toString()
row[INDEX_RATING_FORE_UDDER] = r.foreUdderAttachment.toString()
row[INDEX_RATING_REAR_UDDER_HEIGHT] = r.rearUdderHeight.toString()
row[INDEX_RATING_CENTRAL_LIG] = r.centralLigament.toString()
row[INDEX_RATING_UDDER_DEPTH] = r.udderDepth.toString()
row[INDEX_RATING_FRONT_TEAT] = r.frontTeatPosition.toString()
row[INDEX_RATING_TEAT_LEN] = r.teatLength.toString()
row[INDEX_RATING_REAR_TEAT] = r.rearTeatPosition.toString()
row[INDEX_RATING_LOCOMOTION] = r.locomotion.toString()
row[INDEX_RATING_BCS] = r.bodyConditionScore.toString()
row[INDEX_RATING_HOCK] = r.hockDevelopment.toString()
row[INDEX_RATING_BONE] = r.boneStructure.toString()
row[INDEX_RATING_REAR_UDDER_WIDTH] = r.rearUdderWidth.toString()
row[INDEX_RATING_TEAT_THICKNESS] = r.teatThickness.toString()
row[INDEX_RATING_MUSCULARITY] = r.muscularity.toString()
row[INDEX_RATING_BODY_COND_COMMENTS] = r.bodyConditionComments
return row
}
private fun createProfile(p: AnimalProfile) = updateProfile(empty(), p)
private fun createDetails(d: AnimalDetails) = updateDetails(empty(), d)
private fun createRating(r: AnimalRating) = updateRating(empty(), r)
companion object {
// Same columns as before
const val INDEX_ID = 0
const val INDEX_NAME = 1
const val INDEX_SPECIES = 2
const val INDEX_BREED = 3
const val INDEX_SEX = 4
const val INDEX_WEIGHT = 5
const val INDEX_AGE = 6
const val INDEX_MILK = 7
const val INDEX_CALVING = 8
const val INDEX_REPRO = 9
const val INDEX_DESC = 10
const val INDEX_IMAGES = 11
const val INDEX_VIDEO = 12
const val INDEX_RATING_OVERALL = 13
const val INDEX_RATING_HEALTH = 14
const val INDEX_RATING_BREED = 15
const val INDEX_RATING_STATURE = 16
const val INDEX_RATING_CHEST = 17
const val INDEX_RATING_BODY_DEPTH = 18
const val INDEX_RATING_ANGULARITY = 19
const val INDEX_RATING_RUMP_ANGLE = 20
const val INDEX_RATING_RUMP_WIDTH = 21
const val INDEX_RATING_REAR_LEG_SET = 22
const val INDEX_RATING_REAR_LEG_REAR = 23
const val INDEX_RATING_FOOT_ANGLE = 24
const val INDEX_RATING_FORE_UDDER = 25
const val INDEX_RATING_REAR_UDDER_HEIGHT = 26
const val INDEX_RATING_CENTRAL_LIG = 27
const val INDEX_RATING_UDDER_DEPTH = 28
const val INDEX_RATING_FRONT_TEAT = 29
const val INDEX_RATING_TEAT_LEN = 30
const val INDEX_RATING_REAR_TEAT = 31
const val INDEX_RATING_LOCOMOTION = 32
const val INDEX_RATING_BCS = 33
const val INDEX_RATING_HOCK = 34
const val INDEX_RATING_BONE = 35
const val INDEX_RATING_REAR_UDDER_WIDTH = 36
const val INDEX_RATING_TEAT_THICKNESS = 37
const val INDEX_RATING_MUSCULARITY = 38
const val INDEX_RATING_BODY_COND_COMMENTS = 39
const val TOTAL_COLUMNS = 40
val HEADER = arrayOf(
"ID", "Name", "Species", "Breed", "Sex", "Weight", "Age", "MilkYield",
"CalvingNum", "ReproStatus", "Description", "Images", "Video",
"OverallRating", "HealthRating", "BreedRating", "Stature", "ChestWidth",
"BodyDepth", "Angularity", "RumpAngle", "RumpWidth", "RearLegSet",
"RearLegRearView", "FootAngle", "ForeUdderAttachment", "RearUdderHeight",
"CentralLigament", "UdderDepth", "FrontTeatPosition", "TeatLength",
"RearTeatPosition", "Locomotion", "BodyConditionScore", "HockDevelopment",
"BoneStructure", "RearUdderWidth", "TeatThickness", "Muscularity",
"BodyConditionComments"
)
}
}

View File

@ -0,0 +1,6 @@
package com.example.livingai.data.local.model
data class SettingsData(
val language: String,
val isAutoCaptureOn: Boolean
)

View File

@ -0,0 +1,31 @@
package com.example.livingai.data.manager
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import com.example.livingai.domain.manager.LocalUserManager
import com.example.livingai.utils.Constants
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class LocalUserManagerImpl(
private val dataStore: DataStore<Preferences>
): LocalUserManager {
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
}
}
}
private object PreferencesKeys {
val APP_ENTRY = booleanPreferencesKey(name = Constants.APP_ENTRY)
}

View File

@ -0,0 +1,11 @@
package com.example.livingai.data.ml
import android.graphics.Bitmap
import com.example.livingai.domain.ml.AIModel
class AIModelImpl : AIModel {
override fun deriveInference(bitmap: Bitmap): String {
// Placeholder for actual inference logic
return "Inference Result"
}
}

View File

@ -0,0 +1,44 @@
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.stringPreferencesKey
import com.example.livingai.data.local.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository
import com.example.livingai.utils.Constants.APP_ENTRY
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
class SettingsRepositoryImpl(private val dataStore: DataStore<Preferences>) : SettingsRepository {
private object PreferencesKeys {
val LANGUAGE = stringPreferencesKey("language")
val IS_AUTO_CAPTURE_ON = booleanPreferencesKey("is_auto_capture_on")
}
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
SettingsData(language, isAutoCaptureOn)
}
}
override suspend fun saveSettings(settings: SettingsData) {
dataStore.edit {
it[PreferencesKeys.LANGUAGE] = settings.language
it[PreferencesKeys.IS_AUTO_CAPTURE_ON] = settings.isAutoCaptureOn
}
}
}

View File

@ -0,0 +1,28 @@
package com.example.livingai.data.repository.business
import com.example.livingai.domain.model.AnimalDetails
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
import com.example.livingai.domain.repository.business.DataSource
import kotlinx.coroutines.flow.Flow
class AnimalDetailsRepositoryImpl(
private val dataSource: DataSource
) : AnimalDetailsRepository {
override fun getAnimalDetails(id: String): Flow<AnimalDetails?> {
return dataSource.getAnimalDetails(id)
}
override suspend fun saveAnimalDetails(animalDetails: AnimalDetails) {
dataSource.setAnimalDetails(animalDetails)
}
override suspend fun deleteAnimalDetails(id: String) {
// Currently only full profile deletion is exposed by DataSource as per request
// but we can call that if needed, or just leave it as no-op if details deletion is specific
// Assuming for now it deletes the profile or we might need to add specific delete to DataSource
// But the prompt said "DataSource should have get, set and delete... get and delete based on a string which will be id"
// And "deleteAnimalProfile - takes an Id deletes that animals complete profile"
// So strictly for details, maybe we don't have a specific delete or we just don't impl it yet.
// However, to satisfy the interface:
}
}

View File

@ -0,0 +1,23 @@
package com.example.livingai.data.repository.business
import androidx.paging.PagingData
import com.example.livingai.domain.model.AnimalProfile
import com.example.livingai.domain.repository.business.AnimalProfileRepository
import com.example.livingai.domain.repository.business.DataSource
import kotlinx.coroutines.flow.Flow
class AnimalProfileRepositoryImpl(
private val dataSource: DataSource
) : AnimalProfileRepository {
override fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>> {
return dataSource.getAnimalProfiles()
}
override suspend fun saveAnimalProfile(animalProfile: AnimalProfile) {
dataSource.setAnimalProfile(animalProfile)
}
override suspend fun deleteAnimalProfile(id: String) {
dataSource.deleteAnimalProfile(id)
}
}

View File

@ -0,0 +1,22 @@
package com.example.livingai.data.repository.business
import com.example.livingai.domain.model.AnimalRating
import com.example.livingai.domain.repository.business.AnimalRatingRepository
import com.example.livingai.domain.repository.business.DataSource
import kotlinx.coroutines.flow.Flow
class AnimalRatingRepositoryImpl(
private val dataSource: DataSource
) : AnimalRatingRepository {
override fun getAnimalRating(id: String): Flow<AnimalRating?> {
return dataSource.getAnimalRatings(id)
}
override suspend fun saveAnimalRating(animalRating: AnimalRating) {
dataSource.setAnimalRatings(animalRating)
}
override suspend fun deleteAnimalRating(id: String) {
// Same as details, placeholder for now as only full profile delete is requested in DataSource
}
}

View File

@ -0,0 +1,74 @@
package com.example.livingai.data.repository.media
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.net.Uri
import android.provider.MediaStore
import androidx.camera.core.ImageProxy
import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.repository.CameraRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
class CameraRepositoryImpl(
private val aiModel: AIModel,
private val context: Context
) : CameraRepository {
override suspend fun captureImage(imageProxy: ImageProxy): Bitmap = withContext(Dispatchers.IO) {
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val bitmap = imageProxy.toBitmap()
imageProxy.close()
// Rotate bitmap if needed
if (rotationDegrees != 0) {
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
} else {
bitmap
}
}
override suspend fun processFrame(bitmap: Bitmap): String = withContext(Dispatchers.Default) {
aiModel.deriveInference(bitmap)
}
override suspend fun saveImage(bitmap: Bitmap, animalId: String, orientation: String?): String = withContext(Dispatchers.IO) {
val orientationSuffix = orientation?.let { "_$it" } ?: ""
val filename = "${animalId}${orientationSuffix}.jpg"
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/LivingAI/Media/$animalId")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
?: throw RuntimeException("Failed to create image record")
try {
resolver.openOutputStream(uri)?.use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
}
} catch (e: Exception) {
resolver.delete(uri, null, null)
throw e
}
uri.toString()
}
}

View File

@ -0,0 +1,31 @@
package com.example.livingai.data.repository.media
import android.graphics.Bitmap
import com.example.livingai.domain.ml.AIModel
import com.example.livingai.domain.repository.VideoRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class VideoRepositoryImpl(private val aiModel: AIModel) : VideoRepository {
private var isRecording = false
override fun startRecording(onRecordingStarted: () -> Unit) {
isRecording = true
// Logic to start recording video if needed
onRecordingStarted()
}
override fun stopRecording() {
isRecording = false
// Logic to stop recording
}
override suspend fun processFrame(bitmap: Bitmap): String = withContext(Dispatchers.Default) {
if (isRecording) {
aiModel.deriveInference(bitmap)
} else {
""
}
}
}

View File

@ -0,0 +1,113 @@
package com.example.livingai.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
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.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.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.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.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.SetAnimalDetails
import com.example.livingai.domain.usecases.SetAnimalRatings
import com.example.livingai.pages.addprofile.AddProfileViewModel
import com.example.livingai.pages.camera.CameraViewModel
import com.example.livingai.pages.camera.VideoViewModel
import com.example.livingai.pages.home.HomeViewModel
import com.example.livingai.pages.listings.ListingsViewModel
import com.example.livingai.pages.onboarding.OnBoardingViewModel
import com.example.livingai.pages.ratings.RatingViewModel
import com.example.livingai.pages.settings.SettingsViewModel
import com.example.livingai.utils.Constants
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
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 {
AppEntryUseCases(
readAppEntry = ReadAppEntry(get()),
saveAppEntry = SaveAppEntry(get())
)
}
// Data Source
single<DataSource> {
CSVDataSource(
context = androidContext(),
fileName = Constants.ANIMAL_DATA_FILENAME
)
}
// ML Model
single<AIModel> { AIModelImpl() }
// Repositories
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()) }
// Use Cases
single { GetAnimalProfiles(get()) }
single { GetAnimalDetails(get()) }
single { GetAnimalRatings(get()) }
single { SetAnimalDetails(get()) }
single { SetAnimalRatings(get()) }
single { DeleteAnimalProfile(get()) }
//Use Cases
single {
ProfileEntryUseCase(
getAnimalDetails = GetAnimalDetails(get()),
setAnimalDetails = SetAnimalDetails(get())
)
}
single {
ProfileListingUseCase(
getAnimalProfiles = GetAnimalProfiles(get()),
deleteAnimalProfile = DeleteAnimalProfile(get())
)
}
// ViewModels
viewModel { HomeViewModel(get()) }
viewModel { OnBoardingViewModel(get(), get()) }
viewModel { AddProfileViewModel(get()) }
viewModel { ListingsViewModel(get()) }
viewModel { SettingsViewModel(get()) }
viewModel { RatingViewModel(get(), get(), get(), get()) }
viewModel { CameraViewModel(get()) }
viewModel { VideoViewModel() }
}

View File

@ -0,0 +1,8 @@
package com.example.livingai.domain.manager
import kotlinx.coroutines.flow.Flow
interface LocalUserManager {
suspend fun saveAppEntry()
fun readAppEntry(): Flow<Boolean>
}

View File

@ -0,0 +1,7 @@
package com.example.livingai.domain.ml
import android.graphics.Bitmap
interface AIModel {
fun deriveInference(bitmap: Bitmap): String
}

View File

@ -0,0 +1,17 @@
package com.example.livingai.domain.model
data class AnimalDetails(
val animalId: String,
val name: String,
val species: String,
val breed: String,
val sex: String,
val weight: Int,
val age: Int,
val milkYield: Int,
val calvingNumber: Int,
val reproductiveStatus: String,
val description: String,
val images: List<String>,
val video: String
)

View File

@ -0,0 +1,13 @@
package com.example.livingai.domain.model
data class AnimalProfile(
val animalId: String,
val name: String,
val species: String,
val breed: String,
val sex: String,
val weight: Int,
val age: Int,
val overallRating: Int? = null,
val imageUrls: List<String>
)

View File

@ -0,0 +1,32 @@
package com.example.livingai.domain.model
data class AnimalRating(
val animalId: String,
val overallRating: Int,
val healthRating: Int,
val breedRating: Int,
val stature: Int,
val chestWidth: Int,
val bodyDepth: Int,
val angularity: Int,
val rumpAngle: Int,
val rumpWidth: Int,
val rearLegSet: Int,
val rearLegRearView: Int,
val footAngle: Int,
val foreUdderAttachment: Int,
val rearUdderHeight: Int,
val centralLigament: Int,
val udderDepth: Int,
val frontTeatPosition: Int,
val teatLength: Int,
val rearTeatPosition: Int,
val locomotion: Int,
val bodyConditionScore: Int,
val hockDevelopment: Int,
val boneStructure: Int,
val rearUdderWidth: Int,
val teatThickness: Int,
val muscularity: Int,
val bodyConditionComments: String,
)

View File

@ -0,0 +1,9 @@
package com.example.livingai.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class SettingsData(
val language: String = "en",
val isAutoCaptureOn: Boolean = false
)

View File

@ -0,0 +1,10 @@
package com.example.livingai.domain.repository
import android.graphics.Bitmap
import androidx.camera.core.ImageProxy
interface CameraRepository {
suspend fun captureImage(imageProxy: ImageProxy): Bitmap
suspend fun processFrame(bitmap: Bitmap): String
suspend fun saveImage(bitmap: Bitmap, animalId: String, orientation: String?): String
}

View File

@ -0,0 +1,9 @@
package com.example.livingai.domain.repository
import com.example.livingai.data.local.model.SettingsData
import kotlinx.coroutines.flow.Flow
interface SettingsRepository {
fun getSettings(): Flow<SettingsData>
suspend fun saveSettings(settings: SettingsData)
}

View File

@ -0,0 +1,10 @@
package com.example.livingai.domain.repository
import android.graphics.Bitmap
import androidx.camera.video.Recording
interface VideoRepository {
fun startRecording(onRecordingStarted: () -> Unit)
fun stopRecording()
suspend fun processFrame(bitmap: Bitmap): String
}

View File

@ -0,0 +1,12 @@
package com.example.livingai.domain.repository.business
import com.example.livingai.domain.model.AnimalDetails
import kotlinx.coroutines.flow.Flow
interface AnimalDetailsRepository {
fun getAnimalDetails(id: String): Flow<AnimalDetails?>
suspend fun saveAnimalDetails(animalDetails: AnimalDetails)
suspend fun deleteAnimalDetails(id: String)
}

View File

@ -0,0 +1,13 @@
package com.example.livingai.domain.repository.business
import androidx.paging.PagingData
import com.example.livingai.domain.model.AnimalProfile
import kotlinx.coroutines.flow.Flow
interface AnimalProfileRepository {
fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>>
suspend fun saveAnimalProfile(animalProfile: AnimalProfile)
suspend fun deleteAnimalProfile(id: String)
}

View File

@ -0,0 +1,12 @@
package com.example.livingai.domain.repository.business
import com.example.livingai.domain.model.AnimalRating
import kotlinx.coroutines.flow.Flow
interface AnimalRatingRepository {
fun getAnimalRating(id: String): Flow<AnimalRating?>
suspend fun saveAnimalRating(animalRating: AnimalRating)
suspend fun deleteAnimalRating(id: String)
}

View File

@ -0,0 +1,18 @@
package com.example.livingai.domain.repository.business
import androidx.paging.PagingData
import com.example.livingai.domain.model.AnimalDetails
import com.example.livingai.domain.model.AnimalProfile
import com.example.livingai.domain.model.AnimalRating
import kotlinx.coroutines.flow.Flow
interface DataSource {
fun getAnimalProfiles(): Flow<PagingData<AnimalProfile>>
fun getAnimalDetails(animalId: String): Flow<AnimalDetails?>
fun getAnimalRatings(animalId: String): Flow<AnimalRating?>
suspend fun setAnimalProfile(animalProfile: AnimalProfile)
suspend fun setAnimalDetails(animalDetails: AnimalDetails)
suspend fun setAnimalRatings(animalRating: AnimalRating)
suspend fun deleteAnimalProfile(animalId: String)
}

View File

@ -0,0 +1,9 @@
package com.example.livingai.domain.usecases.AppEntry
import com.example.livingai.domain.usecases.ReadAppEntry
import com.example.livingai.domain.usecases.SaveAppEntry
data class AppEntryUseCases(
val readAppEntry: ReadAppEntry,
val saveAppEntry: SaveAppEntry
)

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.repository.business.AnimalProfileRepository
class DeleteAnimalProfile(
private val animalProfileRepository: AnimalProfileRepository
) {
suspend operator fun invoke(animalId: String) {
animalProfileRepository.deleteAnimalProfile(animalId)
}
}

View File

@ -0,0 +1,13 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.model.AnimalDetails
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
import kotlinx.coroutines.flow.Flow
class GetAnimalDetails(
private val animalDetailsRepository: AnimalDetailsRepository
) {
operator fun invoke(animalId: String): Flow<AnimalDetails?> {
return animalDetailsRepository.getAnimalDetails(animalId)
}
}

View File

@ -0,0 +1,14 @@
package com.example.livingai.domain.usecases
import androidx.paging.PagingData
import com.example.livingai.domain.model.AnimalProfile
import com.example.livingai.domain.repository.business.AnimalProfileRepository
import kotlinx.coroutines.flow.Flow
class GetAnimalProfiles(
private val animalProfileRepository: AnimalProfileRepository
) {
operator fun invoke(): Flow<PagingData<AnimalProfile>> {
return animalProfileRepository.getAnimalProfiles()
}
}

View File

@ -0,0 +1,13 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.model.AnimalRating
import com.example.livingai.domain.repository.business.AnimalRatingRepository
import kotlinx.coroutines.flow.Flow
class GetAnimalRatings(
private val animalRatingRepository: AnimalRatingRepository
) {
operator fun invoke(animalId: String): Flow<AnimalRating?> {
return animalRatingRepository.getAnimalRating(animalId)
}
}

View File

@ -0,0 +1,9 @@
package com.example.livingai.domain.usecases.ProfileEntry
import com.example.livingai.domain.usecases.GetAnimalDetails
import com.example.livingai.domain.usecases.SetAnimalDetails
data class ProfileEntryUseCase(
val getAnimalDetails: GetAnimalDetails,
val setAnimalDetails: SetAnimalDetails,
)

View File

@ -0,0 +1,9 @@
package com.example.livingai.domain.usecases.ProfileListing
import com.example.livingai.domain.usecases.DeleteAnimalProfile
import com.example.livingai.domain.usecases.GetAnimalProfiles
data class ProfileListingUseCase(
val getAnimalProfiles: GetAnimalProfiles,
val deleteAnimalProfile: DeleteAnimalProfile
)

View File

@ -0,0 +1,9 @@
package com.example.livingai.domain.usecases.ProfilesEntry
import com.example.livingai.domain.usecases.DeleteAnimalProfile
import com.example.livingai.domain.usecases.GetAnimalProfiles
data class ProfilesEntryUseCases(
val getAnimalProfiles: GetAnimalProfiles,
val deleteAnimalProfile: DeleteAnimalProfile
)

View File

@ -0,0 +1,12 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.manager.LocalUserManager
import kotlinx.coroutines.flow.Flow
class ReadAppEntry(
private val localUserManager: LocalUserManager
) {
operator fun invoke(): Flow<Boolean> {
return localUserManager.readAppEntry()
}
}

View File

@ -0,0 +1,11 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.manager.LocalUserManager
class SaveAppEntry(
private val localUserManager: LocalUserManager
) {
suspend operator fun invoke() {
localUserManager.saveAppEntry()
}
}

View File

@ -0,0 +1,12 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.model.AnimalDetails
import com.example.livingai.domain.repository.business.AnimalDetailsRepository
class SetAnimalDetails(
private val animalDetailsRepository: AnimalDetailsRepository
) {
suspend operator fun invoke(animalDetails: AnimalDetails) {
animalDetailsRepository.saveAnimalDetails(animalDetails)
}
}

View File

@ -0,0 +1,12 @@
package com.example.livingai.domain.usecases
import com.example.livingai.domain.model.AnimalRating
import com.example.livingai.domain.repository.business.AnimalRatingRepository
class SetAnimalRatings(
private val animalRatingRepository: AnimalRatingRepository
) {
suspend operator fun invoke(animalRating: AnimalRating) {
animalRatingRepository.saveAnimalRating(animalRating)
}
}

View File

@ -0,0 +1,212 @@
package com.example.livingai.pages.addprofile
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.navigation.NavController
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.CommonScaffold
import com.example.livingai.pages.components.ImageThumbnailButton
import com.example.livingai.pages.components.LabeledDropdown
import com.example.livingai.pages.components.LabeledTextField
import com.example.livingai.pages.components.RadioGroup
import com.example.livingai.pages.components.VideoThumbnailButton
import com.example.livingai.utils.Constants
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun AddProfileScreen(
navController: NavController,
viewModel: AddProfileViewModel,
onSave: () -> Unit,
onCancel: () -> Unit,
onTakePhoto: (String) -> Unit,
onTakeVideo: () -> Unit
) {
val context = LocalContext.current
val speciesList = stringArrayResource(id = R.array.species_list).toList()
val breedList = stringArrayResource(id = R.array.cow_breed_list).toList()
val reproList = listOf(
stringResource(R.string.option_pregnant),
stringResource(R.string.option_calved),
stringResource(R.string.option_none)
)
val silhouette = Constants.silhouetteList.associate { item ->
val resId = context.resources.getIdentifier("label_${item}", "string", context.packageName)
item to if (resId != 0) resId else R.string.default_orientation_label
}
// Use ViewModel state
var species by viewModel.species
var breed by viewModel.breed
var age by viewModel.age
var milkYield by viewModel.milkYield
var calvingNumber by viewModel.calvingNumber
var reproductiveStatus by viewModel.reproductiveStatus
var description by viewModel.description
val photos = viewModel.photos
val videoUri by viewModel.videoUri
CommonScaffold(
navController = navController,
title = stringResource(id = R.string.top_bar_add_profile)
) { innerPadding ->
LazyVerticalGrid(
columns = GridCells.Fixed(2),
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT),
horizontalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_IMAGE),
modifier = Modifier
.padding(innerPadding)
.padding(Dimentions.SMALL_PADDING_TEXT)
) {
item(span = { GridItemSpan(2) }) {
Column(
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT)
) {
LabeledDropdown(
labelRes = R.string.label_species,
options = speciesList,
selected = species,
onSelected = { species = it }
)
LabeledDropdown(
labelRes = R.string.label_breed,
options = breedList,
selected = breed,
onSelected = { breed = it }
)
Row(
horizontalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_INPUT),
modifier = Modifier.fillMaxWidth(),
) {
LabeledTextField(
labelRes = R.string.label_age,
value = age,
modifier = Modifier.weight(1f),
onValueChange = { age = it },
keyboardType = KeyboardType.Number
)
LabeledTextField(
labelRes = R.string.label_milk_yield,
value = milkYield,
modifier = Modifier.weight(1f),
onValueChange = { milkYield = it },
keyboardType = KeyboardType.Number
)
}
LabeledTextField(
labelRes = R.string.label_calving_number,
value = calvingNumber,
modifier = Modifier.fillMaxWidth(),
onValueChange = { calvingNumber = it },
keyboardType = KeyboardType.Number
)
RadioGroup(
titleRes = R.string.label_reproductive_status,
options = reproList,
selected = reproductiveStatus,
onSelected = { reproductiveStatus = it }
)
LabeledTextField(
labelRes = R.string.label_description,
value = description,
modifier = Modifier
.fillMaxWidth(),
onValueChange = { description = it },
keyboardType = KeyboardType.Text
)
Text(
text = stringResource(id = R.string.label_upload_media),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
)
}
}
items(silhouette.entries.toList()) { (key, value) ->
ImageThumbnailButton(
image = photos[key],
onClick = { onTakePhoto(key) },
labelRes = value
)
}
// Video Button
item {
VideoThumbnailButton(
videoSource = videoUri, // Removed videoThumbnail fallback as it's not in VM
onClick = onTakeVideo
)
}
// Save and Cancel Buttons
item(span = { GridItemSpan(2) }) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = Dimentions.MEDIUM_PADDING_BUTTON),
horizontalArrangement = Arrangement.Center
) {
OutlinedButton(
onClick = onCancel,
modifier = Modifier
.weight(1f)
.padding(end = Dimentions.SMALL_PADDING_BUTTON)
) {
Text(text = stringResource(id = R.string.btn_cancel))
}
Button(
onClick = onSave,
modifier = Modifier
.weight(1f)
.padding(start = Dimentions.SMALL_PADDING_BUTTON)
) {
Text(text = stringResource(id = R.string.btn_save_profile))
}
}
}
item(span = { GridItemSpan(2) }) {
Spacer(modifier = Modifier.height(Dimentions.LARGE_PADDING))
}
}
}
}

View File

@ -0,0 +1,118 @@
package com.example.livingai.pages.addprofile
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.model.AnimalDetails
import com.example.livingai.domain.usecases.ProfileEntry.ProfileEntryUseCase
import com.example.livingai.utils.IdGenerator
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class AddProfileViewModel(
private val profileEntryUseCase: ProfileEntryUseCase
) : ViewModel() {
private val _animalDetails = mutableStateOf<AnimalDetails?>(null)
val animalDetails: State<AnimalDetails?> = _animalDetails
private val _currentAnimalId = mutableStateOf<String?>(null)
val currentAnimalId: State<String?> = _currentAnimalId
// UI State
var species = mutableStateOf<String?>(null)
var breed = mutableStateOf<String?>(null)
var age = mutableStateOf("")
var milkYield = mutableStateOf("")
var calvingNumber = mutableStateOf("")
var reproductiveStatus = mutableStateOf<String?>(null)
var description = mutableStateOf("")
// State for photos and video
val photos = mutableStateMapOf<String, String>()
private val _videoUri = mutableStateOf<String?>(null)
val videoUri: State<String?> = _videoUri
fun loadAnimalDetails(animalId: String?) {
if (animalId == null) {
val newId = IdGenerator.generateAnimalId()
_currentAnimalId.value = newId
_animalDetails.value = null
// Reset UI State
species.value = null
breed.value = null
age.value = ""
milkYield.value = ""
calvingNumber.value = ""
reproductiveStatus.value = null
description.value = ""
photos.clear()
_videoUri.value = null
} else {
_currentAnimalId.value = animalId
profileEntryUseCase.getAnimalDetails(animalId).onEach { details ->
if (details != null) {
_animalDetails.value = details
// Populate UI State
species.value = details.species.ifBlank { null }
breed.value = details.breed.ifBlank { null }
age.value = if (details.age == 0) "" else details.age.toString()
milkYield.value = if (details.milkYield == 0) "" else details.milkYield.toString()
calvingNumber.value = if (details.calvingNumber == 0) "" else details.calvingNumber.toString()
reproductiveStatus.value = details.reproductiveStatus.ifBlank { null }
description.value = details.description
// Populate photos
photos.clear()
details.images.forEach { path ->
// path: .../{id}_{orientation}.jpg
val filename = path.substringAfterLast('/')
val nameWithoutExt = filename.substringBeforeLast('.')
val parts = nameWithoutExt.split('_')
if (parts.size >= 2) {
val orientation = parts.last()
photos[orientation] = path
}
}
_videoUri.value = details.video.ifBlank { null }
}
}.launchIn(viewModelScope)
}
}
fun addPhoto(orientation: String, uri: String) {
photos[orientation] = uri
}
fun setVideo(uri: String) {
_videoUri.value = uri
}
fun saveAnimalDetails() {
val id = _currentAnimalId.value ?: IdGenerator.generateAnimalId().also { _currentAnimalId.value = it }
val details = AnimalDetails(
animalId = id,
species = species.value ?: "",
breed = breed.value ?: "",
age = age.value.toIntOrNull() ?: 0,
milkYield = milkYield.value.toIntOrNull() ?: 0,
calvingNumber = calvingNumber.value.toIntOrNull() ?: 0,
reproductiveStatus = reproductiveStatus.value ?: "",
description = description.value,
images = photos.values.toList(),
video = _videoUri.value ?: "",
name = "", sex = "", weight = 0
)
viewModelScope.launch {
profileEntryUseCase.setAnimalDetails(details)
}
}
}

View File

@ -0,0 +1,129 @@
package com.example.livingai.pages.camera
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController
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.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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import org.koin.androidx.compose.koinViewModel
import com.example.livingai.pages.components.CameraPreview
import com.example.livingai.pages.components.PermissionWrapper
import com.example.livingai.pages.navigation.Route
@Composable
fun CameraScreen(
viewModel: CameraViewModel = koinViewModel(),
navController: NavController,
orientation: String? = null,
animalId: String
) {
PermissionWrapper {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val controller = remember {
LifecycleCameraController(context).apply {
setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE)
}
}
LaunchedEffect(animalId, orientation) {
viewModel.onEvent(CameraEvent.SetContext(animalId, orientation))
}
LaunchedEffect(state.capturedImageUri) {
state.capturedImageUri?.let {
navController.navigate(
Route.ViewImageScreen(
imageUri = it.toString(),
shouldAllowRetake = true,
showAccept = true,
orientation = orientation,
animalId = animalId
)
)
viewModel.onEvent(CameraEvent.ClearCapturedImage) // Clear after navigation
}
}
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
}
}
)
}) {
Icon(Icons.Default.Camera, contentDescription = "Capture Image")
}
}
},
floatingActionButtonPosition = FabPosition.Center
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
CameraPreview(
modifier = Modifier.fillMaxSize(),
controller = controller,
onFrame = { bitmap ->
if (state.isAutoCaptureOn) {
viewModel.onEvent(CameraEvent.FrameReceived(bitmap))
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text("Auto Capture")
Switch(
checked = state.isAutoCaptureOn,
onCheckedChange = { viewModel.onEvent(CameraEvent.ToggleAutoCapture) }
)
}
}
}
}
}

View File

@ -0,0 +1,88 @@
package com.example.livingai.pages.camera
import android.graphics.Bitmap
import android.net.Uri
import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.repository.CameraRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CameraViewModel(
private val cameraRepository: CameraRepository
) : ViewModel() {
private val _state = MutableStateFlow(CameraState())
val state = _state.asStateFlow()
private var currentAnimalId: String = ""
private var currentOrientation: String? = null
fun onEvent(event: CameraEvent) {
when (event) {
is CameraEvent.CaptureImage -> {
// Signal UI to capture? Or handle if passed image
}
is CameraEvent.ImageCaptured -> {
saveImage(event.imageProxy)
}
is CameraEvent.FrameReceived -> {
processFrame(event.bitmap)
}
is CameraEvent.ToggleAutoCapture -> {
_state.value = _state.value.copy(isAutoCaptureOn = !_state.value.isAutoCaptureOn)
}
is CameraEvent.ClearCapturedImage -> {
_state.value = _state.value.copy(capturedImage = null, capturedImageUri = null)
}
is CameraEvent.SetContext -> {
currentAnimalId = event.animalId
currentOrientation = event.orientation
}
}
}
private fun saveImage(imageProxy: ImageProxy) {
viewModelScope.launch {
try {
val bitmap = cameraRepository.captureImage(imageProxy)
val uriString = cameraRepository.saveImage(bitmap, currentAnimalId, currentOrientation)
_state.value = _state.value.copy(capturedImage = bitmap, capturedImageUri = Uri.parse(uriString))
} catch (e: Exception) {
// Handle error
imageProxy.close() // Ensure closed on error if repository didn't
}
}
}
private fun processFrame(bitmap: Bitmap) {
viewModelScope.launch {
try {
val result = cameraRepository.processFrame(bitmap)
_state.value = _state.value.copy(inferenceResult = result)
} catch (e: Exception) {
// Handle error
}
}
}
}
data class CameraState(
val isLoading: Boolean = false,
val isCameraReady: Boolean = false,
val capturedImage: Any? = null,
val capturedImageUri: Uri? = null,
val inferenceResult: String? = null,
val isAutoCaptureOn: Boolean = false
)
sealed class CameraEvent {
object CaptureImage : CameraEvent()
data class ImageCaptured(val imageProxy: ImageProxy) : CameraEvent()
data class FrameReceived(val bitmap: Bitmap) : CameraEvent()
object ToggleAutoCapture : CameraEvent()
object ClearCapturedImage : CameraEvent()
data class SetContext(val animalId: String, val orientation: String?) : CameraEvent()
}

View File

@ -0,0 +1,143 @@
package com.example.livingai.pages.camera
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues
import android.net.Uri
import android.provider.MediaStore
import androidx.camera.core.CameraSelector
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Recording
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.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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 org.koin.androidx.compose.koinViewModel
import java.io.File
@SuppressLint("MissingPermission")
@Composable
fun VideoRecordScreen(
viewModel: VideoViewModel = koinViewModel(),
navController: NavController,
animalId: String
) {
val context = LocalContext.current
val state by viewModel.state.collectAsState()
var recording by remember { mutableStateOf<Recording?>(null) }
// We need RECORD_AUDIO permission for video with audio
PermissionWrapper(
permissions = listOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
) {
val controller = remember {
LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.VIDEO_CAPTURE)
cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
}
}
LaunchedEffect(state.recordedVideoUri) {
state.recordedVideoUri?.let { uri ->
navController.navigate(Route.ViewVideoScreen(videoUri = uri.toString(), shouldAllowRetake = true, animalId = animalId))
viewModel.onEvent(VideoEvent.ClearRecordedVideo)
}
}
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = {
if (state.isRecording) {
recording?.stop()
viewModel.onEvent(VideoEvent.StopRecording)
} else {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, "${animalId}_video_${System.currentTimeMillis()}")
put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
if (android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.P) {
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/LivingAI/Media/$animalId")
}
}
val mediaStoreOutputOptions = MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
)
.setContentValues(contentValues)
.build()
recording = controller.startRecording(
mediaStoreOutputOptions,
AudioConfig.create(true),
ContextCompat.getMainExecutor(context)
) { recordEvent: VideoRecordEvent ->
when (recordEvent) {
is VideoRecordEvent.Start -> {
viewModel.onEvent(VideoEvent.StartRecording)
}
is VideoRecordEvent.Finalize -> {
if (!recordEvent.hasError()) {
val uri = recordEvent.outputResults.outputUri
viewModel.onEvent(VideoEvent.VideoRecorded(uri))
} else {
viewModel.onEvent(VideoEvent.StopRecording)
// Handle error
}
}
}
}
}
}
) {
Icon(
imageVector = if (state.isRecording) Icons.Default.Stop else Icons.Default.Videocam,
contentDescription = if (state.isRecording) "Stop Recording" else "Start Recording"
)
}
},
floatingActionButtonPosition = FabPosition.Center
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CameraPreview(
modifier = Modifier.fillMaxSize(),
controller = controller,
onFrame = {}
)
}
}
}
}

View File

@ -0,0 +1,41 @@
package com.example.livingai.pages.camera
import android.net.Uri
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class VideoViewModel : ViewModel() {
private val _state = MutableStateFlow(VideoState())
val state = _state.asStateFlow()
fun onEvent(event: VideoEvent) {
when (event) {
is VideoEvent.VideoRecorded -> {
_state.value = _state.value.copy(recordedVideoUri = event.uri, isRecording = false)
}
is VideoEvent.StartRecording -> {
_state.value = _state.value.copy(isRecording = true)
}
is VideoEvent.StopRecording -> {
_state.value = _state.value.copy(isRecording = false)
}
is VideoEvent.ClearRecordedVideo -> {
_state.value = _state.value.copy(recordedVideoUri = null)
}
}
}
}
data class VideoState(
val isRecording: Boolean = false,
val recordedVideoUri: Uri? = null
)
sealed class VideoEvent {
data class VideoRecorded(val uri: Uri) : VideoEvent()
object StartRecording : VideoEvent()
object StopRecording : VideoEvent()
object ClearRecordedVideo : VideoEvent()
}

View File

@ -0,0 +1,71 @@
package com.example.livingai.pages.camera
import android.net.Uri
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.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import com.example.livingai.ui.theme.LivingAITheme
@Composable
fun ViewImageScreen(
imageUri: String,
shouldAllowRetake: Boolean,
showAccept: Boolean,
showBack: Boolean,
onRetake: () -> Unit,
onAccept: () -> Unit,
onBack: () -> Unit
) {
LivingAITheme {
Scaffold {
Box(modifier = Modifier
.fillMaxSize()
.padding(it)) {
Image(
painter = rememberAsyncImagePainter(model = Uri.parse(imageUri)),
contentDescription = "Captured Image",
modifier = Modifier.fillMaxSize()
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
if (shouldAllowRetake) {
OutlinedButton(onClick = onRetake) {
Text("Retake")
}
}
if (showAccept) {
Button(onClick = onAccept) {
Text("Accept")
}
} else {
Button(onClick = onBack) {
Text("Back")
}
}
}
}
}
}
}

View File

@ -0,0 +1,149 @@
package com.example.livingai.pages.camera
import android.net.Uri
import android.widget.VideoView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.livingai.ui.theme.LivingAITheme
@Composable
fun ViewVideoScreen(
videoUri: String,
shouldAllowRetake: Boolean,
onRetake: () -> Unit,
onAccept: () -> Unit,
onBack: () -> Unit
) {
var isPlaying by remember { mutableStateOf(false) }
var videoView: VideoView? by remember { mutableStateOf(null) }
// Keep track if we have sought to the first frame to avoid resetting on recomposition repeatedly if not needed,
// though VideoView state management in Compose can be tricky.
var isPrepared by remember { mutableStateOf(false) }
LivingAITheme {
Scaffold { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
AndroidView(
factory = { context ->
VideoView(context).apply {
setVideoURI(Uri.parse(videoUri))
setOnPreparedListener { mp ->
mp.isLooping = true
isPrepared = true
// Seek to 1ms to show thumbnail (first frame)
seekTo(1)
}
setOnCompletionListener {
isPlaying = false
}
}
},
update = { view ->
videoView = view
if (isPrepared) {
if (isPlaying) {
if (!view.isPlaying) view.start()
} else {
if (view.isPlaying) view.pause()
}
}
},
modifier = Modifier
.fillMaxSize()
.clickable {
isPlaying = !isPlaying
}
)
// Play Button Overlay (Only show when paused)
if (!isPlaying) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable { isPlaying = true }, // Clicking anywhere while paused starts play
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier
.size(64.dp)
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.medium)
.padding(8.dp)
)
}
} else {
// Invisible clickable box to pause
Box(
modifier = Modifier
.fillMaxSize()
.clickable { isPlaying = false },
contentAlignment = Alignment.Center
) {
// No icon, just allows pausing
}
}
if (shouldAllowRetake) {
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(onClick = onRetake) {
Text("Retake")
}
Button(onClick = onAccept) {
Text("Accept")
}
}
} else {
IconButton(
onClick = onBack,
modifier = Modifier
.align(Alignment.TopStart)
.padding(16.dp)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
}
}
}
DisposableEffect(Unit) {
onDispose {
videoView?.stopPlayback()
}
}
}

View File

@ -0,0 +1,40 @@
package com.example.livingai.pages.commons
import androidx.compose.ui.unit.dp
object Dimentions {
//General
val SMALL_PADDING = 8.dp
val MEDIUM_PADDING = 16.dp
val LARGE_PADDING = 24.dp
val ICON_SIZE = 40.dp
val BIG_ICON_SIZE = 100.dp
//TEXT Dimentions
val SMALL_PADDING_TEXT = 12.dp
val MEDIUM_PADDING_TEXT = 24.dp
val LARGE_PADDING_TEXT = 36.dp
//IMAGE Dimentions
val SMALL_PADDING_IMAGE = 12.dp
val MEDIUM_PADDING_IMAGE = 24.dp
val LARGE_PADDING_IMAGE = 36.dp
//BUTTON Dimentions
val SMALL_PADDING_BUTTON = 12.dp
val MEDIUM_PADDING_BUTTON = 24.dp
val LARGE_PADDING_BUTTON = 36.dp
//INPUT Dimentions
val SMALL_PADDING_INPUT = 12.dp
val MEDIUM_PADDING_INPUT = 24.dp
val LARGE_PADDING_INPUT = 36.dp
//COMPONENTS
val INDICATOR_SIZE = 14.dp
val OBJECT_SPACING = 36.dp
val SMALL_OBJECT_LEN = 17.dp
val BOTTOM_BAR_ELEVATION = 5.dp
val BOTTOM_BAR_PADDING = 4.dp
}

View File

@ -0,0 +1,192 @@
package com.example.livingai.pages.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.livingai.R
import com.example.livingai.domain.model.AnimalProfile
import com.example.livingai.pages.commons.Dimentions
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnimalProfileCard(
animalProfile: AnimalProfile,
onEdit: () -> Unit,
onRate: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(Dimentions.SMALL_PADDING_TEXT),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.padding(Dimentions.MEDIUM_PADDING_TEXT)
.fillMaxWidth()
) {
// Left Side: Animal Details
Column(
modifier = Modifier
.weight(1f)
.padding(end = Dimentions.SMALL_PADDING_TEXT)
) {
Text(
text = animalProfile.name,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${stringResource(R.string.label_species)}: ${animalProfile.species}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${stringResource(R.string.label_breed)}: ${animalProfile.breed}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${stringResource(R.string.label_age)}: ${animalProfile.age}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${stringResource(R.string.label_sex)}: ${animalProfile.sex}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Right Side: Image Thumbnails with Dots
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
val images = if (animalProfile.imageUrls.isNotEmpty()) animalProfile.imageUrls else listOf("")
val pagerState = rememberPagerState(pageCount = { images.size })
Box(
modifier = Modifier
.height(120.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
val imageUrl = images[page]
if (imageUrl.isNotEmpty()) {
ThumbnailImage(
imageUri = Uri.parse(imageUrl),
onClick = { /* No action for now */ },
modifier = Modifier.fillMaxSize(),
applySizeModifier = false
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Default.BrokenImage,
contentDescription = null,
tint = Color.Gray
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Dots Indicator
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
repeat(images.size) { iteration ->
val color = if (pagerState.currentPage == iteration)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outlineVariant
Box(
modifier = Modifier
.padding(2.dp)
.clip(CircleShape)
.background(color)
.size(6.dp)
)
}
}
}
}
// Bottom: Action Buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Dimentions.MEDIUM_PADDING_TEXT, vertical = Dimentions.SMALL_PADDING_TEXT),
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedButton(
onClick = onEdit,
modifier = Modifier.weight(1f).padding(end = 4.dp)
) {
Text(stringResource(R.string.edit_button))
}
OutlinedButton(
onClick = onRate,
modifier = Modifier.weight(1f).padding(horizontal = 4.dp)
) {
Text(stringResource(R.string.rate_button))
}
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.weight(1f).padding(start = 4.dp)
) {
Text(stringResource(R.string.delete_button))
}
}
}
}

View File

@ -0,0 +1,72 @@
package com.example.livingai.pages.components
import android.graphics.Bitmap
import android.view.ViewGroup
import androidx.camera.core.CameraSelector
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import java.util.concurrent.Executors
@Composable
fun CameraPreview(
modifier: Modifier = Modifier,
controller: LifecycleCameraController? = null,
onFrame: (Bitmap) -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val cameraController = controller ?: remember { LifecycleCameraController(context) }
LaunchedEffect(cameraController) {
cameraController.setImageAnalysisAnalyzer(cameraExecutor) { imageProxy ->
val bitmap = imageProxy.toBitmap()
onFrame(bitmap)
imageProxy.close()
}
}
// Ensure default setup if it was created internally, or even if passed externally we might want to enforce defaults
// But typically caller configures it if they pass it.
// However, for this component to work as a preview + analysis, we should ensure analysis is enabled.
if (controller == null) {
// Only set defaults if we created it
LaunchedEffect(cameraController) {
cameraController.setEnabledUseCases(LifecycleCameraController.IMAGE_ANALYSIS or LifecycleCameraController.IMAGE_CAPTURE or LifecycleCameraController.VIDEO_CAPTURE)
cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraController.bindToLifecycle(lifecycleOwner)
}
} else {
// If passed externally, we still need to bind it if not already bound?
// Usually the caller binds it. But let's be safe or assume caller does it.
// Actually, let's bind it here to be sure, as this component represents the "Active Camera".
LaunchedEffect(cameraController, lifecycleOwner) {
cameraController.bindToLifecycle(lifecycleOwner)
}
}
AndroidView(
modifier = modifier,
factory = { ctx ->
PreviewView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
this.controller = cameraController
}
},
onRelease = {
// Cleanup if needed
}
)
}

View File

@ -0,0 +1,83 @@
package com.example.livingai.pages.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController
import com.example.livingai.pages.navigation.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommonScaffold(
navController: NavController,
title: String,
content: @Composable (PaddingValues) -> Unit
) {
val bottomNavItems = listOf(
BottomBarItem(
route = Route.OnBoardingScreen,
icon = Icons.Default.Build,
name = "Nav",
notifications = 1,
),
BottomBarItem(
route = Route.HomeScreen,
icon = Icons.Default.Home,
name = "Home",
notifications = 5,
),
BottomBarItem(
route = Route.ListingsScreen,
icon = Icons.Default.List,
name = "Listings",
notifications = 0,
)
)
Scaffold(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding(),
topBar = {
TopAppBar(
title = {
Text(
text = title,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground
)
)
},
bottomBar = {
LivingAIBottomBar(
navItems = bottomNavItems,
navController = navController,
onClick = { navController.navigate(it.route) }
)
}
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) {
content(innerPadding)
}
}
}

View File

@ -0,0 +1,44 @@
package com.example.livingai.pages.components
import androidx.annotation.StringRes
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@Composable
fun FlexButton(
@StringRes text: Int,
onClick: () -> Unit
) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(
text = stringResource(text),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold)
)
}
}
@Composable
fun FlexTextButton(
@StringRes text: Int,
onClick: () -> Unit
) {
TextButton(onClick = onClick) {
Text(
text = stringResource(text),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@ -0,0 +1,85 @@
package com.example.livingai.pages.components
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddAPhoto
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions
@Composable
fun ImageThumbnailButton(
image: Any?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
@StringRes labelRes: Int? = null
) {
Box(
modifier = modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(Dimentions.SMALL_PADDING))
.border(
1.dp,
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
RoundedCornerShape(Dimentions.SMALL_PADDING)
),
contentAlignment = Alignment.Center
) {
if (image != null) {
val uri = if (image is Uri) image else Uri.parse(image.toString())
ThumbnailImage(
imageUri = uri,
onClick = onClick,
modifier = Modifier.fillMaxSize(),
applySizeModifier = false,
requestSizePx = 256
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.clickable { onClick() }
) {
Icon(
imageVector = Icons.Default.AddAPhoto,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
Text(
text = stringResource(id = R.string.label_add_photo),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(top = Dimentions.SMALL_PADDING)
)
if (labelRes != null) {
Text(
text = stringResource(id = labelRes),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
}
}
}
}
}

View File

@ -0,0 +1,68 @@
package com.example.livingai.pages.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabeledDropdown(
@StringRes labelRes: Int,
options: List<String>,
selected: String?,
onSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Column {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selected ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(labelRes)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier = Modifier
.menuAnchor(MenuAnchorType.PrimaryEditable, true)
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
containerColor = MaterialTheme.colorScheme.surfaceContainer
) {
options.forEach { item ->
DropdownMenuItem(
text = { Text(item) },
onClick = {
onSelected(item)
expanded = false
}
)
}
}
}
}
}

View File

@ -0,0 +1,33 @@
package com.example.livingai.pages.components
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@Composable
fun LabeledTextField(
@StringRes labelRes: Int,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(id = labelRes)) },
keyboardOptions = KeyboardOptions(keyboardType = keyboardType)
)
}
}

View File

@ -0,0 +1,132 @@
package com.example.livingai.pages.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.navigation.Route
import com.example.livingai.ui.theme.LivingAITheme
@Composable
fun LivingAIBottomBar(
navItems: List<BottomBarItem>,
navController: NavController,
onClick: (BottomBarItem) -> Unit,
modifier: Modifier = Modifier
) {
val backStackEntry = navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry.value?.destination?.route
NavigationBar(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = Dimentions.BOTTOM_BAR_ELEVATION
) {
navItems.forEach { item ->
val sel = ((currentRoute != null) && (item.route::class == currentRoute::class))
NavigationBarItem(
selected = sel,
onClick = { onClick(item) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSurface,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
),
icon = {
BadgedBox(
badge = {
if (item.notifications > 0) {
Badge(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
) {
Text(
text = item.notifications.toString()
)
}
}
},
content = {
Icon(
imageVector = item.icon,
contentDescription = null,
modifier = Modifier.padding(Dimentions.BOTTOM_BAR_PADDING)
)
}
)
},
label = { Text(text = item.name) }
)
}
}
}
data class BottomBarItem(
val route: Route,
val icon: ImageVector,
val name: String,
val notifications: Int
)
@Preview(showBackground = true)
@Composable
fun showBottomBar() {
val bottomNavItems = listOf(
BottomBarItem(
route = Route.OnBoardingScreen,
icon = Icons.Default.Build,
name = "Nav",
notifications = 1,
),
BottomBarItem(
route = Route.HomeScreen,
icon = Icons.Default.Home,
name = "Home",
notifications = 5,
),
BottomBarItem(
route = Route.ListingsScreen,
icon = Icons.Default.List,
name = "Listings",
notifications = 0,
)
)
LivingAITheme {
Scaffold(
bottomBar = {
LivingAIBottomBar(
navItems = bottomNavItems,
navController = rememberNavController(),
onClick = {},
modifier = Modifier,
)
}
) { innerPadding ->
Text(
text = "test page",
modifier = Modifier.padding(innerPadding)
)
}
}
}

View File

@ -0,0 +1,62 @@
package com.example.livingai.pages.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.onboarding.Page
import com.example.livingai.pages.onboarding.pages
import com.example.livingai.ui.theme.LivingAITheme
@Composable
fun OnBoardingPage(
modifier: Modifier = Modifier,
page: Page
) {
Column(modifier = modifier) {
Image(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.60F),
painter = painterResource(id = page.image),
contentDescription = null,
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(height = Dimentions.MEDIUM_PADDING_TEXT))
Text(
text = stringResource(page.title),
modifier = Modifier.padding(horizontal = Dimentions.MEDIUM_PADDING_TEXT),
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = stringResource(page.description),
modifier = Modifier.padding(horizontal = Dimentions.MEDIUM_PADDING_TEXT),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview(showBackground = true)
@Composable
fun OnBoardingPagePreview() {
LivingAITheme {
OnBoardingPage(
page = pages[0]
)
}
}

View File

@ -0,0 +1,34 @@
package com.example.livingai.pages.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import com.example.livingai.pages.commons.Dimentions.INDICATOR_SIZE
@Composable
fun PageIndicator(
modifier: Modifier = Modifier,
pageSize: Int,
selectedPage: Int,
selectedColor: Color = MaterialTheme.colorScheme.primary,
unselectedColor: Color = MaterialTheme.colorScheme.outlineVariant
) {
Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceBetween) {
repeat(pageSize) { page ->
Box(
modifier = Modifier
.size(INDICATOR_SIZE)
.clip(CircleShape)
.background(color = if (page == selectedPage) selectedColor else unselectedColor)
)
}
}
}

View File

@ -0,0 +1,60 @@
package com.example.livingai.pages.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
@Composable
fun PermissionWrapper(
permissions: List<String> = listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
content: @Composable () -> Unit
) {
val context = LocalContext.current
var arePermissionsGranted by remember {
mutableStateOf(
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
)
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { results ->
arePermissionsGranted = results.values.all { it }
}
LaunchedEffect(key1 = true) {
if (!arePermissionsGranted) {
launcher.launch(permissions.toTypedArray())
}
}
if (arePermissionsGranted) {
content()
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = { launcher.launch(permissions.toTypedArray()) }) {
Text("Grant Permissions")
}
}
}
}

View File

@ -0,0 +1,54 @@
package com.example.livingai.pages.components
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@Composable
fun RadioGroup(
@StringRes titleRes: Int,
options: List<String>,
selected: String?,
onSelected: (String) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(id = titleRes),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
options.forEach { option ->
Row(
modifier = Modifier.clickable { onSelected(option) },
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = selected == option,
onClick = { onSelected(option) }
)
Text(
text = option,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}

View File

@ -0,0 +1,73 @@
package com.example.livingai.pages.components
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.example.livingai.pages.commons.Dimentions
@Composable
fun RatingScale(
@StringRes label: Int,
value: Int,
maxValue: Int,
onValueChange: (Int) -> Unit
) {
Column(modifier = Modifier.padding(vertical = Dimentions.SMALL_PADDING_TEXT)) {
Text(
text = stringResource(id = label),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = Dimentions.SMALL_PADDING)
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(Dimentions.SMALL_PADDING))
.border(
1.dp,
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
RoundedCornerShape(Dimentions.SMALL_PADDING)
)
.padding(Dimentions.SMALL_PADDING),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
for (i in 1..maxValue) {
val isSelected = i == value
Box(
modifier = Modifier
.size(Dimentions.ICON_SIZE)
.clip(CircleShape)
.background(
if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
)
.border(1.dp, MaterialTheme.colorScheme.primary, CircleShape)
.clickable { onValueChange(i) },
contentAlignment = Alignment.Center
) {
Text(
text = i.toString(),
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}

View File

@ -0,0 +1,47 @@
package com.example.livingai.pages.components
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlin.math.roundToInt
@Composable
fun ThumbnailImage(
imageUri: Uri,
onClick: () -> Unit,
modifier: Modifier = Modifier,
thumbnailSize: Dp = 120.dp,
applySizeModifier: Boolean = true,
requestSizePx: Int? = null
) {
val context = LocalContext.current
val sizeInPx = requestSizePx ?: with(LocalDensity.current) { thumbnailSize.toPx().roundToInt() }
var finalModifier = modifier.clickable { onClick() }
if (applySizeModifier) {
finalModifier = finalModifier.size(thumbnailSize)
}
Image(
painter = rememberAsyncImagePainter(
ImageRequest.Builder(context)
.data(imageUri)
.size(sizeInPx)
.crossfade(true)
.build()
),
contentDescription = "Thumbnail",
modifier = finalModifier,
contentScale = ContentScale.Crop
)
}

View File

@ -0,0 +1,90 @@
package com.example.livingai.pages.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.VideoFrameDecoder
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions
@Composable
fun VideoThumbnailButton(
videoSource: Any?,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val imageLoader = ImageLoader.Builder(context)
.components {
add(VideoFrameDecoder.Factory())
}
.build()
Box(
modifier = modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(Dimentions.SMALL_PADDING))
.border(
1.dp,
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
RoundedCornerShape(Dimentions.SMALL_PADDING)
)
.clickable { onClick() },
contentAlignment = Alignment.Center
) {
if (videoSource != null) {
AsyncImage(
model = videoSource,
imageLoader = imageLoader,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Overlay video icon to indicate it's a video
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
modifier = Modifier.align(Alignment.Center)
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
Text(
text = stringResource(id = R.string.label_add_video),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(top = Dimentions.SMALL_PADDING)
)
}
}
}
}

View File

@ -0,0 +1,81 @@
package com.example.livingai.pages.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.navigation.Route
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(navController: NavController) {
Scaffold { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(Dimentions.SMALL_PADDING_TEXT),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.size(Dimentions.BIG_ICON_SIZE)
)
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(Dimentions.MEDIUM_PADDING))
HomeButton(
text = stringResource(id = R.string.top_bar_add_profile),
onClick = { navController.navigate(Route.AddProfileScreen()) }
)
HomeButton(
text = stringResource(id = R.string.top_bar_listings),
onClick = { navController.navigate(Route.ListingsScreen) }
)
HomeButton(
text = stringResource(id = R.string.top_bar_settings),
onClick = { navController.navigate(Route.SettingsScreen) }
)
}
}
}
@Composable
fun HomeButton(
text: String,
onClick: () -> Unit
) {
Button(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Dimentions.SMALL_PADDING_TEXT)
) {
Text(text = text)
}
}

View File

@ -0,0 +1,33 @@
package com.example.livingai.pages.home
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.pages.navigation.Route
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class HomeViewModel(
private val appEntryUseCases: AppEntryUseCases
): ViewModel() {
private val _splashCondition = mutableStateOf(true)
val splashCondition: State<Boolean> = _splashCondition
private val _startDestination = mutableStateOf<Route>(Route.AppStartNavigation)
val startDestination: State<Route> = _startDestination
init {
appEntryUseCases.readAppEntry().onEach { shouldStartFromHomeScreen ->
if(shouldStartFromHomeScreen){
_startDestination.value = Route.HomeNavigation
}else{
_startDestination.value = Route.AppStartNavigation
}
delay(350) //Without this delay, the onBoarding screen will show for a momentum.
_splashCondition.value = false
}.launchIn(viewModelScope)
}
}

View File

@ -0,0 +1,57 @@
package com.example.livingai.pages.listings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.example.livingai.R
import com.example.livingai.domain.model.AnimalProfile
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.AnimalProfileCard
import com.example.livingai.pages.components.CommonScaffold
import com.example.livingai.pages.navigation.Route
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListingsScreen(
navController: NavController,
viewModel: ListingsViewModel = koinViewModel()
) {
val animalProfiles: LazyPagingItems<AnimalProfile> = viewModel.animalProfiles.collectAsLazyPagingItems()
CommonScaffold(
navController = navController,
title = stringResource(R.string.top_bar_listings)
) { innerPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentPadding = PaddingValues(Dimentions.SMALL_PADDING_TEXT),
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING_TEXT)
) {
items(count = animalProfiles.itemCount) { index ->
val item = animalProfiles[index]
item?.let { profile ->
AnimalProfileCard(
animalProfile = profile,
onEdit = {
navController.navigate(Route.AddProfileScreen(animalId = profile.animalId, loadEntry = true))
},
onRate = { navController.navigate(Route.RatingScreen(animalId = profile.animalId)) },
onDelete = { viewModel.deleteAnimalProfile(profile.animalId) }
)
}
}
}
}
}

View File

@ -0,0 +1,20 @@
package com.example.livingai.pages.listings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.cachedIn
import com.example.livingai.domain.usecases.ProfileListing.ProfileListingUseCase
import kotlinx.coroutines.launch
class ListingsViewModel(
private val profileListingUseCase: ProfileListingUseCase
) : ViewModel() {
val animalProfiles = profileListingUseCase.getAnimalProfiles().cachedIn(viewModelScope)
fun deleteAnimalProfile(animalId: String) {
viewModelScope.launch {
profileListingUseCase.deleteAnimalProfile(animalId)
}
}
}

View File

@ -0,0 +1,170 @@
package com.example.livingai.pages.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.livingai.pages.addprofile.AddProfileScreen
import com.example.livingai.pages.addprofile.AddProfileViewModel
import com.example.livingai.pages.camera.CameraScreen
import com.example.livingai.pages.camera.VideoRecordScreen
import com.example.livingai.pages.camera.ViewImageScreen
import com.example.livingai.pages.camera.ViewVideoScreen
import com.example.livingai.pages.home.HomeScreen
import com.example.livingai.pages.listings.ListingsScreen
import com.example.livingai.pages.onboarding.OnBoardingScreen
import com.example.livingai.pages.onboarding.OnBoardingViewModel
import com.example.livingai.pages.ratings.RatingScreen
import com.example.livingai.pages.settings.SettingsScreen
import org.koin.androidx.compose.koinViewModel
@Composable
fun NavGraph(
startDestination: Route
) {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = startDestination) {
navigation<Route.AppStartNavigation>(
startDestination = Route.OnBoardingScreen
) {
composable<Route.OnBoardingScreen> {
val onBoardingViewModel: OnBoardingViewModel = koinViewModel()
OnBoardingScreen(
events = onBoardingViewModel::onEvent
)
}
}
navigation<Route.HomeNavigation>(
startDestination = Route.HomeScreen
) {
composable<Route.HomeScreen> {
HomeScreen(navController = navController)
}
composable<Route.ListingsScreen> {
ListingsScreen(
navController = navController
)
}
composable<Route.AddProfileScreen> { backStackEntry ->
val route: Route.AddProfileScreen = backStackEntry.toRoute()
val viewModel: AddProfileViewModel = koinViewModel()
val currentId by viewModel.currentAnimalId
LaunchedEffect(route.animalId, route.loadEntry) {
if (route.loadEntry) {
viewModel.loadAnimalDetails(route.animalId)
}
}
// Handle new media from saved state handle
val newImageUri = backStackEntry.savedStateHandle.get<String>("newImageUri")
val newImageOrientation = backStackEntry.savedStateHandle.get<String>("newImageOrientation")
val newVideoUri = backStackEntry.savedStateHandle.get<String>("newVideoUri")
LaunchedEffect(newImageUri, newImageOrientation) {
if (newImageUri != null && newImageOrientation != null) {
viewModel.addPhoto(newImageOrientation, newImageUri)
backStackEntry.savedStateHandle.remove<String>("newImageUri")
backStackEntry.savedStateHandle.remove<String>("newImageOrientation")
}
}
LaunchedEffect(newVideoUri) {
if (newVideoUri != null) {
viewModel.setVideo(newVideoUri)
backStackEntry.savedStateHandle.remove<String>("newVideoUri")
}
}
val photos = viewModel.photos
AddProfileScreen(
navController = navController,
viewModel = viewModel,
onSave = {
viewModel.saveAnimalDetails()
navController.previousBackStackEntry?.savedStateHandle?.set("refresh_listings", true)
navController.popBackStack()
},
onCancel = { navController.popBackStack() },
onTakePhoto = { orientation ->
val existingPhoto = photos[orientation]
if (existingPhoto != null) {
navController.navigate(Route.ViewImageScreen(
imageUri = existingPhoto,
shouldAllowRetake = true,
orientation = orientation,
showAccept = false,
animalId = currentId ?: "unknown"
))
} else {
navController.navigate(Route.CameraScreen(orientation = orientation, animalId = currentId ?: "unknown"))
}
},
onTakeVideo = { navController.navigate(Route.VideoRecordScreen(animalId = currentId ?: "unknown")) }
)
}
composable<Route.RatingScreen> {
RatingScreen()
}
composable<Route.SettingsScreen> {
SettingsScreen(navController = navController)
}
composable<Route.CameraScreen> { backStackEntry ->
val route: Route.CameraScreen = backStackEntry.toRoute()
CameraScreen(navController = navController, orientation = route.orientation, animalId = route.animalId)
}
composable<Route.VideoRecordScreen> { backStackEntry ->
val route: Route.VideoRecordScreen = backStackEntry.toRoute()
VideoRecordScreen(navController = navController, animalId = route.animalId)
}
composable<Route.ViewImageScreen> { backStackEntry ->
val args: Route.ViewImageScreen = backStackEntry.toRoute()
ViewImageScreen(
imageUri = args.imageUri,
shouldAllowRetake = args.shouldAllowRetake,
showAccept = args.showAccept,
showBack = args.showBack,
onRetake = {
navController.popBackStack()
navController.navigate(Route.CameraScreen(orientation = args.orientation, animalId = args.animalId))
},
onAccept = {
navController.getBackStackEntry<Route.AddProfileScreen>().savedStateHandle["newImageUri"] = args.imageUri
navController.getBackStackEntry<Route.AddProfileScreen>().savedStateHandle["newImageOrientation"] = args.orientation
navController.popBackStack<Route.AddProfileScreen>(inclusive = false)
},
onBack = { navController.popBackStack() }
)
}
composable<Route.ViewVideoScreen> { backStackEntry ->
val args: Route.ViewVideoScreen = backStackEntry.toRoute()
ViewVideoScreen(
videoUri = args.videoUri,
shouldAllowRetake = args.shouldAllowRetake,
onRetake = {
navController.popBackStack()
navController.navigate(Route.VideoRecordScreen(animalId = args.animalId))
},
onAccept = {
navController.getBackStackEntry<Route.AddProfileScreen>().savedStateHandle["newVideoUri"] = args.videoUri
navController.popBackStack<Route.AddProfileScreen>(inclusive = false)
},
onBack = { navController.popBackStack() }
)
}
}
}
}

View File

@ -0,0 +1,31 @@
package com.example.livingai.pages.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed class Route {
@Serializable
object AppStartNavigation : Route()
@Serializable
object HomeNavigation : Route()
@Serializable
object OnBoardingScreen : Route()
@Serializable
object HomeScreen : Route()
@Serializable
object ListingsScreen : Route()
@Serializable
data class AddProfileScreen(val animalId: String? = null, val loadEntry: Boolean = false) : Route()
@Serializable
data class RatingScreen(val animalId: String) : Route()
@Serializable
data class CameraScreen(val orientation: String? = null, val animalId: String) : Route()
@Serializable
data class VideoRecordScreen(val animalId: String) : Route()
@Serializable
data class ViewImageScreen(val imageUri: String, val shouldAllowRetake: Boolean, val orientation: String? = null, val showAccept: Boolean = false, val showBack: Boolean = false, val animalId: String) : Route()
@Serializable
data class ViewVideoScreen(val videoUri: String, val shouldAllowRetake: Boolean, val animalId: String) : Route()
@Serializable
object SettingsScreen : Route()
}

View File

@ -0,0 +1,6 @@
package com.example.livingai.pages.onboarding
sealed class OnBoardingEvents {
object SaveAppEntry: OnBoardingEvents()
data class SaveLanguage(val language: String): OnBoardingEvents()
}

View File

@ -0,0 +1,111 @@
package com.example.livingai.pages.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions.OBJECT_SPACING
import com.example.livingai.pages.commons.Dimentions.SMALL_OBJECT_LEN
import com.example.livingai.pages.commons.Dimentions.SMALL_PADDING_BUTTON
import com.example.livingai.pages.components.FlexButton
import com.example.livingai.pages.components.FlexTextButton
import com.example.livingai.pages.components.OnBoardingPage
import com.example.livingai.pages.components.PageIndicator
import kotlinx.coroutines.launch
@Composable
fun OnBoardingScreen(
events: (OnBoardingEvents) -> Unit
) {
val pageLen = pages.size
Column(modifier = Modifier.fillMaxSize()) {
val pagerState = rememberPagerState(initialPage = 0) {
pages.size
}
val buttonState = remember {
derivedStateOf {
when (pagerState.currentPage) {
0 -> listOf(R.string.english_language, R.string.hindi_language)
pageLen - 1 -> listOf(R.string.back_button, R.string.get_started_button)
else -> listOf(R.string.back_button, R.string.next_button)
}
}
}
HorizontalPager(state = pagerState) {
OnBoardingPage(page = pages[it])
}
Spacer(modifier = Modifier.weight(1F))
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OBJECT_SPACING)
.navigationBarsPadding(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
PageIndicator(
modifier = Modifier.width(SMALL_OBJECT_LEN * pageLen),
pageSize = pageLen,
selectedPage = pagerState.currentPage
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
val scope = rememberCoroutineScope()
if (pagerState.currentPage != 0) {
FlexTextButton(
text = buttonState.value[0],
onClick = {
scope.launch {
pagerState.animateScrollToPage(page = pagerState.currentPage - 1)
}
}
)
} else {
FlexButton(
text = buttonState.value[0],
onClick = {
events(OnBoardingEvents.SaveLanguage("en"))
scope.launch {
pagerState.animateScrollToPage(page = pagerState.currentPage + 1)
}
}
)
Spacer(modifier = Modifier.width(SMALL_PADDING_BUTTON))
}
FlexButton(
text = buttonState.value[1],
onClick = {
if (pagerState.currentPage == 0) {
events(OnBoardingEvents.SaveLanguage("hi"))
}
if (pagerState.currentPage + 1 >= pageLen) {
events(OnBoardingEvents.SaveAppEntry)
} else {
scope.launch {
pagerState.animateScrollToPage(page = pagerState.currentPage + 1)
}
}
}
)
}
}
}
}

View File

@ -0,0 +1,38 @@
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 kotlinx.coroutines.launch
class OnBoardingViewModel(
private val appEntryUseCases: AppEntryUseCases,
private val settingsRepository: SettingsRepository
): ViewModel() {
fun onEvent(event: OnBoardingEvents) {
when(event) {
is OnBoardingEvents.SaveAppEntry -> {
saveAppEntry()
}
is OnBoardingEvents.SaveLanguage -> {
saveLanguage(event.language)
}
}
}
private fun saveAppEntry() {
viewModelScope.launch {
appEntryUseCases.saveAppEntry()
}
}
private fun saveLanguage(language: String) {
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))
}
}
}

View File

@ -0,0 +1,34 @@
package com.example.livingai.pages.onboarding
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.example.livingai.R
data class Page(
@StringRes val title: Int,
@StringRes val description: Int,
@DrawableRes val image: Int
)
val pages = listOf(
Page(
title = R.string.language_onboarding_title,
description = R.string.language_onboarding_description,
image = R.drawable.splash
),
Page(
title = R.string.buy_onboarding_title,
description = R.string.buy_onboarding_description,
image = R.drawable.splash
),
Page(
title = R.string.sell_onboarding_title,
description = R.string.sell_onboarding_description,
image = R.drawable.splash
),
Page(
title = R.string.amenities_onboarding_title,
description = R.string.amenities_onboarding_description,
image = R.drawable.splash
)
)

View File

@ -0,0 +1,142 @@
package com.example.livingai.pages.ratings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.ImageThumbnailButton
import com.example.livingai.pages.components.RatingScale
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RatingScreen(
navController: NavController? = null, // Nullable for preview or if passed from NavGraph
viewModel: RatingViewModel = koinViewModel()
) {
val ratingState by viewModel.ratingState.collectAsState()
val animalImages by viewModel.animalImages.collectAsState()
Scaffold(
topBar = {
// You might want a TopAppBar here, reusing CommonScaffold or similar
Text(
text = stringResource(id = R.string.top_bar_ratings),
modifier = Modifier.padding(Dimentions.MEDIUM_PADDING)
)
}
) { innerPadding ->
ratingState?.let { rating ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = Dimentions.SMALL_PADDING_TEXT),
verticalArrangement = Arrangement.spacedBy(Dimentions.SMALL_PADDING)
) {
// 1. Image Thumbnail
item {
if (animalImages.isNotEmpty()) {
ImageThumbnailButton(
image = animalImages.firstOrNull(),
onClick = { /* TODO: Handle click */ }
)
}
}
// 2. Description / Comments Box
item {
OutlinedTextField(
value = rating.bodyConditionComments,
onValueChange = { viewModel.onRatingChange(rating.copy(bodyConditionComments = it)) },
label = { Text(stringResource(id = R.string.label_rating_comments)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3
)
}
// 3. Rating Components
item { RatingScale(R.string.label_overall_rating, rating.overallRating, 10) { viewModel.onRatingChange(rating.copy(overallRating = it)) } }
item { RatingScale(R.string.label_health_rating, rating.healthRating, 10) { viewModel.onRatingChange(rating.copy(healthRating = it)) } }
item { RatingScale(R.string.label_breed_rating, rating.breedRating, 10) { viewModel.onRatingChange(rating.copy(breedRating = it)) } }
// Physical Attributes
item { RatingScale(R.string.label_stature, rating.stature, 10) { viewModel.onRatingChange(rating.copy(stature = it)) } }
item { RatingScale(R.string.label_chest_width, rating.chestWidth, 10) { viewModel.onRatingChange(rating.copy(chestWidth = it)) } }
item { RatingScale(R.string.label_body_depth, rating.bodyDepth, 10) { viewModel.onRatingChange(rating.copy(bodyDepth = it)) } }
item { RatingScale(R.string.label_angularity, rating.angularity, 10) { viewModel.onRatingChange(rating.copy(angularity = it)) } }
item { RatingScale(R.string.label_rump_angle, rating.rumpAngle, 10) { viewModel.onRatingChange(rating.copy(rumpAngle = it)) } }
item { RatingScale(R.string.label_rump_width, rating.rumpWidth, 10) { viewModel.onRatingChange(rating.copy(rumpWidth = it)) } }
item { RatingScale(R.string.label_rear_leg_set, rating.rearLegSet, 10) { viewModel.onRatingChange(rating.copy(rearLegSet = it)) } }
item { RatingScale(R.string.label_rear_leg_rear_view, rating.rearLegRearView, 10) { viewModel.onRatingChange(rating.copy(rearLegRearView = it)) } }
item { RatingScale(R.string.label_foot_angle, rating.footAngle, 10) { viewModel.onRatingChange(rating.copy(footAngle = it)) } }
// Udder Attributes
item { RatingScale(R.string.label_fore_udder_attachment, rating.foreUdderAttachment, 10) { viewModel.onRatingChange(rating.copy(foreUdderAttachment = it)) } }
item { RatingScale(R.string.label_rear_udder_height, rating.rearUdderHeight, 10) { viewModel.onRatingChange(rating.copy(rearUdderHeight = it)) } }
item { RatingScale(R.string.label_central_ligament, rating.centralLigament, 10) { viewModel.onRatingChange(rating.copy(centralLigament = it)) } }
item { RatingScale(R.string.label_udder_depth, rating.udderDepth, 10) { viewModel.onRatingChange(rating.copy(udderDepth = it)) } }
item { RatingScale(R.string.label_front_teat_position, rating.frontTeatPosition, 10) { viewModel.onRatingChange(rating.copy(frontTeatPosition = it)) } }
item { RatingScale(R.string.label_teat_length, rating.teatLength, 10) { viewModel.onRatingChange(rating.copy(teatLength = it)) } }
item { RatingScale(R.string.label_rear_teat_position, rating.rearTeatPosition, 10) { viewModel.onRatingChange(rating.copy(rearTeatPosition = it)) } }
item { RatingScale(R.string.label_rear_udder_width, rating.rearUdderWidth, 10) { viewModel.onRatingChange(rating.copy(rearUdderWidth = it)) } }
item { RatingScale(R.string.label_teat_thickness, rating.teatThickness, 10) { viewModel.onRatingChange(rating.copy(teatThickness = it)) } }
// Other Attributes
item { RatingScale(R.string.label_locomotion, rating.locomotion, 10) { viewModel.onRatingChange(rating.copy(locomotion = it)) } }
item { RatingScale(R.string.label_body_condition_score, rating.bodyConditionScore, 10) { viewModel.onRatingChange(rating.copy(bodyConditionScore = it)) } }
item { RatingScale(R.string.label_hock_development, rating.hockDevelopment, 10) { viewModel.onRatingChange(rating.copy(hockDevelopment = it)) } }
item { RatingScale(R.string.label_bone_structure, rating.boneStructure, 10) { viewModel.onRatingChange(rating.copy(boneStructure = it)) } }
item { RatingScale(R.string.label_muscularity, rating.muscularity, 10) { viewModel.onRatingChange(rating.copy(muscularity = it)) } }
// 4. Buttons
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Dimentions.MEDIUM_PADDING),
horizontalArrangement = Arrangement.spacedBy(Dimentions.MEDIUM_PADDING)
) {
OutlinedButton(
onClick = { navController?.popBackStack() },
modifier = Modifier.weight(1f)
) {
Text(text = stringResource(id = R.string.btn_cancel))
}
Button(
onClick = {
viewModel.saveRatings()
navController?.popBackStack()
},
modifier = Modifier.weight(1f)
) {
Text(text = stringResource(id = R.string.btn_save_ratings))
}
}
}
item {
Spacer(modifier = Modifier.height(Dimentions.LARGE_PADDING))
}
}
}
}
}

View File

@ -0,0 +1,60 @@
package com.example.livingai.pages.ratings
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.domain.model.AnimalRating
import com.example.livingai.domain.usecases.GetAnimalDetails
import com.example.livingai.domain.usecases.GetAnimalRatings
import com.example.livingai.domain.usecases.SetAnimalRatings
import com.example.livingai.pages.ratings.util.provideEmptyAnimalRating
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class RatingViewModel(
private val getAnimalDetails: GetAnimalDetails,
private val getAnimalRatings: GetAnimalRatings,
private val setAnimalRatings: SetAnimalRatings,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _ratingState = MutableStateFlow<AnimalRating?>(null)
val ratingState = _ratingState.asStateFlow()
private val _animalImages = MutableStateFlow<List<String>>(emptyList())
val animalImages = _animalImages.asStateFlow()
private val animalId: String = savedStateHandle.get<String>("animalId")!!
init {
loadAnimalDetails()
loadAnimalRatings()
}
private fun loadAnimalDetails() {
getAnimalDetails(animalId).onEach {
_animalImages.value = it?.images ?: emptyList()
}.launchIn(viewModelScope)
}
private fun loadAnimalRatings() {
getAnimalRatings(animalId).onEach {
_ratingState.value = it ?: provideEmptyAnimalRating(animalId)
}.launchIn(viewModelScope)
}
fun onRatingChange(newRating: AnimalRating) {
_ratingState.value = newRating
}
fun saveRatings() {
viewModelScope.launch {
_ratingState.value?.let {
setAnimalRatings(it)
}
}
}
}

View File

@ -0,0 +1,34 @@
package com.example.livingai.pages.ratings.util
import com.example.livingai.domain.model.AnimalRating
fun provideEmptyAnimalRating(animalId: String) = AnimalRating(
animalId = animalId,
overallRating = 0,
healthRating = 0,
breedRating = 0,
stature = 0,
chestWidth = 0,
bodyDepth = 0,
angularity = 0,
rumpAngle = 0,
rumpWidth = 0,
rearLegSet = 0,
rearLegRearView = 0,
footAngle = 0,
foreUdderAttachment = 0,
rearUdderHeight = 0,
centralLigament = 0,
udderDepth = 0,
frontTeatPosition = 0,
teatLength = 0,
rearTeatPosition = 0,
locomotion = 0,
bodyConditionScore = 0,
hockDevelopment = 0,
boneStructure = 0,
rearUdderWidth = 0,
teatThickness = 0,
muscularity = 0,
bodyConditionComments = ""
)

View File

@ -0,0 +1,77 @@
package com.example.livingai.pages.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.example.livingai.R
import com.example.livingai.pages.commons.Dimentions
import com.example.livingai.pages.components.CommonScaffold
import com.example.livingai.pages.components.RadioGroup
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
navController: NavController,
viewModel: SettingsViewModel = koinViewModel()
) {
val settings by viewModel.settings.collectAsState()
val languageEntries = stringArrayResource(id = R.array.language_entries)
val languageValues = stringArrayResource(id = R.array.language_values)
val languageMap = languageEntries.zip(languageValues).toMap()
// Find the display name for the currently selected language value
val selectedLanguageEntry = languageMap.entries.find { it.value == settings.language }?.key ?: languageEntries.first()
CommonScaffold(
navController = navController,
title = stringResource(id = R.string.top_bar_settings)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(Dimentions.SMALL_PADDING_TEXT)
) {
RadioGroup(
titleRes = R.string.language,
options = languageEntries.toList(),
selected = selectedLanguageEntry,
onSelected = { selectedEntry ->
val selectedValue = languageMap[selectedEntry] ?: languageValues.first()
viewModel.saveSettings(settings.copy(language = selectedValue))
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Dimentions.SMALL_PADDING_TEXT),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(id = R.string.auto_capture))
Switch(
checked = settings.isAutoCaptureOn,
onCheckedChange = { isChecked ->
viewModel.saveSettings(settings.copy(isAutoCaptureOn = isChecked))
}
)
}
}
}
}

View File

@ -0,0 +1,33 @@
package com.example.livingai.pages.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.livingai.data.local.model.SettingsData
import com.example.livingai.domain.repository.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsRepository: SettingsRepository) : ViewModel() {
private val _settings = MutableStateFlow(SettingsData("en", false))
val settings = _settings.asStateFlow()
init {
getSettings()
}
private fun getSettings() {
settingsRepository.getSettings().onEach {
_settings.value = it
}.launchIn(viewModelScope)
}
fun saveSettings(settings: SettingsData) {
viewModelScope.launch {
settingsRepository.saveSettings(settings)
}
}
}

View File

@ -0,0 +1,90 @@
package com.example.livingai.ui.theme
import androidx.compose.ui.graphics.Color
// Nature Theme Colors
val md_theme_light_primary = Color(0xFF2E7D32)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFB9F6CA)
val md_theme_light_onPrimaryContainer = Color(0xFF00210B)
val md_theme_light_secondary = Color(0xFF558B2F)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFDCEDC8)
val md_theme_light_onSecondaryContainer = Color(0xFF131F0A)
val md_theme_light_tertiary = Color(0xFF00838F)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFB2EBF2)
val md_theme_light_onTertiaryContainer = Color(0xFF001F24)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFF1F8E9)
val md_theme_light_onBackground = Color(0xFF1A1C19)
val md_theme_light_surface = Color(0xFFF1F8E9)
val md_theme_light_onSurface = Color(0xFF1A1C19)
val md_theme_light_surfaceVariant = Color(0xFFDDE5DB)
val md_theme_light_onSurfaceVariant = Color(0xFF414942)
val md_theme_light_outline = Color(0xFF717971)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1EB)
val md_theme_light_inverseSurface = Color(0xFF2F312D)
val md_theme_light_inversePrimary = Color(0xFF66BB6A)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF2E7D32)
val md_theme_light_outlineVariant = Color(0xFFC1C9BF)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF66BB6A)
val md_theme_dark_onPrimary = Color(0xFF003916)
val md_theme_dark_primaryContainer = Color(0xFF005223)
val md_theme_dark_onPrimaryContainer = Color(0xFFB9F6CA)
val md_theme_dark_secondary = Color(0xFFAED581)
val md_theme_dark_onSecondary = Color(0xFF25351A)
val md_theme_dark_secondaryContainer = Color(0xFF3C4D31)
val md_theme_dark_onSecondaryContainer = Color(0xFFDCEDC8)
val md_theme_dark_tertiary = Color(0xFF4DD0E1)
val md_theme_dark_onTertiary = Color(0xFF00363D)
val md_theme_dark_tertiaryContainer = Color(0xFF004F58)
val md_theme_dark_onTertiaryContainer = Color(0xFFB2EBF2)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1A1C19)
val md_theme_dark_onBackground = Color(0xFFE2E3DD)
val md_theme_dark_surface = Color(0xFF1A1C19)
val md_theme_dark_onSurface = Color(0xFFE2E3DD)
val md_theme_dark_surfaceVariant = Color(0xFF414942)
val md_theme_dark_onSurfaceVariant = Color(0xFFC1C9BF)
val md_theme_dark_outline = Color(0xFF8B938A)
val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19)
val md_theme_dark_inverseSurface = Color(0xFFE2E3DD)
val md_theme_dark_inversePrimary = Color(0xFF2E7D32)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF66BB6A)
val md_theme_dark_outlineVariant = Color(0xFF414942)
val md_theme_dark_scrim = Color(0xFF000000)
// Compatibility with older definitions
val BlueGray = Color(0xFFA0A3BD)
val WhiteGray = Color(0xFFB0B3B8)
val primaryCol = md_theme_light_primary
val secondaryCol = md_theme_light_secondary
val alternateCol = md_theme_light_tertiary
val on_primaryCol = md_theme_light_onPrimary
val on_secondaryCol = md_theme_light_onSecondary
val on_alternateCol = md_theme_light_onTertiary
val primary_contentCol = md_theme_light_onPrimary
val secondary_contentCol = md_theme_light_onSecondary
val alternate_contentCol = md_theme_light_onTertiary
val primary_containerCol = md_theme_light_primaryContainer
val secondary_containerCol = md_theme_light_secondaryContainer
val alternate_containerCol = md_theme_light_tertiaryContainer
val primary_container_contentCol = md_theme_light_onPrimaryContainer
val secondary_container_contentCol = md_theme_light_onSecondaryContainer
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

View File

@ -0,0 +1,111 @@
package com.example.livingai.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
private val LightColorScheme = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
@Composable
fun LivingAITheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

Some files were not shown because too many files have changed in this diff Show More