feat: add database lab_test

This commit is contained in:
2026-05-06 11:50:20 +03:30
parent 1fb7477b5c
commit 989200cd9b
19 changed files with 591 additions and 13 deletions
+6
View File
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
android {
@@ -82,4 +83,9 @@ dependencies {
//navigation
implementation(libs.androidx.navigation.compose)
// Room Database
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?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-permission android:name="android.permission.INTERNET" />
<application
android:name=".DrugApp"
android:allowBackup="true"
Binary file not shown.
@@ -1,7 +1,10 @@
package com.approagency.drug
import android.app.Application
import com.approagency.drug.data.local.database.LabDatabase
import com.approagency.drug.di.appModule
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
@@ -13,5 +16,10 @@ class DrugApp : Application(){
modules(appModule)
printLogger()
}
// Preload database (optional, for faster access)
GlobalScope.launch {
LabDatabase.getInstance(this@DrugApp)
}
}
}
@@ -0,0 +1,32 @@
package com.approagency.drug.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.approagency.drug.data.local.entities.TestGroupEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TestGroupDao {
@Query("SELECT * FROM testGroup ORDER BY Id")
fun getAllGroups(): Flow<List<TestGroupEntity>>
@Query("SELECT * FROM testGroup WHERE Id = :groupId")
suspend fun getGroupById(groupId: Int): TestGroupEntity?
@Query("SELECT * FROM testGroup WHERE Isparent = '1'")
fun getParentGroups(): Flow<List<TestGroupEntity>>
@Query("SELECT * FROM testGroup WHERE Isparent = '0'")
fun getChildGroups(): Flow<List<TestGroupEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGroup(group: TestGroupEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllGroups(groups: List<TestGroupEntity>)
@Query("DELETE FROM testGroup")
suspend fun deleteAllGroups()
}
@@ -0,0 +1,36 @@
package com.approagency.drug.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.approagency.drug.data.local.entities.TestItemEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface TestItemDao {
@Query("SELECT * FROM testItem ORDER BY Id")
fun getAllItems(): Flow<List<TestItemEntity>>
@Query("SELECT * FROM testItem WHERE Group_Id = :groupId ORDER BY Id")
fun getItemsByGroupId(groupId: Int): Flow<List<TestItemEntity>>
@Query("SELECT * FROM testItem WHERE Id = :itemId")
suspend fun getItemById(itemId: Int): TestItemEntity?
@Query("""
SELECT ti.* FROM testItem ti
INNER JOIN testGroup tg ON ti.Group_Id = tg.Id
WHERE tg.Isparent = '0' AND ti.Title LIKE '%' || :searchQuery || '%'
""")
fun searchTestItems(searchQuery: String): Flow<List<TestItemEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: TestItemEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllItems(items: List<TestItemEntity>)
@Query("DELETE FROM testItem")
suspend fun deleteAllItems()
}
@@ -0,0 +1,43 @@
package com.approagency.drug.data.local.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.approagency.drug.data.local.dao.TestGroupDao
import com.approagency.drug.data.local.dao.TestItemDao
import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.drug.data.local.entities.TestItemEntity
@Database(
entities = [
TestGroupEntity::class,
TestItemEntity::class
],
version = 1,
exportSchema = false
)
abstract class LabDatabase : RoomDatabase() {
abstract fun testGroupDao(): TestGroupDao
abstract fun testItemDao(): TestItemDao
companion object {
@Volatile
private var INSTANCE: LabDatabase? = null
fun getInstance(context: Context): LabDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
LabDatabase::class.java,
"lab_tests.db" // This will use your existing database
)
.createFromAsset("lab_tests.db") // IMPORTANT: Copy from assets
.fallbackToDestructiveMigration() // For development only
.build()
INSTANCE = instance
instance
}
}
}
}
@@ -0,0 +1,24 @@
package com.approagency.drug.data.local.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "testGroup")
data class TestGroupEntity(
@PrimaryKey
@ColumnInfo(name = "Id")
val id: Int,
@ColumnInfo(name = "Ename")
val ename: String?,
@ColumnInfo(name = "Fname")
val fname: String?,
@ColumnInfo(name = "Detail")
val detail: String?,
@ColumnInfo(name = "Isparent")
val isParent: String?
)
@@ -0,0 +1,37 @@
package com.approagency.drug.data.local.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "testItem",
// foreignKeys = [
// ForeignKey(
// entity = TestGroupEntity::class,
// parentColumns = ["Id"],
// childColumns = ["Group_Id"],
// onDelete = ForeignKey.CASCADE
// )
// ],
indices = [Index(value = ["Group_Id"])]
)
data class TestItemEntity(
@PrimaryKey
@ColumnInfo(name = "Id")
val id: Int,
@ColumnInfo(name = "Group_Id")
val groupId: Int,
@ColumnInfo(name = "Title")
val title: String?,
@ColumnInfo(name = "Normal_Value")
val normalValue: String?,
@ColumnInfo(name = "Detail")
val detail: String?
)
@@ -0,0 +1,24 @@
package com.approagency.drug.data.repository
import com.approagency.drug.data.local.dao.TestGroupDao
import com.approagency.drug.data.local.dao.TestItemDao
import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.drug.data.local.entities.TestItemEntity
import kotlinx.coroutines.flow.Flow
class LabRepositoryImpl (
private val testGroupDao: TestGroupDao,
private val testItemDao: TestItemDao
) {
// Test Group Operations
fun getAllGroups(): Flow<List<TestGroupEntity>> = testGroupDao.getAllGroups()
fun getParentGroups(): Flow<List<TestGroupEntity>> = testGroupDao.getParentGroups()
fun getChildGroups(): Flow<List<TestGroupEntity>> = testGroupDao.getChildGroups()
suspend fun getGroupById(groupId: Int): TestGroupEntity? = testGroupDao.getGroupById(groupId)
// Test Item Operations
fun getAllItems(): Flow<List<TestItemEntity>> = testItemDao.getAllItems()
fun getItemsByGroupId(groupId: Int): Flow<List<TestItemEntity>> = testItemDao.getItemsByGroupId(groupId)
suspend fun getItemById(itemId: Int): TestItemEntity? = testItemDao.getItemById(itemId)
fun searchTestItems(query: String): Flow<List<TestItemEntity>> = testItemDao.searchTestItems(query)
}
@@ -2,20 +2,32 @@ package com.approagency.drug.di
import android.app.Application
import com.approagency.drug.data.local.database.LabDatabase
import com.approagency.drug.data.remote.DrugApiService
import com.approagency.drug.data.repository.DrugRepositoryImpl
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.repository.DrugRepository
import com.approagency.drug.domain.usecase.GetDarmanUseCase
import com.approagency.drug.domain.usecase.GetDrugDetailUseCase
import com.approagency.drug.domain.usecase.GetDrugSearchUseCase
import com.approagency.drug.domain.usecase.GetTestGroupUseCase
import com.approagency.drug.domain.usecase.GetTestItemByGroupId
import com.approagency.drug.presentation.viewModel.HomeViewModel
import com.approagency.drug.presentation.viewModel.LabViewModel
import com.approagency.drug.utils.Config
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
val appModule= module {
single { LabDatabase.getInstance(androidContext()) }
single { get<LabDatabase>().testGroupDao() }
single { get<LabDatabase>().testItemDao() }
single {
Retrofit.Builder()
.baseUrl(Config.BASE_URL)
@@ -47,8 +59,21 @@ val appModule= module {
GetDarmanUseCase(get())
}
single {
GetTestGroupUseCase(get())
}
single {
GetTestItemByGroupId(get())
}
single { LabRepositoryImpl(get(), get()) }
//view model
viewModel {
HomeViewModel(get() , get() , get())
}
viewModel {
LabViewModel(get() , get())
}
}
@@ -0,0 +1,11 @@
package com.approagency.drug.domain.model
data class TestGroup(
val id: Int,
val ename: String?,
val fname: String?,
val detail: String?,
val isParent: Boolean
)
@@ -0,0 +1,9 @@
package com.approagency.drug.domain.model
data class TestItem(
val id: Int,
val groupId: Int,
val title: String?,
val normalValue: String?,
val detail: String?
)
@@ -0,0 +1,24 @@
package com.approagency.drug.domain.usecase
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.model.TestGroup
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetTestGroupUseCase (
private val repository: LabRepositoryImpl
) {
operator fun invoke(): Flow<List<TestGroup>> {
return repository.getAllGroups().map { entities ->
entities.map { entity ->
TestGroup(
id = entity.id,
ename = entity.ename,
fname = entity.fname,
detail = entity.detail,
isParent = entity.isParent == "1"
)
}
}
}
}
@@ -0,0 +1,26 @@
package com.approagency.drug.domain.usecase
import com.approagency.drug.data.local.entities.TestItemEntity
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.model.TestItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetTestItemByGroupId (
private val repository: LabRepositoryImpl
) {
operator fun invoke(id:Int): Flow<List<TestItem>> {
return repository.getItemsByGroupId(groupId = id).map {
entities -> entities.map {
entity ->
TestItem(
id = entity.id,
groupId = entity.groupId,
title = entity.title,
normalValue = entity.normalValue,
detail = entity.detail,
)
}
}
}
}
@@ -1,12 +1,185 @@
package com.approagency.drug.presentation.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.approagency.drug.presentation.viewModel.HomeViewModel
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.presentation.common.CustomBox
import com.approagency.drug.presentation.common.CustomModalDialog
import com.approagency.drug.presentation.common.Loading
import com.approagency.drug.presentation.common.PrimaryButton
import com.approagency.drug.presentation.viewModel.LabViewModel
import com.vada.caller.ui.theme.dime
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.count
import org.koin.androidx.compose.koinViewModel
@Composable
fun LabScreen(
modifier: Modifier,
viewModel: HomeViewModel = koinViewModel()
){}
modifier: Modifier = Modifier,
viewModel: LabViewModel = koinViewModel()
) {
val testGroupsState by viewModel.testGroups.collectAsState()
val isLoading = testGroupsState.isLoading
val error = testGroupsState.error
val testGroups = testGroupsState.testGroup
Column(modifier = modifier.fillMaxSize()) {
if (isLoading) {
CustomModalDialog(
onDismissRequest = { /* maybe disable dismiss while loading */ },
content = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Loading(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(MaterialTheme.dime.sm))
Text("در حال جستجو..." , textAlign = TextAlign.Right)
}
},
// modifier = Modifier
// .matchParentSize()
// .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f))
)
}
else if(error != null){
CustomBox(
child = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(MaterialTheme.dime.md)
) {
Text(
text = "خطا در دریافت اطلاعات",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(vertical = 8.dp)
)
PrimaryButton(
text = "تلاش مجدد",
height = 40,
isLoading = false,
onClick = { viewModel.loadTestGroups() }
)
}
}
)
}
else if (testGroups != null){
TestGroupsList(
testGroupsFlow = testGroups,
viewModel = viewModel
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("هیچ داده‌ای موجود نیست")
}
}
}
}
@Composable
fun TestGroupsList(
testGroupsFlow: Flow<List<TestGroup>>,
viewModel: LabViewModel
) {
val testGroups by testGroupsFlow.collectAsState(initial = emptyList())
if (testGroups.isEmpty()) {
CustomModalDialog(
onDismissRequest = { /* maybe disable dismiss while loading */ },
content = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Loading(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(MaterialTheme.dime.sm))
Text("در حال جستجو..." , textAlign = TextAlign.Right)
}
},
// modifier = Modifier
// .matchParentSize()
// .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f))
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(testGroups) { group ->
TestGroupItem(
group = group,
onClick = {
// Navigate to test items of this group
// viewModel.loadTestItemsByGroup(group.id)
}
)
}
}
}
}
@Composable
fun TestGroupItem(
group: TestGroup,
onClick: () -> Unit
) {
CustomBox (
modifier = Modifier
.padding(bottom = MaterialTheme.dime.sm)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
},
) {
Column (
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Text(
text = group.fname ?: group.ename ?: "بدون نام",
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Right,
textDirection = TextDirection.Rtl
),
modifier = Modifier.padding(vertical = MaterialTheme.dime.xs)
)
group.detail?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall.copy(
textAlign = TextAlign.Right ,
textDirection = TextDirection.Rtl
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = MaterialTheme.dime.lg)
)
}
}
}
}
@@ -80,7 +80,7 @@ fun RootScreen(navHostController: NavHostController, modifier: Modifier){
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.height(110.dp).padding(bottom = 2.dp).clip(MaterialTheme.shapes.extraLarge), // 👈 کوچیک‌تر از حالت پیش‌فرض
.height(110.dp).padding(bottom = 2.dp).clip(MaterialTheme.shapes.extraLarge),
tonalElevation = 8.dp, // subtle shadow
windowInsets = WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom)
@@ -114,7 +114,7 @@ fun RootScreen(navHostController: NavHostController, modifier: Modifier){
)
},
alwaysShowLabel = true // 👈 فقط وقتی select شد label نشون بده
alwaysShowLabel = true
)
}
}
@@ -0,0 +1,94 @@
package com.approagency.drug.presentation.viewModel
import android.net.http.HttpException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.drug.data.dto.DrugListResponse
import com.approagency.drug.data.dto.DrugModels
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.domain.model.TestItem
import com.approagency.drug.domain.usecase.GetTestGroupUseCase
import com.approagency.drug.domain.usecase.GetTestItemByGroupId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class LabViewModel (
private val getTestGroupUseCase: GetTestGroupUseCase,
private val getTestItemByGroupId: GetTestItemByGroupId
) : ViewModel(){
private val _testGroups = MutableStateFlow(TestGroupItem())
val testGroups: StateFlow<TestGroupItem> = _testGroups.asStateFlow()
private val _testItems = MutableStateFlow(TestItemState())
val testItems: StateFlow<TestItemState> = _testItems.asStateFlow()
init {
loadTestGroups()
}
fun loadTestGroups(){
_testGroups.update {
it.copy(
isLoading = true
)
}
viewModelScope.launch {
try {
val result = getTestGroupUseCase.invoke();
_testGroups.update {
it.copy(
isLoading = false,
testGroup = result,
)
}
} catch (e:HttpException){
handleError(e.message)
}
}
}
fun getItemByGroupId(id:Int){
_testItems.update { it.copy(isLoading = true) }
viewModelScope.launch {
try {
val result = getTestItemByGroupId.invoke(id);
_testItems.update {
it.copy(
isLoading = false,
testItem = result
)
}
} catch (e:HttpException){
handleError(e.message)
}
}
}
private suspend fun handleError(message: String?) {
_testGroups.update {
it.copy(
isLoading = false,
testGroup = null,
error = message
)
}
}
}
data class TestGroupItem(
val isLoading: Boolean = false,
val testGroup: Flow<List<TestGroup>>? = null,
val error:String? = null
)
data class TestItemState(
val isLoading: Boolean = false,
val testItem: Flow<List<TestItem>>? = null,
val error:String? = null
)