feat: add test items and group tests

This commit is contained in:
2026-05-14 20:45:31 +03:30
parent 989200cd9b
commit 42938fb651
74 changed files with 3516 additions and 2700 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" /> <bytecodeTargetLevel target="17" />
</component> </component>
</project> </project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">
+3 -3
View File
@@ -31,11 +31,11 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlinOptions {
jvmTarget = "11" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -29,4 +29,18 @@ interface TestGroupDao {
@Query("DELETE FROM testGroup") @Query("DELETE FROM testGroup")
suspend fun deleteAllGroups() suspend fun deleteAllGroups()
@Query("""
SELECT * FROM testGroup
WHERE Fname LIKE '%' || :query || '%'
OR Ename LIKE '%' || :query || '%'
OR Detail LIKE '%' || :query || '%'
ORDER BY
CASE
WHEN Fname LIKE '%' || :query || '%' THEN 1
WHEN Ename LIKE '%' || :query || '%' THEN 2
ELSE 3
END
""")
fun searchGroups(query: String): Flow<List<TestGroupEntity>>
} }
@@ -20,8 +20,18 @@ interface TestItemDao {
@Query(""" @Query("""
SELECT ti.* FROM testItem ti SELECT ti.* FROM testItem ti
INNER JOIN testGroup tg ON ti.Group_Id = tg.Id LEFT JOIN testGroup tg ON ti.Group_Id = tg.Id
WHERE tg.Isparent = '0' AND ti.Title LIKE '%' || :searchQuery || '%' WHERE ti.Title LIKE '%' || :searchQuery || '%'
OR ti.Normal_Value LIKE '%' || :searchQuery || '%'
OR ti.Detail LIKE '%' || :searchQuery || '%'
OR tg.Fname LIKE '%' || :searchQuery || '%'
OR tg.Ename LIKE '%' || :searchQuery || '%'
ORDER BY
CASE
WHEN ti.Title LIKE '%' || :searchQuery || '%' THEN 1
WHEN tg.Fname LIKE '%' || :searchQuery || '%' THEN 2
ELSE 3
END
""") """)
fun searchTestItems(searchQuery: String): Flow<List<TestItemEntity>> fun searchTestItems(searchQuery: String): Flow<List<TestItemEntity>>
@@ -5,6 +5,7 @@ import com.approagency.drug.data.local.dao.TestItemDao
import com.approagency.drug.data.local.entities.TestGroupEntity import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.drug.data.local.entities.TestItemEntity import com.approagency.drug.data.local.entities.TestItemEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
class LabRepositoryImpl ( class LabRepositoryImpl (
private val testGroupDao: TestGroupDao, private val testGroupDao: TestGroupDao,
@@ -21,4 +22,14 @@ class LabRepositoryImpl (
fun getItemsByGroupId(groupId: Int): Flow<List<TestItemEntity>> = testItemDao.getItemsByGroupId(groupId) fun getItemsByGroupId(groupId: Int): Flow<List<TestItemEntity>> = testItemDao.getItemsByGroupId(groupId)
suspend fun getItemById(itemId: Int): TestItemEntity? = testItemDao.getItemById(itemId) suspend fun getItemById(itemId: Int): TestItemEntity? = testItemDao.getItemById(itemId)
fun searchTestItems(query: String): Flow<List<TestItemEntity>> = testItemDao.searchTestItems(query) fun searchTestItems(query: String): Flow<List<TestItemEntity>> = testItemDao.searchTestItems(query)
fun searchGroupsAndItems(searchQuery: String): Flow<Pair<List<TestGroupEntity>, List<TestItemEntity>>> {
return combine(
testGroupDao.searchGroups(searchQuery),
testItemDao.searchTestItems(searchQuery)
) { groups, items ->
Pair(groups, items)
}
}
} }
@@ -12,6 +12,7 @@ import com.approagency.drug.domain.usecase.GetDrugDetailUseCase
import com.approagency.drug.domain.usecase.GetDrugSearchUseCase import com.approagency.drug.domain.usecase.GetDrugSearchUseCase
import com.approagency.drug.domain.usecase.GetTestGroupUseCase import com.approagency.drug.domain.usecase.GetTestGroupUseCase
import com.approagency.drug.domain.usecase.GetTestItemByGroupId import com.approagency.drug.domain.usecase.GetTestItemByGroupId
import com.approagency.drug.domain.usecase.SearchTestsUseCase
import com.approagency.drug.presentation.viewModel.HomeViewModel import com.approagency.drug.presentation.viewModel.HomeViewModel
import com.approagency.drug.presentation.viewModel.LabViewModel import com.approagency.drug.presentation.viewModel.LabViewModel
import com.approagency.drug.utils.Config import com.approagency.drug.utils.Config
@@ -68,12 +69,14 @@ val appModule= module {
} }
single { LabRepositoryImpl(get(), get()) } single { LabRepositoryImpl(get(), get()) }
single { SearchTestsUseCase(get()) }
//view model //view model
viewModel { viewModel {
HomeViewModel(get() , get() , get()) HomeViewModel(get() , get() , get())
} }
viewModel { viewModel {
LabViewModel(get() , get()) LabViewModel(get() , get() , get())
} }
} }
@@ -1,11 +1,22 @@
package com.approagency.drug.domain.model package com.approagency.drug.domain.model
import com.approagency.drug.data.local.entities.TestGroupEntity
data class TestGroup( data class TestGroup(
val id: Int, val id: Int,
val ename: String?, val ename: String?,
val fname: String?, val fname: String?,
val detail: String?, val detail: String?,
val isParent: Boolean val isParent: String?,
) )
fun TestGroupEntity.toTestGroup(): TestGroup {
return TestGroup(
id = this.id,
ename = this.ename,
fname = this.fname,
detail = this.detail,
isParent = this.isParent
)
}
@@ -1,5 +1,7 @@
package com.approagency.drug.domain.model package com.approagency.drug.domain.model
import com.approagency.drug.data.local.entities.TestItemEntity
data class TestItem( data class TestItem(
val id: Int, val id: Int,
val groupId: Int, val groupId: Int,
@@ -7,3 +9,12 @@ data class TestItem(
val normalValue: String?, val normalValue: String?,
val detail: String? val detail: String?
) )
fun TestItemEntity.toTestItem(): TestItem {
return TestItem(
id = this.id,
groupId = this.groupId,
title = this.title,
normalValue = this.normalValue,
detail = this.detail
)
}
@@ -16,7 +16,7 @@ class GetTestGroupUseCase (
ename = entity.ename, ename = entity.ename,
fname = entity.fname, fname = entity.fname,
detail = entity.detail, detail = entity.detail,
isParent = entity.isParent == "1" isParent = entity.isParent
) )
} }
} }
@@ -0,0 +1,23 @@
package com.approagency.drug.domain.usecase
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.domain.model.TestItem
import com.approagency.drug.domain.model.toTestGroup
import com.approagency.drug.domain.model.toTestItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SearchTestsUseCase(
private val repository: LabRepositoryImpl
) {
operator fun invoke(query: String): Flow<Pair<List<TestGroup>, List<TestItem>>> {
return repository.searchGroupsAndItems(query).map { (groups, items) ->
Pair(
groups.map { it.toTestGroup() },
items.map { it.toTestItem() }
)
}
}
}
@@ -0,0 +1,183 @@
package com.approagency.drug.presentation.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.focus.onFocusChanged
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.vada.caller.ui.theme.dime
@Composable
fun CustomTextFilled(
value: String = "",
onValueChange: (String) -> Unit,
onSearch: (String) -> Unit = {},
placeholder: String = "جستجو...",
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
showClearButton: Boolean = true,
showSearchButton: Boolean = true,
autoSearch: Boolean = false,
searchDelay: Long = 500L,
keyboardOptions: KeyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search
),
keyboardActions: KeyboardActions = KeyboardActions(
onSearch = {
if (value.isNotBlank()) {
onSearch(value)
}
}
),
textStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyLarge,
hintStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
containerColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
borderColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primaryContainer,
shape: androidx.compose.ui.graphics.Shape = MaterialTheme.shapes.medium,
height: Int = 45,
onFocusChange: ((Boolean) -> Unit)? = null
) {
var isFocused by remember { mutableStateOf(false) }
var localValue by remember(value) { mutableStateOf(value) }
// Auto-search functionality
androidx.compose.runtime.LaunchedEffect(autoSearch, searchDelay, localValue) {
if (autoSearch && localValue.isNotBlank()) {
kotlinx.coroutines.delay(searchDelay)
onSearch(localValue)
}
}
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Box(
modifier = modifier
.fillMaxWidth()
.height(height.dp)
.background(
color = containerColor,
shape = shape
)
.border(
width = 1.dp,
color = if (isFocused)
MaterialTheme.colorScheme.primary
else
borderColor,
shape = shape
)
.padding(horizontal = MaterialTheme.dime.md, vertical = MaterialTheme.dime.sm)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
onFocusChange?.invoke(focusState.isFocused)
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Clear button (shows when there's text and clear button is enabled)
if (showClearButton && localValue.isNotEmpty()) {
IconButton(
onClick = {
localValue = ""
onValueChange("")
},
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Text field
BasicTextField(
value = localValue,
onValueChange = { newValue ->
localValue = newValue
onValueChange(newValue)
},
modifier = Modifier
.weight(1f)
.padding(horizontal = if (showClearButton && localValue.isNotEmpty()) 4.dp else 0.dp),
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
textStyle = textStyle,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
if (localValue.isEmpty() && !isFocused) {
Text(
text = placeholder,
style = hintStyle
)
}
innerTextField()
}
}
)
// Search button
if (showSearchButton) {
IconButton(
onClick = {
if (localValue.isNotBlank()) {
onSearch(localValue)
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(18.dp),
tint = if (localValue.isNotBlank())
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@@ -43,40 +43,15 @@ fun DarmanContent(
){ ){
val drugs = darmanData?.getOrNull()?.data ?: emptyList() val drugs = darmanData?.getOrNull()?.data ?: emptyList()
if (drugs.isEmpty()){ if (drugs.isEmpty()){
CustomBox( RetryContent(
child = { modifier = Modifier,
Column { error = "مشکلی از سمت سرور پیش آمده",
Row( onClick = {
horizontalArrangement = Arrangement.Center, onRetryClick()
modifier = Modifier
.fillMaxWidth()
.padding(all = MaterialTheme.dime.sm),
) {
Text(
text = "مشکل در دریافت اطلاعات",
fontWeight = FontWeight.W500
)
Spacer(modifier = Modifier.width(MaterialTheme.dime.xs))
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search"
)
}
PrimaryButton(
text = "تلاش دوباره",
height = 45,
isLoading = false,
onClick = {
onRetryClick()
}
)
}
} }
) )
}else { } else {
LazyColumn( LazyColumn {
){
items(drugs.count() , itemContent ={ items(drugs.count() , itemContent ={
i -> DarmanItem( i -> DarmanItem(
darman = darmanData?.getOrNull()!!.data[i] , darman = darmanData?.getOrNull()!!.data[i] ,
@@ -0,0 +1,42 @@
package com.approagency.drug.presentation.components
import androidx.compose.foundation.layout.Column
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.approagency.drug.presentation.common.CustomBox
import com.approagency.drug.presentation.common.PrimaryButton
import com.vada.caller.ui.theme.dime
@Composable
fun RetryContent(modifier: Modifier = Modifier , error:String ,onClick:()-> Unit ) {
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 = { onClick() }
)
}
}
)
}
@@ -0,0 +1,205 @@
package com.approagency.drug.presentation.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.domain.model.TestItem
import com.approagency.drug.presentation.common.CustomBox
import com.approagency.drug.presentation.common.Loading
import com.approagency.drug.presentation.viewModel.SearchResult
import com.vada.caller.ui.theme.dime
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun SearchResultsContent(
searchResult: SearchResult?,
isLoading: Boolean,
error: String?,
onGroupClick: (TestGroup) -> Unit,
onItemClick: (TestItem) -> Unit,
onClearSearch: () -> Unit
) {
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Loading(color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(MaterialTheme.dime.sm))
Text("در حال جستجو...")
}
}
}
error != null -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "خطا در جستجو",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
searchResult != null -> {
val groups = searchResult.groups
val items = searchResult.items
if (groups.isEmpty() && items.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "نتیجه‌ای یافت نشد",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.sm))
Text(
text = "لطفاً عبارت دیگری را جستجو کنید",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = MaterialTheme.dime.md),
horizontalAlignment = AbsoluteAlignment.Right
) {
if (groups.isNotEmpty()) {
item {
Text(
text = "گروه‌های آزمایشگاهی (${groups.size})",
style = MaterialTheme.typography.titleMedium.copy(
textAlign = TextAlign.Center
),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = MaterialTheme.dime.md)
)
}
items(groups) { group ->
TestGroupItemContent(
group = group,
onClick = { onGroupClick(group) }
)
}
if (items.isNotEmpty()) {
item {
HorizontalDivider(
modifier = Modifier.padding(vertical = MaterialTheme.dime.md)
)
Text(
text = "آیتم‌های آزمایشگاهی (${items.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = MaterialTheme.dime.md)
)
}
}
}
items(items) { item ->
TestItemSearchResult(
testItem = item,
onClick = { onItemClick(item) }
)
}
}
}
}
}
}
@Composable
fun TestItemSearchResult(
testItem: TestItem,
onClick: () -> Unit
) {
CustomBox(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = MaterialTheme.dime.sm)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
}
) {
Column(
modifier = Modifier.padding(all = MaterialTheme.dime.lg),
horizontalAlignment = AbsoluteAlignment.Right
) {
Text(
text = testItem.title ?: "بدون عنوان",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
if (!testItem.normalValue.isNullOrBlank()) {
Text(
text = "مقدار نرمال: ${testItem.normalValue}",
style = MaterialTheme.typography.bodySmall.copy(
textDirection = TextDirection.Rtl,
textAlign = TextAlign.Right
),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = MaterialTheme.dime.xs)
)
}
if (!testItem.detail.isNullOrBlank()) {
Text(
text = testItem.detail,
style = MaterialTheme.typography.bodySmall.copy(
textDirection = TextDirection.Rtl,
textAlign = TextAlign.Right
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = MaterialTheme.dime.sm)
)
}
}
}
}
@@ -0,0 +1,122 @@
package com.approagency.drug.presentation.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.presentation.common.CustomModalBottomSheet
import com.approagency.drug.presentation.viewModel.LabViewModel
import com.approagency.drug.presentation.viewModel.TestItemState
import com.vada.caller.ui.theme.dime
import kotlinx.coroutines.flow.collect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TestDetailSheet(state: TestItemState, testGroup: TestGroup, onDismiss: () -> Unit) {
if (state.testItem != null) {
val testItems by state.testItem.collectAsState(initial = emptyList())
CustomModalBottomSheet(
onDismiss = onDismiss,
content = {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MaterialTheme.dime.lg),
contentPadding = PaddingValues(bottom = MaterialTheme.dime.lg)
) {
item {
Text(
text = testGroup.fname ?: "جزئیات آزمایش",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = MaterialTheme.dime.md)
)
Text(
text = testGroup.ename ?: "N/A (English Name)",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = MaterialTheme.dime.sm)
)
if (testGroup.detail != null){
Text(testGroup.detail , style = MaterialTheme.typography.bodySmall,modifier = Modifier.padding(bottom = MaterialTheme.dime.md) )
}
HorizontalDivider(Modifier.padding(bottom = MaterialTheme.dime.md))
}
if (testItems.isEmpty()) {
item {
}
} else {
items(testItems) { testItem ->
TestItemDetailCard(testItem = testItem)
}
}
}
}
}
)
}
}
@Composable
fun TestItemDetailCard(testItem: com.approagency.drug.domain.model.TestItem) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.dime.md)
) {
Text(
text = testItem.title ?: "بدون عنوان",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
if (!testItem.normalValue.isNullOrBlank()) {
Text(
text = "مقدار نرمال: ${testItem.normalValue}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = MaterialTheme.dime.xs)
)
}
if (!testItem.detail.isNullOrBlank()) {
Text(
text = testItem.detail,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = MaterialTheme.dime.sm)
)
}
HorizontalDivider(
modifier = Modifier.padding(top = MaterialTheme.dime.md),
thickness = 0.5.dp
)
}
}
@@ -0,0 +1,90 @@
package com.approagency.drug.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowLeft
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.presentation.common.CustomBox
import com.vada.caller.ui.theme.dime
@Composable
fun TestGroupItemContent(
group: TestGroup,
onClick: () -> Unit
) {
CustomBox(
modifier = Modifier
.padding(bottom = MaterialTheme.dime.sm)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick()
},
) {
Row(
modifier = Modifier.padding(all = MaterialTheme.dime.lg),
) {
Column (
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = AbsoluteAlignment.Right
// horizontalArrangement = Arrangement.SpaceBetween
) {
Text(group.fname ?: "" , style = MaterialTheme.typography.labelLarge , textAlign = TextAlign.Right )
Text(group.ename ?: "" , style = MaterialTheme.typography.bodySmall , textAlign = TextAlign.Left)
Spacer(modifier = Modifier.padding(vertical = MaterialTheme.dime.xs))
Text(group.detail ?: "" , style = MaterialTheme.typography.bodySmall.copy(
textDirection = TextDirection.Rtl
) , textAlign = TextAlign.Right , maxLines = 2 , softWrap = true , overflow = TextOverflow.Ellipsis)
}
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
onClick()
}
) {
Row(
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.KeyboardArrowLeft
, contentDescription = "see more"
)
Text("مشاهده جزییات")
}
}
}
}
}
}
}
@@ -69,6 +69,7 @@ import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.presentation.common.CustomBox import com.approagency.drug.presentation.common.CustomBox
import com.approagency.drug.presentation.common.CustomModalBottomSheet import com.approagency.drug.presentation.common.CustomModalBottomSheet
import com.approagency.drug.presentation.common.CustomModalDialog import com.approagency.drug.presentation.common.CustomModalDialog
import com.approagency.drug.presentation.common.CustomTextFilled
import com.approagency.drug.presentation.common.Loading import com.approagency.drug.presentation.common.Loading
import com.approagency.drug.presentation.common.PrimaryButton import com.approagency.drug.presentation.common.PrimaryButton
import com.approagency.drug.presentation.components.DarmanContent import com.approagency.drug.presentation.components.DarmanContent
@@ -129,7 +130,7 @@ fun HomeContent(
onDrugClick: (Int) -> Unit, onDrugClick: (Int) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var search by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize().padding(horizontal = MaterialTheme.dime.md) .fillMaxSize().padding(horizontal = MaterialTheme.dime.md)
@@ -139,70 +140,28 @@ fun HomeContent(
Column ( Column (
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
){ ){
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { CustomTextFilled(
Box( value = searchText,
modifier = Modifier onValueChange = { searchText = it },
.fillMaxWidth() onSearch = { query ->
.height(45.dp) // Your desired height if (query.isNotBlank()) {
.background( onSearch(query)
color = MaterialTheme.colorScheme.surfaceVariant,
shape =MaterialTheme.shapes.medium
)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.primaryContainer,
shape =MaterialTheme.shapes.medium
)
.padding(horizontal = MaterialTheme.dime.md, vertical = MaterialTheme.dime.sm)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize()
) {
BasicTextField(
value = search,
onValueChange = { search = it },
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
decorationBox = { innerTextField ->
Box {
if (search.isEmpty()) {
Text(
"جستجوی دارو",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
}
)
IconButton(
onClick = {
if (search.isNotBlank()) {
onSearch(search)
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(18.dp)
)
}
} }
} },
} placeholder = "جستجوی دارو",
showClearButton = true,
showSearchButton = true,
autoSearch = false, // Set to true if you want search while typing
height = 45
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.xs)) Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
PrimaryButton( PrimaryButton(
text = "جستجو", text = "جستجو",
height = 40, height = 40,
isLoading = state.drugSearchState.isLoading, isLoading = state.drugSearchState.isLoading,
onClick = { onClick = {
if (search.isNotBlank()) { if (searchText.isNotBlank()) {
onSearch(search) onSearch(searchText)
} }
} }
) )
@@ -1,33 +1,50 @@
package com.approagency.drug.presentation.screens package com.approagency.drug.presentation.screens
import android.R.attr.onClick
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowLeft
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.AbsoluteAlignment
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.approagency.drug.domain.model.TestGroup import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.domain.model.TestItem
import com.approagency.drug.presentation.common.CustomBox import com.approagency.drug.presentation.common.CustomBox
import com.approagency.drug.presentation.common.CustomModalDialog import com.approagency.drug.presentation.common.CustomModalDialog
import com.approagency.drug.presentation.common.CustomTextFilled
import com.approagency.drug.presentation.common.Loading import com.approagency.drug.presentation.common.Loading
import com.approagency.drug.presentation.common.PrimaryButton import com.approagency.drug.presentation.common.PrimaryButton
import com.approagency.drug.presentation.components.RetryContent
import com.approagency.drug.presentation.components.SearchResultsContent
import com.approagency.drug.presentation.components.TestDetailSheet
import com.approagency.drug.presentation.components.TestGroupItemContent
import com.approagency.drug.presentation.viewModel.LabViewModel import com.approagency.drug.presentation.viewModel.LabViewModel
import com.vada.caller.ui.theme.dime import com.vada.caller.ui.theme.dime
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -40,59 +57,85 @@ fun LabScreen(
viewModel: LabViewModel = koinViewModel() viewModel: LabViewModel = koinViewModel()
) { ) {
val testGroupsState by viewModel.testGroups.collectAsState() val testGroupsState by viewModel.testGroups.collectAsState()
val isLoading = testGroupsState.isLoading val testItemsState by viewModel.testItems.collectAsState()
val error = testGroupsState.error val searchResultsState by viewModel.searchResults.collectAsState()
val isLoading = testGroupsState.isLoading || testItemsState.isLoading || searchResultsState.isLoading
val error = testGroupsState.error ?: searchResultsState.error
val testGroups = testGroupsState.testGroup val testGroups = testGroupsState.testGroup
Column(modifier = modifier.fillMaxSize()) { var selectedGroup by remember { mutableStateOf<TestGroup?>(null) }
if (isLoading) { var selectedItem by remember { mutableStateOf<TestItem?>(null) }
CustomModalDialog( var showSheet by remember { mutableStateOf(false) }
onDismissRequest = { /* maybe disable dismiss while loading */ }, var searchText by remember { mutableStateOf("") }
content = { Column(modifier = modifier.fillMaxSize().padding(horizontal = MaterialTheme.dime.md)
Column(horizontalAlignment = Alignment.CenterHorizontally) { .padding(top = MaterialTheme.dime.md),) {
Loading(color = MaterialTheme.colorScheme.primary) CustomTextFilled(
Spacer(modifier = Modifier.height(MaterialTheme.dime.sm)) value = searchText,
Text("در حال جستجو..." , textAlign = TextAlign.Right) onValueChange = {
} searchText = it
viewModel.updateSearchQuery(it)
},
onSearch = { query ->
if (query.isNotBlank()) {
viewModel.updateSearchQuery(query)
}
},
placeholder = "جستجو در گروه‌ها و آیتم‌های آزمایشگاهی",
showClearButton = true,
showSearchButton = true,
autoSearch = true,
height = 45
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
PrimaryButton(
text = "جستجو",
height = 40,
isLoading =false,
onClick = {}
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
if (searchResultsState.query.isNotBlank()) {
// Search Results Section
SearchResultsContent(
searchResult = searchResultsState.searchResults,
isLoading = searchResultsState.isLoading,
error = searchResultsState.error,
onGroupClick = { group ->
selectedGroup = group
viewModel.getItemByGroupId(group.id)
showSheet = true
}, },
// modifier = Modifier onItemClick = { item ->
// .matchParentSize() selectedItem = item
// .background(MaterialTheme.colorScheme.background.copy(alpha = 0.5f)) // You can show item detail sheet here
) // For now, we'll show the parent group
} viewModel.getItemByGroupId(item.groupId)
else if(error != null){ showSheet = true
CustomBox( },
child = { onClearSearch = {
Column( searchText = ""
horizontalAlignment = Alignment.CenterHorizontally, viewModel.clearSearch()
modifier = Modifier.padding(MaterialTheme.dime.md) viewModel.loadTestGroups()
) {
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(error != null){
RetryContent(modifier = modifier , error = error , onClick = {
viewModel.loadTestGroups()
})
} }
else if (testGroups != null){ else if (testGroups != null){
TestGroupsList( TestGroupsList(
testGroupsFlow = testGroups, testGroupsFlow = testGroups,
viewModel = viewModel viewModel = viewModel,
onGroupClick = { group ->
selectedGroup = group
viewModel.getItemByGroupId(group.id)
showSheet = true
}
) )
} else { }
else {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -102,12 +145,35 @@ fun LabScreen(
} }
} }
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))
)
}
if (showSheet && selectedGroup != null) {
TestDetailSheet(
state = testItemsState,
testGroup = selectedGroup!!,
onDismiss = { showSheet = false }
)
}
} }
@Composable @Composable
fun TestGroupsList( fun TestGroupsList(
testGroupsFlow: Flow<List<TestGroup>>, testGroupsFlow: Flow<List<TestGroup>>,
viewModel: LabViewModel viewModel: LabViewModel,
onGroupClick: (TestGroup) -> Unit
) { ) {
val testGroups by testGroupsFlow.collectAsState(initial = emptyList()) val testGroups by testGroupsFlow.collectAsState(initial = emptyList())
@@ -128,56 +194,12 @@ fun TestGroupsList(
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
.fillMaxSize().padding( vertical =MaterialTheme.dime.md )
) { ) {
items(testGroups) { group -> items(testGroups) { group ->
TestGroupItem( TestGroupItemContent(
group = group, group = group,
onClick = { onClick = { onGroupClick(group) }
// 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)
) )
} }
} }
@@ -69,14 +69,12 @@ fun RootScreen(navHostController: NavHostController, modifier: Modifier){
titleContentColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.primary,
), ),
title = { title = {
Text("جستجوی دارویی" , textAlign = TextAlign.Right , style = MaterialTheme.typography.titleLarge , fontWeight = FontWeight.W700 ) Text("دستیار سلامت" , textAlign = TextAlign.Right , style = MaterialTheme.typography.titleLarge , fontWeight = FontWeight.W700 )
} }
) )
}}, }},
// modifier = modifier.fillMaxSize(),
bottomBar = { bottomBar = {
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -12,6 +12,7 @@ import com.approagency.drug.domain.usecase.GetDarmanUseCase
import com.approagency.drug.domain.usecase.GetDrugDetailUseCase import com.approagency.drug.domain.usecase.GetDrugDetailUseCase
import com.approagency.drug.domain.usecase.GetDrugSearchUseCase import com.approagency.drug.domain.usecase.GetDrugSearchUseCase
import com.approagency.drug.presentation.screens.HomeScreen import com.approagency.drug.presentation.screens.HomeScreen
import com.approagency.drug.utils.retryWithBackoff
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -33,24 +34,45 @@ class HomeViewModel (
init { init {
getDarmani() getDarmani()
} }
fun getDarmani(){ fun getDarmani() {
_uiState.update { it.copy(darmanState = it.darmanState.copy( _uiState.update {
isLoading = true it.copy(
) , showDarmanList = true ) } darmanState = it.darmanState.copy(isLoading = true),
showDarmanList = true
)
}
viewModelScope.launch { viewModelScope.launch {
try { retryWithBackoff(
val result = getDarmanUseCase.invoke() maxRetries = 3,
_uiState.update { onRetry = { attempt, delay ->
it.copy( // Optional: log retry attempt
darmanState = it.darmanState.copy( println("Retrying getDarmani, attempt $attempt, delay $delay ms")
isLoading = false, }
getDarmani = result ) {
) getDarmanUseCase.invoke()
) }.fold(
} onSuccess = { result ->
} catch (e: HttpException){ _uiState.update {
handleError(e.message) it.copy(
} darmanState = it.darmanState.copy(
isLoading = false,
getDarmani = result
)
)
}
},
onFailure = { error ->
handleError(error.message ?: "Unknown error")
_uiState.update {
it.copy(
darmanState = it.darmanState.copy(
isLoading = false,
getDarmani = Result.failure(error)
)
)
}
}
)
} }
} }
@@ -9,16 +9,20 @@ import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.domain.model.TestItem import com.approagency.drug.domain.model.TestItem
import com.approagency.drug.domain.usecase.GetTestGroupUseCase import com.approagency.drug.domain.usecase.GetTestGroupUseCase
import com.approagency.drug.domain.usecase.GetTestItemByGroupId import com.approagency.drug.domain.usecase.GetTestItemByGroupId
import com.approagency.drug.domain.usecase.SearchTestsUseCase
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class LabViewModel ( class LabViewModel (
private val getTestGroupUseCase: GetTestGroupUseCase, private val getTestGroupUseCase: GetTestGroupUseCase,
private val getTestItemByGroupId: GetTestItemByGroupId private val getTestItemByGroupId: GetTestItemByGroupId,
private val searchTestsUseCase: SearchTestsUseCase
) : ViewModel(){ ) : ViewModel(){
private val _testGroups = MutableStateFlow(TestGroupItem()) private val _testGroups = MutableStateFlow(TestGroupItem())
val testGroups: StateFlow<TestGroupItem> = _testGroups.asStateFlow() val testGroups: StateFlow<TestGroupItem> = _testGroups.asStateFlow()
@@ -26,10 +30,71 @@ class LabViewModel (
private val _testItems = MutableStateFlow(TestItemState()) private val _testItems = MutableStateFlow(TestItemState())
val testItems: StateFlow<TestItemState> = _testItems.asStateFlow() val testItems: StateFlow<TestItemState> = _testItems.asStateFlow()
private val _searchResults = MutableStateFlow(SearchResultState())
val searchResults: StateFlow<SearchResultState> = _searchResults.asStateFlow()
private val searchQuery = MutableStateFlow("")
init { init {
loadTestGroups() loadTestGroups()
setupSearch()
}
private fun setupSearch() {
viewModelScope.launch {
searchQuery
.debounce(1000) // Wait 500ms after user stops typing
.distinctUntilChanged()
.collect { query ->
if (query.isNotBlank()) {
performSearch(query)
} else {
_searchResults.update {
SearchResultState(
isLoading = false,
searchResults = null,
query = ""
)
}
}
}
}
} }
fun updateSearchQuery(query: String) {
searchQuery.value = query
_searchResults.update {
it.copy(
isLoading = query.isNotBlank(),
query = query
)
}
}
private fun performSearch(query: String) {
viewModelScope.launch {
try {
val result = searchTestsUseCase.invoke(query)
result.collect { (groups, items) ->
_searchResults.update {
it.copy(
isLoading = false,
searchResults = SearchResult(groups = groups, items = items),
error = null
)
}
}
} catch (e: Exception) {
_searchResults.update {
it.copy(
isLoading = false,
searchResults = null,
error = e.message ?: "خطا در جستجو"
)
}
}
}
}
fun loadTestGroups(){ fun loadTestGroups(){
_testGroups.update { _testGroups.update {
it.copy( it.copy(
@@ -51,7 +116,12 @@ class LabViewModel (
} }
} }
fun clearSearch() {
searchQuery.value = ""
_searchResults.update {
SearchResultState()
}
}
fun getItemByGroupId(id:Int){ fun getItemByGroupId(id:Int){
_testItems.update { it.copy(isLoading = true) } _testItems.update { it.copy(isLoading = true) }
viewModelScope.launch { viewModelScope.launch {
@@ -91,4 +161,14 @@ data class TestItemState(
val testItem: Flow<List<TestItem>>? = null, val testItem: Flow<List<TestItem>>? = null,
val error:String? = null val error:String? = null
) )
data class SearchResultState(
val isLoading: Boolean = false,
val searchResults: SearchResult? = null,
val error: String? = null,
val query: String = ""
)
data class SearchResult(
val groups: List<TestGroup>,
val items: List<TestItem>
)
@@ -0,0 +1,29 @@
package com.approagency.drug.utils
import kotlinx.coroutines.delay
suspend fun <T> retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
maxDelayMs: Long = 5000,
factor: Double = 2.0,
onRetry: ((attempt: Int, delayMs: Long) -> Unit)? = null,
block: suspend () -> T
): Result<T> {
var currentDelay = initialDelayMs
repeat(maxRetries - 1) { attempt ->
try {
return Result.success(block())
} catch (e: Exception) {
onRetry?.invoke(attempt + 1, currentDelay)
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
}
}
// Last attempt
return try {
Result.success(block())
} catch (e: Exception) {
Result.failure(e)
}
}
+6
View File
@@ -22,3 +22,9 @@ kotlin.code.style=official
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.compiler.execution.strategy=in-process kotlin.compiler.execution.strategy=in-process
# Disable downloading Gradle sources
org.gradle.caching=false
org.gradle.daemon=true
# Prevent Gradle from downloading sources
org.gradle.internal.launcher.welcomeMessageDisplayed=false
-1
View File
@@ -1,4 +1,3 @@
#Thu Feb 26 19:18:21 IRST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://maven.myket.ir/gradle/distributions/gradle-8.13-bin.zip distributionUrl=https\://maven.myket.ir/gradle/distributions/gradle-8.13-bin.zip
+8 -8
View File
@@ -1,19 +1,19 @@
pluginManagement { pluginManagement {
repositories { repositories {
maven { url = uri("https://maven.myket.ir") } maven { url = uri("https://maven.myket.ir") }
google { // google {
content { // content {
includeGroupByRegex("com\\.android.*") // includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*") // includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*") // includeGroupByRegex("androidx.*")
} // }
} // }
// mavenCentral() // mavenCentral()
// gradlePluginPortal() // gradlePluginPortal()
} }
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories { repositories {
//google() //google()
//mavenCentral() //mavenCentral()