feat: change package name

This commit is contained in:
2026-06-05 18:02:48 +03:30
parent 92b7ab49c4
commit cbe77fbb9e
97 changed files with 3228 additions and 3395 deletions
@@ -0,0 +1,25 @@
package com.approagency.pharmacy
import android.app.Application
import com.approagency.pharmacy.data.local.database.LabDatabase
import com.approagency.pharmacy.di.appModule
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class DrugApp : Application(){
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@DrugApp)
modules(appModule)
printLogger()
}
// Preload database (optional, for faster access)
GlobalScope.launch {
LabDatabase.getInstance(this@DrugApp)
}
}
}
@@ -0,0 +1,33 @@
package com.approagency.pharmacy
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ExperimentalMaterial3Api
import com.approagency.pharmacy.navigation.AppNavGraph
import com.approagency.pharmacy.ui.theme.DrugTheme
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DrugTheme {
AppNavGraph(
// navController = navController,
// modifier = Modifier.padding(innerPadding)
)
// Scaffold(modifier = Modifier.fillMaxSize(),
// ) { innerPadding ->
// AppNavGraph(
//// navController = navController,
//// modifier = Modifier.padding(innerPadding)
// )
// }
}
}
}
}
@@ -0,0 +1,13 @@
package com.approagency.pharmacy.data.dto
data class DarmanModel(
val success : String,
val message:String,
val data: List<DarmanList>
)
data class DarmanList(
val cod : Int,
val nam_fa:String,
val nam_en:String,
)
@@ -0,0 +1,74 @@
package com.approagency.pharmacy.data.dto
data class DrugModels(
val success: Boolean,
val message: String?,
val data: DrugDetail?,
val meta: Meta?
)
data class DrugDetail(
val cod: Int?,
val goroh_darmani_detail_cod: Int?,
val goroh_daroei_cod: Int?,
val goroh_farmakologic_cod: Int?,
val goroh_darmani_cod: Int?,
val nam_fa: String?,
val nam_en: String?,
val mavaredmasraf: String?,
val meghdarmasraf: String?,
val masrafdarhamelegi: String?,
val masrafdarshirdehi: String?,
val manemasraf: String?,
val avarez: String?,
val tadakhol: String?,
val mekanismtasir: String?,
val nokte: String?,
val hoshdar: String?,
val sharayetnegahdari: String?,
val ashkal_daroei: String?,
val created_at: String?,
val updated_at: String?,
val goroh_daroei: GorohDaroei?,
val goroh_darmani: GorohDarmani?,
val goroh_darmani_detail: GorohDarmaniDetail?
)
data class GorohDaroei(
val cod: Int?,
val nam: String?,
val created_at: String?,
val updated_at: String?
)
data class GorohDarmani(
val cod: Int?,
val nam_fa: String?,
val nam_en: String?,
val giyahi_ya_shimiyaei: String?,
val image: String?,
val created_at: String?,
val updated_at: String?
)
data class GorohDarmaniDetail(
val cod: Int?,
val nam: String?,
val created_at: String?,
val updated_at: String?
)
data class Meta(
val current_page: Int?,
val last_page: Int?,
val per_page: Int?,
val total: Int?
)
// For list response compatibility
data class DrugListResponse(
val success: Boolean,
val message: String?,
val data: List<DrugDetail> = emptyList(),
val meta: Meta?
)
@@ -0,0 +1,46 @@
package com.approagency.pharmacy.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.approagency.pharmacy.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()
@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>>
}
@@ -0,0 +1,46 @@
package com.approagency.pharmacy.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.approagency.pharmacy.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
LEFT JOIN testGroup tg ON ti.Group_Id = tg.Id
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>>
@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.pharmacy.data.local.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.approagency.pharmacy.data.local.dao.TestGroupDao
import com.approagency.pharmacy.data.local.dao.TestItemDao
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
import com.approagency.pharmacy.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.pharmacy.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,36 @@
package com.approagency.pharmacy.data.local.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
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,60 @@
package com.approagency.pharmacy.data.remote
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Url
interface DarooyabApiService {
@FormUrlEncoded
@POST("Home/PartialNewSearch")
@Headers(
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
"X-Requested-With: XMLHttpRequest",
"Accept: */*",
"Accept-Language: en-US,en;q=0.9,fa;q=0.8"
)
suspend fun searchDrugs(
@Field("autocomplete") searchText: String,
@Field("DrugName_pageNumber") pageNumber: Int = 1
): String
@GET("{detailUrl}")
@Headers(
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9",
"Accept-Language: en-US,en;q=0.9,fa;q=0.8"
)
suspend fun getDrugDetail(
@Path(value = "detailUrl", encoded = true) detailUrl: String
): String
@GET
@Headers(
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9",
"Accept-Language: en-US,en;q=0.9,fa;q=0.8"
)
// @GET("{pharmacyPath}")
suspend fun getPharmacyDetail(
@Url url: String
): String
@POST("Home/partialPatientReferralList")
@FormUrlEncoded
@Headers(
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
"X-Requested-With: XMLHttpRequest",
"Accept: */*",
"Accept-Language: en-US,en;q=0.9,fa;q=0.8"
)
suspend fun getPharmacies(
@Field("brandIrc") brandIrc: String,
@Field("genericDrugId") genericDrugId: String,
@Field("provId") provId: String,
@Field("cityId") cityId: String
): String // returns HTML
}
@@ -0,0 +1,30 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface DrugApiService {
@GET("drugs/search")
suspend fun getDrugs(
@Query("q")query: String?,
@Query("per_page")perPage:Int? = 20,
@Query("with_relations")withRelations: Boolean? =true,
@Query("goroh_daroei_cod")drugGroup:Int?,
@Query("goroh_darmani_cod")healGroup:Int?,
): DrugListResponse
@GET("drugs/{cod}")
suspend fun getDrugDetail(
@Path("cod") cod: Int
): DrugModels
@GET("drugs/goroh-darmani")
suspend fun getGorohDaroei(): DarmanModel
}
@@ -0,0 +1,628 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.domain.model.*
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
class DrugDetailParser {
fun parseDrugDetail(html: String): DrugDetail {
val document = Jsoup.parse(html)
// تشخیص نوع صفحه: Generic (G) یا Brand (B)
val isGenericPage = detectPageType(document)
return if (isGenericPage) {
parseGenericPage(document)
} else {
parseBrandPage(document)
}
}
/**
* تشخیص نوع صفحه با بررسی URL canonical و ساختار DOM
*/
private fun detectPageType(document: Document): Boolean {
// روش 1: بررسی URL canonical
val canonicalUrl = document.select("link[rel=canonical]").attr("href")
if (canonicalUrl.contains("/G-")) {
return true
}
if (canonicalUrl.contains("/B-")) {
return false
}
// روش 2: بررسی ساختار DOM (Fallback)
val hasGenericLayout = document.select("#UL_GenericTabInfo").isNotEmpty()
val hasBrandLayout = document.select("#BrandInfoContainer").isNotEmpty()
return hasGenericLayout || !hasBrandLayout // Default to generic if ambiguous
}
// ============================================================
// Generic Page Parsing
// ============================================================
private fun parseGenericPage(document: Document): DrugDetail {
val genericId = extractGenericId(document)
val persianName = extractPersianName(document)
val englishName = extractEnglishName(document)
val sections = extractSectionsFromGeneric(document)
return DrugDetail(
genericId = genericId,
persianName = persianName,
englishName = englishName,
drugClass = extractDrugClass(document),
therapeuticClass = extractTherapeuticClass(document),
usage = sections["usage"],
mechanism = sections["mechanism"],
pharmacokinetics = sections["pharmacokinetics"],
contraindications = sections["contraindications"],
sideEffects = sections["sideEffects"],
interactions = sections["interactions"],
warnings = sections["warnings"],
recommendations = sections["recommendations"],
pregnancyCategory = extractPregnancyCategory(document),
pregnancyDescription = extractPregnancyDescription(document),
dosageForms = extractDosageForms(document),
brandNames = extractBrandNames(document),
similarDrugs = extractSimilarDrugs(document),
categories = extractCategories(document),
comments = extractComments(document),
manufacturer = null,
isGeneric = true,
generalInfo = extractGeneralInfoGeneric(document),
specializedInfo = extractSpecializedInfoGeneric(document),
)
}
private fun extractGenericId(document: Document): String {
val url = document.select("link[rel=canonical]").attr("href")
val regex = "/G-(\\d+)/".toRegex()
return regex.find(url)?.groupValues?.get(1) ?: ""
}
private fun extractPersianName(document: Document): String {
val titleElement = document.select("h1.EnglishNumericFont").first()
val fullText = titleElement?.text() ?: ""
val regexFullText = fullText.replace(Regex("\\s*چیست و برای چه مواردی استفاده می شود؟\\s*"), "").trim()
println("regexFullText: $regexFullText")
return regexFullText
}
private fun extractEnglishName(document: Document): String {
return document.select("label.EnglishTopLabel").text().trim()
}
private fun extractDrugClass(document: Document): String? {
return document.select("#divExtraInfo > div:first-child a.ahref_Generic").first()?.text()?.trim()
}
private fun extractTherapeuticClass(document: Document): String? {
// The therapeutic class can be a chain of links
val classLinks = document.select("#divExtraInfo > div:last-child a.ahref_Generic")
return if (classLinks.isNotEmpty()) classLinks.joinToString(" > ") { it.text().trim() } else null
}
/**
* Generic pages store content in divs with IDs inside #EtelaatTakhasosiContent
* The titles are h2.h2_TabTitle, and content is everything until the next h2.
*/
private fun extractSectionsFromGeneric(document: Document): Map<String, String> {
val sections = mutableMapOf<String, String>()
val specializedContentDiv = document.getElementById("EtelaatTakhasosiContent") ?: return sections
// Mapping of section title keywords to our data class keys
val titleToKey = mapOf(
"موارد مصرف" to "usage",
"مکانیسم اثر" to "mechanism",
"فارماکوکینتیک" to "pharmacokinetics",
"منع مصرف" to "contraindications",
"عوارض جانبی" to "sideEffects",
"تداخلات دارویی" to "interactions",
"هشدار" to "warnings",
"توصیه های دارویی" to "recommendations"
)
val sectionHeaders = specializedContentDiv.select("h2.h2_TabTitle")
for (header in sectionHeaders) {
val headerText = header.text().trim()
val sectionKey = titleToKey.entries.find { headerText.contains(it.key) }?.value
if (sectionKey != null) {
var contentElement = header.nextElementSibling()
val contentBuilder = StringBuilder()
while (contentElement != null && !contentElement.select("h2.h2_TabTitle").hasText()) {
// Extract clean text from various elements
val text = cleanText(contentElement)
if (text.isNotBlank()) {
contentBuilder.append(text).append("\n\n")
}
contentElement = contentElement.nextElementSibling()
}
val content = contentBuilder.toString().trim()
if (content.isNotEmpty()) {
sections[sectionKey] = content
}
}
}
return sections
}
/**
* General info for generic pages is inside #EtelaatOmomiVaTakhasosi > #EtelaatOmomi
*/
private fun extractGeneralInfoGeneric(document: Document): String? {
val generalDiv = document.select("#EtelaatOmomiVaTakhasosi #EtelaatOmomi").first() ?: return null
// Clone the element to avoid modifying the original document
val clone = generalDiv.clone()
// Remove the accordion (table of contents) as it's not part of the main content
clone.select(".accordion").remove()
// Remove navigation boxes if any
clone.select("#nav_box_general").remove()
return cleanText(clone).trim().takeIf { it.isNotEmpty() }
}
/**
* Specialized info for generic pages is inside #EtelaatTakhasosiContent
*/
private fun extractSpecializedInfoGeneric(document: Document): String? {
val specializedDiv = document.getElementById("EtelaatTakhasosiContent") ?: return null
val clone = specializedDiv.clone()
// Remove the table of contents
clone.select(".accordion").remove()
// Remove the "دارو های هم گروه" section and sources if you don't want them in specializedInfo
clone.select("#Teammate").remove()
clone.select("#externalLinks").remove()
return cleanText(clone).trim().takeIf { it.isNotEmpty() }
}
// ============================================================
// Brand Page Parsing
// ============================================================
private fun parseBrandPage(document: Document): DrugDetail {
val brandId = extractBrandId(document)
val persianName = extractBrandPersianName(document)
val englishName = extractBrandEnglishName(document)
val manufacturer = extractManufacturer(document)
val genericInfo = extractGenericInfo(document)
// Extract all sections from .brandAttrPersDesc
val (introText, sections) = extractBrandSections(document)
return DrugDetail(
genericId = genericInfo?.genericId ?: "",
persianName = persianName,
englishName = englishName,
drugClass = genericInfo?.persianName, // Drug class is essentially the generic name
therapeuticClass = extractBrandTherapeuticClass(document),
usage = sections["usage"] ?: introText,
mechanism = sections["mechanism"],
pharmacokinetics = null,
contraindications = sections["contraindications"],
sideEffects = sections["sideEffects"],
interactions = sections["interactions"],
warnings = sections["warnings"],
recommendations = sections["recommendations"],
pregnancyCategory = null,
pregnancyDescription = null,
dosageForms = extractOtherBrandForms(document),
brandNames = listOf(), // This page itself is a brand
similarDrugs = emptyList(),
categories = null,
comments = extractComments(document),
manufacturer = manufacturer,
genericInfo = genericInfo,
otherBrandForms = extractOtherBrandForms(document),
isGeneric = false
)
}
private fun extractBrandId(document: Document): String {
val url = document.select("link[rel=canonical]").attr("href")
val regex = "/B-(\\d+)/".toRegex()
return regex.find(url)?.groupValues?.get(1) ?: ""
}
private fun extractBrandPersianName(document: Document): String {
return document.select("#h1PersianName").text().trim()
}
private fun extractBrandEnglishName(document: Document): String {
return document.select("#h2EnglishName").text().trim()
}
private fun extractManufacturer(document: Document): String? {
val manufacturerLink = document.select("#divProducer a.ahref_Generic").first()
return manufacturerLink?.text()?.trim()
}
private fun extractGenericInfo(document: Document): GenericInfo? {
val genericLink = document.select("#divAjzaContent a.ahref_Generic").first() ?: return null
val href = genericLink.attr("href")
val genericIdRegex = "/G-(\\d+)/".toRegex()
val genericId = genericIdRegex.find(href)?.groupValues?.get(1) ?: ""
return GenericInfo(
genericId = genericId,
persianName = genericLink.text().trim(),
detailUrl = "https://www.darooyab.ir$href"
)
}
private fun extractBrandTherapeuticClass(document: Document): String? {
val therapeuticContainer = document.select("#brand_desc > div:last-child").first()
val text = therapeuticContainer?.text() ?: ""
val regex = "طبقه بندی درمانی :\\s*(.+?)(?:\$|\\n)".toRegex()
return regex.find(text)?.groupValues?.get(1)?.trim()
}
/**
* Extract all content from .brandAttrPersDesc.
* Returns a Pair: first is the introductory text (before any h2), second is a map of section title to content.
*/
private fun extractBrandSections(document: Document): Pair<String?, Map<String, String>> {
val container = document.select(".brandAttrPersDesc").first() ?: return Pair(null, emptyMap())
val sections = mutableMapOf<String, String>()
val titleToKey = mapOf(
"موارد مصرف" to "usage",
"مکانیسم اثر" to "mechanism",
"منع مصرف" to "contraindications",
"عوارض جانبی" to "sideEffects",
"تداخلات دارویی" to "interactions",
"هشدار" to "warnings",
"توصیه های دارویی" to "recommendations"
)
var introText: String? = null
var currentSectionKey: String? = null
val contentBuilder = StringBuilder()
for (child in container.children()) {
if (child.tagName() == "h2" || child.tagName() == "h3") {
// If we were building a previous section, save it
if (currentSectionKey != null && contentBuilder.isNotEmpty()) {
sections[currentSectionKey] = contentBuilder.toString().trim()
contentBuilder.clear()
}
// Start a new section
val headerText = child.text().trim()
currentSectionKey = titleToKey.entries.find { headerText.contains(it.key) }?.value
} else {
val text = cleanText(child)
if (text.isNotBlank()) {
if (currentSectionKey == null) {
// This is introductory text before any h2
introText = introText?.let { "$it\n\n$text" } ?: text
} else {
contentBuilder.append(text).append("\n\n")
}
}
}
}
// Save the last section if any
if (currentSectionKey != null && contentBuilder.isNotEmpty()) {
sections[currentSectionKey] = contentBuilder.toString().trim()
}
return Pair(introText, sections)
}
private fun extractOtherBrandForms(document: Document): List<DosageForm> {
val forms = mutableListOf<DosageForm>()
val brandmateDiv = document.select(".brandmate").first() ?: return forms
val links = brandmateDiv.select("a.ahref_Brand")
for (link in links) {
val persianName = link.text().trim()
val detailUrl = "https://www.darooyab.ir${link.attr("href")}"
forms.add(
DosageForm(
code = "",
persianName = persianName,
englishName = "",
isHighRisk = false,
temperature = null,
isVital = false,
warningLabel = null,
detailUrl = detailUrl
)
)
}
return forms
}
// ============================================================
// Common Extractors (for both page types)
// ============================================================
private fun extractPregnancyCategory(document: Document): String? {
// On generic pages only
val categoryElement = document.select("#UseInPregnancy .EnglishNumericFont, #UseInPregnancy > div.EnglishNumericFont").first()
return categoryElement?.text()?.trim()
}
private fun extractPregnancyDescription(document: Document): String? {
// On generic pages only
val descElement = document.select("#UseInPregnancy p, #UseInPregnancy .alert").first()
val text = descElement?.text()?.trim()
return if (text.isNullOrBlank() || text.contains("ثبت نشده")) null else text
}
private fun extractDosageForms(document: Document): List<DosageForm> {
val forms = mutableListOf<DosageForm>()
val table = document.select("#TBL_AshkalDarooyi").first() ?: return forms
val rows = table.select("tbody tr").filter { !it.hasClass("showMoreRow") && it.id() != "showMoreRow" }
for (row in rows) {
try {
val cells = row.select("td")
if (cells.size >= 2) {
val persianNameElement = cells[1].select("h3").first()
val persianName = persianNameElement?.text()?.trim() ?: continue
forms.add(
DosageForm(
code = cells[0].text().trim(),
persianName = persianName,
englishName = cells[1].select("label.EnglishNumericFont").first()?.text()?.trim() ?: "",
isHighRisk = cells.getOrNull(2)?.hasText() == true,
temperature = cells.getOrNull(3)?.text()?.takeIf { it.isNotBlank() },
isVital = cells.getOrNull(4)?.hasText() == true,
warningLabel = cells.getOrNull(5)?.text()?.takeIf { it.isNotBlank() }
)
)
}
} catch (e: Exception) {
println("Error parsing dosage form: ${e.message}")
}
}
return forms
}
private fun extractBrandNames(document: Document): List<BrandName> {
val brands = mutableListOf<BrandName>()
val persianRows = document.select("#PersCommertialDrugs .tableCommertial tbody tr.tr_persian")
if (persianRows.isEmpty()) return brands
for (row in persianRows) {
try {
val linkElement = row.select("td:first-child a.ahref_Generic").first()
val persianName = linkElement?.text()?.trim() ?: continue
val detailUrl = "https://www.darooyab.ir${linkElement.attr("href")}"
val manufacturerElement = row.select("td:eq(1) a.ahref_Generic").first()
val manufacturer = manufacturerElement?.text()?.trim()
val importerElement = row.select("td:eq(2) a.ahref_Generic").first()
val importer = importerElement?.text()?.trim()
brands.add(
BrandName(
persianName = persianName,
englishName = "",
manufacturer = manufacturer,
importer = importer,
detailUrl = detailUrl
)
)
} catch (e: Exception) {
println("Error parsing brand name: ${e.message}")
}
}
return brands
}
private fun extractSimilarDrugs(document: Document): List<SimilarDrug> {
val drugs = mutableListOf<SimilarDrug>()
val table = document.select("table.tableGroups").first() ?: return drugs
val rows = table.select("tbody tr").filter {
!it.hasClass("hidden-row") && it.select("a#toggleButton").isEmpty()
}
for (row in rows) {
try {
for (cell in row.select("td")) {
val link = cell.select("a.ahref_Generic").first()
if (link != null && link.text().isNotBlank()) {
val href = link.attr("href")
val genericIdRegex = "/G-(\\d+)/".toRegex()
val genericId = genericIdRegex.find(href)?.groupValues?.get(1) ?: ""
drugs.add(
SimilarDrug(
persianName = link.text().trim(),
englishName = null,
genericId = genericId,
detailUrl = "https://www.darooyab.ir$href"
)
)
}
}
} catch (e: Exception) {
println("Error parsing similar drug: ${e.message}")
}
}
return drugs
}
private fun extractCategories(document: Document): DrugCategories? {
val martindaleLink = document.select("#divExtraInfo > div:first-child a.ahref_Generic").first()
val martindale = martindaleLink?.text()?.trim()
val martindaleUrl = martindaleLink?.attr("href")?.let { "https://www.darooyab.ir$it" }
val therapeuticLinks = document.select("#divExtraInfo > div:last-child a.ahref_Generic")
val therapeutic = therapeuticLinks.mapNotNull { it.text().trim().takeIf { text ->
text != "بدون طبقه بندی درمانی" && text.isNotBlank()
} }
val therapeuticUrls = therapeuticLinks.map { "https://www.darooyab.ir${it.attr("href")}" }
return if (martindale != null || therapeutic.isNotEmpty()) {
DrugCategories(
martindale = martindale,
martindaleUrl = martindaleUrl,
therapeutic = therapeutic.ifEmpty { null },
therapeuticUrls = therapeuticUrls.ifEmpty { null }
)
} else null
}
private fun extractComments(document: Document): List<Comment> {
val comments = mutableListOf<Comment>()
val commentElements = document.select("#CommentContent .comment")
for (element in commentElements) {
try {
val authorElement = element.select("span").first()
val author = authorElement?.text()?.replace("(", "")?.replace(")", "")?.trim() ?: "ناشناس"
val date = authorElement?.text()?.let {
val regex = "\\((\\d{4}/\\d{1,2}/\\d{1,2})\\)".toRegex()
regex.find(it)?.groupValues?.get(1) ?: ""
} ?: ""
val textElement = element.select("p.commentText").first()
val text = textElement?.text()?.trim() ?: ""
if (text.isBlank()) continue
val responseElement = element.select(".responseComment").first()
val response = responseElement?.let { parseCommentResponse(it) }
comments.add(
Comment(
author = author,
date = date,
text = text,
response = response
)
)
} catch (e: Exception) {
println("Error parsing comment: ${e.message}")
}
}
return comments
}
private fun parseCommentResponse(element: Element): CommentResponse {
val doctorLink = element.select("a").first()
val doctorName = doctorLink?.text()?.trim() ?: ""
val doctorUrl = doctorLink?.attr("href")?.let { "https://www.darooyab.ir$it" }
val doctorText = element.select("span").first()?.text()?.trim() ?: ""
val doctorTitle = doctorText.substringAfter(" - ").takeIf { it.isNotBlank() } ?: ""
val responseText = element.select("p.commentText").last()?.text()?.trim() ?: ""
return CommentResponse(
doctorName = doctorName,
doctorTitle = doctorTitle,
text = responseText,
doctorUrl = doctorUrl
)
}
/**
* Helper function to clean and normalize text from an HTML element.
* Converts block elements to newlines for better readability.
*/
private fun cleanText(element: Element): String {
val clone = element.clone()
val output = StringBuilder()
// Process all nodes in order (both elements and text nodes)
processNode(clone, output)
return output.toString()
.replace(Regex("\\n{3,}"), "\n\n") // Collapse 3+ newlines to 2
.trim()
}
/**
* Recursively process nodes to build formatted text
*/
private fun processNode(node: org.jsoup.nodes.Node, output: StringBuilder) {
when (node) {
is Element -> {
when (node.tagName()) {
"h2" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("\n\n📌 $text\n\n")
output.append("".repeat(minOf(30, text.length))).append("\n\n")
}
}
"h3" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("\n\n🔹 $text\n\n")
}
}
"h4" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("\n$text\n")
}
}
"p" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append(text).append("\n\n")
}
}
"ul", "ol" -> {
for (li in node.select("li")) {
val liText = li.text().trim()
if (liText.isNotEmpty()) {
output.append("$liText\n")
}
}
output.append("\n")
}
"li" -> {
// Handled by ul/ol processing, but if standalone:
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("$text\n")
}
}
"br" -> {
output.append("\n")
}
"strong", "b" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("**$text**")
}
}
"div", "section", "article" -> {
// Process children of container elements
for (child in node.childNodes()) {
processNode(child, output)
}
}
else -> {
// For other elements, just process their children
for (child in node.childNodes()) {
processNode(child, output)
}
}
}
}
is org.jsoup.nodes.TextNode -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append(text)
}
}
}
}
}
@@ -0,0 +1,102 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.model.DrugSearchResult
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
class DrugHtmlParser {
fun parseSearchResultsWithPagination(html: String): DaroYabSearchResult {
val document: Document = Jsoup.parse(html)
val drugs = parseDrugRows(document)
val paginationInfo = extractPaginationInfo(document)
return DaroYabSearchResult(
drugs = drugs,
currentPage = paginationInfo.currentPage,
totalPages = paginationInfo.totalPages,
hasNextPage = paginationInfo.currentPage < paginationInfo.totalPages,
hasPreviousPage = paginationInfo.currentPage > 1
)
}
private fun parseDrugRows(document: Document): List<DrugSearchResult> {
val results = mutableListOf<DrugSearchResult>()
val rows = document.select("#tbody_DrugList tr")
println("Found ${rows.size} rows in the response")
if (rows.isEmpty()) {
println("No rows found. HTML snippet: ${document.html().take(500)}")
return emptyList()
}
for (row in rows) {
try {
val nameCell = row.selectFirst("td:eq(0)")
val drugLink = nameCell?.selectFirst("a.ahref_Generic")
val persianName = drugLink?.text()?.trim() ?: continue
val detailPageRelativeUrl = drugLink?.attr("href") ?: continue
val detailPageUrl = "https://www.darooyab.ir$detailPageRelativeUrl"
val genericIdRegex = "/G-(\\d+)/?".toRegex()
val genericId = genericIdRegex.find(detailPageRelativeUrl)?.groupValues?.get(1) ?: ""
val brandIdRegex = "/B-(\\d+)/?".toRegex()
val brandId = brandIdRegex.find(detailPageRelativeUrl)?.groupValues?.get(1) ?: ""
val lastCell = row.select("td").last()
val englishLink = lastCell?.selectFirst("a.ahref_Generic")
val englishName = englishLink?.text()?.trim()
val drugResult = DrugSearchResult(
genericId = if (genericId.isNotEmpty()) genericId else brandId,
persianName = persianName,
englishName = englishName,
detailPageUrl = detailPageUrl
)
results.add(drugResult)
println("Parsed: $persianName -> $englishName")
} catch (e: Exception) {
println("Error parsing row: ${e.message}")
}
}
return results
}
private fun extractPaginationInfo(document: Document): PaginationInfo {
var currentPage = 1
var totalPages = 1
try {
// Get current page from hidden input
val currentPageInput = document.select("#CurrentPager_Number")
if (currentPageInput.isNotEmpty()) {
currentPage = currentPageInput.`val`()?.toIntOrNull() ?: 1
}
// Get total pages from pagination links
val pageLinks = document.select(".pagination li .PagerBtn_DrugName")
if (pageLinks.isNotEmpty()) {
val pageNumbers = pageLinks.mapNotNull { it.text().toIntOrNull() }
if (pageNumbers.isNotEmpty()) {
totalPages = pageNumbers.maxOrNull() ?: 1
}
}
println("Pagination: Current page=$currentPage, Total pages=$totalPages")
} catch (e: Exception) {
println("Error extracting pagination: ${e.message}")
}
return PaginationInfo(currentPage, totalPages)
}
private data class PaginationInfo(
val currentPage: Int,
val totalPages: Int
)
}
@@ -0,0 +1,166 @@
// data/remote/PharmacyHtmlParser.kt
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
import org.jsoup.Jsoup
object PharmacyHtmlParser {
// private const val BASE_URL = "https://www.darooyab.ir"
fun parse(html: String): List<PharmacyItem> {
val items = mutableListOf<PharmacyItem>()
val doc = Jsoup.parse(html)
val table = doc.select("table#TBL_patientReferral").first() ?: return emptyList()
// تشخیص ساختار بر اساس سربرگ جدول (یک بار برای کل جدول)
val hasGuaranteeColumn = hasGuaranteeColumnInHeader(table)
val rows = table.select("tbody tr")
for (row in rows) {
val cells = row.select("td")
if (cells.size < 3) continue
// ایندکس ستون‌ها بر اساس وجود ستون تضمین
val brandIndex = if (hasGuaranteeColumn) 1 else 0
val pharmacyIndex = if (hasGuaranteeColumn) 2 else 1
val locationIndex = if (hasGuaranteeColumn) 3 else 2
// ستون برند
val brandCell = cells[brandIndex]
val brandLink = brandCell.select("a").first()
val brandName = brandLink?.text()?.trim() ?: ""
val brandUrl = normalizeUrl(brandLink?.attr("href")?.trim() ?: "")
val brandId = extractIdFromUrl(brandUrl, "/B-(\\d+)/?")
// ستون داروخانه
val pharmacyCell = cells[pharmacyIndex]
val pharmacyLinks = pharmacyCell.select("a")
// پیدا کردن لینک داروخانه (لینکی که "اطلاعات تماس" نباشد)
val pharmacyLink = pharmacyLinks.firstOrNull {
!it.text().contains("اطلاعات تماس")
}
val pharmacyName = pharmacyLink?.text()?.trim() ?: ""
var pharmacyUrl = pharmacyLink?.attr("href")?.trim() ?: ""
// نرمال کردن URL داروخانه
pharmacyUrl = normalizeUrl(pharmacyUrl)
val pharmacyId = extractIdFromUrl(pharmacyUrl, "/ph-(\\d+)/?")
// ستون موقعیت
val locationCell = cells[locationIndex]
val locationText = locationCell.text().trim()
// استخراج استان و شهر
val province = extractProvince(locationText)
val city = extractCity(locationText)
if (brandName.isNotBlank() && pharmacyName.isNotBlank()) {
items.add(
PharmacyItem(
brandName = brandName,
brandUrl = brandUrl,
brandId = brandId,
pharmacyName = pharmacyName,
pharmacyUrl = pharmacyUrl,
pharmacyId = pharmacyId,
province = province,
city = city
)
)
}
}
return items
}
/**
* نرمال کردن URL: تبدیل مسیرهای نسبی به URL کامل
* مثال: ~/../../../../../ph-12012/... -> https://www.darooyab.ir/ph-12012/...
*/
private fun normalizeUrl(url: String): String {
if (url.isBlank()) return ""
// اگر absolute بود domain را حذف کن
if (url.startsWith("http://") || url.startsWith("https://")) {
return url
.replace("https://www.darooyab.ir", "")
.replace("http://www.darooyab.ir", "")
}
var cleanUrl = url
if (cleanUrl.startsWith("~/")) {
cleanUrl = cleanUrl.removePrefix("~/")
}
while (cleanUrl.startsWith("../")) {
cleanUrl = cleanUrl.removePrefix("../")
}
return if (cleanUrl.startsWith("/")) {
cleanUrl
} else {
"/$cleanUrl"
}
}
/**
* تشخیص وجود ستون تضمین موجودی با بررسی سربرگ جدول
*/
private fun hasGuaranteeColumnInHeader(table: org.jsoup.nodes.Element): Boolean {
val headers = table.select("thead th")
if (headers.isEmpty()) return false
// بررسی می‌کنیم که آیا اولین ستون حاوی متن "تضمین" است
val firstHeaderText = headers.firstOrNull()?.text()?.trim() ?: ""
return firstHeaderText.contains("تضمین") || firstHeaderText.contains("موجودی")
}
private fun extractProvince(locationText: String): String {
// الگو: "استان تهران" یا "استان اصفهان"
val provincePattern = "استان\\s*(\\S+)".toRegex()
val match = provincePattern.find(locationText)
return match?.groupValues?.get(1)?.trim() ?: ""
}
private fun extractCity(locationText: String): String {
// الگو: "شهر تهران" یا "شهر اصفهان" یا "شهر پردیس"
val cityPattern = "شهر\\s*(.+)".toRegex()
val match = cityPattern.find(locationText)
return match?.groupValues?.get(1)?.trim() ?: ""
}
private fun extractIdFromUrl(url: String, pattern: String): String {
val regex = pattern.toRegex()
return regex.find(url)?.groupValues?.get(1) ?: ""
}
fun parsePharmacyDetail(html: String): PharmacyDetail {
val doc = Jsoup.parse(html)
// استخراج نام داروخانه
val pharmacyTitle = doc.select("h2#pharmacyTitle").first()?.text()?.trim() ?: ""
// استخراج آدرس
val addressElement = doc.select("h2#h2Address").first()
val address = addressElement?.text()?.trim() ?: ""
// استخراج تلفن
val phoneElement = doc.select("h3#h3Phone a").first()
val phone = phoneElement?.text()?.trim() ?: ""
// استخراج دانشگاه (اختیاری)
val university = doc.select("div.alert.alert-success > span").first()?.text()?.trim()
return PharmacyDetail(
name = pharmacyTitle,
address = address,
phone = phone,
university = university
)
}
}
@@ -0,0 +1,20 @@
package com.approagency.pharmacy.data.remote
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type
class StringConverterFactory : Converter.Factory() {
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
return if (type == String::class.java) {
Converter<ResponseBody, String> { value -> value.string() }
} else {
null
}
}
}
@@ -0,0 +1,124 @@
package com.approagency.pharmacy.data.repository
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.data.remote.DarooyabApiService
import com.approagency.pharmacy.data.remote.DrugApiService
import com.approagency.pharmacy.data.remote.DrugDetailParser
import com.approagency.pharmacy.data.remote.DrugHtmlParser
import com.approagency.pharmacy.data.remote.PharmacyHtmlParser
import com.approagency.pharmacy.domain.model.DaroYabParams
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
import com.approagency.pharmacy.domain.repository.DrugRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
class DrugRepositoryImpl(
private val apiService: DrugApiService,
private val darooyabApiService: DarooyabApiService,
private val parser: DrugHtmlParser,
private val detailParser: DrugDetailParser,
): DrugRepository {
override suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse> {
return try {
val response = apiService.getDrugs(query = params.query , perPage = params.perPage , withRelations = params.withRelations , healGroup = params.healGroup , drugGroup = params.drugGroup )
return Result.success(response)
}catch (e: Exception){
Result.failure(e)
}
}
override suspend fun drugDetail(cod: Int): Result<DrugModels> {
return try {
val response= apiService.getDrugDetail(cod = cod)
println(response.message)
return Result.success(response)
}catch (e: Exception){
println(e.message)
Result.failure(e)
}
}
override suspend fun getGorohDaroei(): Result<DarmanModel> {
return try {
val response = apiService.getGorohDaroei()
println(response)
return Result.success(response)
}catch (e: Exception){
Result.failure(e)
}
}
override suspend fun searchDrugs(params: DaroYabParams): Result<DaroYabSearchResult> {
return withContext(Dispatchers.IO) {
try {
val htmlResponse = darooyabApiService.searchDrugs(
searchText = params.query ?: "",
pageNumber = params.pageNumber
)
println("HTML Response length: ${htmlResponse.length}")
val searchResult = parser.parseSearchResultsWithPagination(htmlResponse)
if (searchResult.drugs.isNotEmpty()) {
Result.success(searchResult)
} else {
Result.failure(Exception("No drugs found for query: '${params.query}'"))
}
} catch (e: IOException) {
Result.failure(Exception("Network error: ${e.message}", e))
} catch (e: Exception) {
Result.failure(Exception("An error occurred: ${e.message}", e))
}
}
}
override suspend fun getDrugDetailFromYab(detailUrl: String): Result<DrugDetail> {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getDrugDetail(detailUrl)
val detail = detailParser.parseDrugDetail(html)
Result.success(detail)
} catch (e: Exception) {
Result.failure(e)
}
}
}
override suspend fun searchPharmacies(
genericDrugId: String,
provId: String
): List<PharmacyItem> {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getPharmacies(
brandIrc = "0",
genericDrugId = genericDrugId,
provId = provId,
cityId = "0"
)
PharmacyHtmlParser.parse(html)
} catch (e: Exception) {
emptyList()
}
}
}
override suspend fun getPharmacyDetail(pharmacyUrl: String): PharmacyDetail {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getPharmacyDetail(pharmacyUrl)
PharmacyHtmlParser.parsePharmacyDetail(html)
} catch (e: Exception) {
PharmacyDetail("", "", "")
}
}
}
}
@@ -0,0 +1,35 @@
package com.approagency.pharmacy.data.repository
import com.approagency.pharmacy.data.local.dao.TestGroupDao
import com.approagency.pharmacy.data.local.dao.TestItemDao
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
import com.approagency.pharmacy.data.local.entities.TestItemEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
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)
fun searchGroupsAndItems(searchQuery: String): Flow<Pair<List<TestGroupEntity>, List<TestItemEntity>>> {
return combine(
testGroupDao.searchGroups(searchQuery),
testItemDao.searchTestItems(searchQuery)
) { groups, items ->
Pair(groups, items)
}
}
}
@@ -0,0 +1,162 @@
package com.approagency.pharmacy.di
import com.approagency.pharmacy.data.local.database.LabDatabase
import com.approagency.pharmacy.data.remote.DarooyabApiService
import com.approagency.pharmacy.data.remote.DrugApiService
import com.approagency.pharmacy.data.remote.DrugDetailParser
import com.approagency.pharmacy.data.remote.DrugHtmlParser
import com.approagency.pharmacy.data.repository.DrugRepositoryImpl
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.repository.DrugRepository
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
import com.approagency.pharmacy.domain.usecase.GetDarmanUseCase
import com.approagency.pharmacy.domain.usecase.GetDrugDetailUseCase
import com.approagency.pharmacy.domain.usecase.GetDrugSearchUseCase
import com.approagency.pharmacy.domain.usecase.GetPharmaciesUseCase
import com.approagency.pharmacy.domain.usecase.GetPharmacyDetailUseCase
import com.approagency.pharmacy.domain.usecase.GetTestGroupUseCase
import com.approagency.pharmacy.domain.usecase.GetTestItemByGroupId
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
import com.approagency.pharmacy.presentation.viewModel.LabViewModel
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
import com.approagency.pharmacy.utils.Config
import com.approgency.drug.presentation.viewModel.SearchViewModel
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.qualifier
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.net.CookieManager
import java.net.CookiePolicy
import java.util.concurrent.TimeUnit
val jsonRetrofitQualifier = qualifier("jsonRetrofit")
val scalarRetrofitQualifier = qualifier("scalarRetrofit")
val appModule= module {
single { LabDatabase.getInstance(androidContext()) }
single { get<LabDatabase>().testGroupDao() }
single { get<LabDatabase>().testItemDao() }
// Shared OkHttpClient for both APIs
single {
val cookieManager = CookieManager()
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL)
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // Change to BODY for debugging
})
// Add cookie support
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.build()
}
single<Retrofit>(jsonRetrofitQualifier) { // Add qualifier
Retrofit.Builder()
.baseUrl(Config.BASE_URL)
.client(get<OkHttpClient>())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single<DrugApiService> {
val retrofit: Retrofit = get(jsonRetrofitQualifier) // Get qualified instance
retrofit.create(DrugApiService::class.java)
}
// ========== Retrofit for DAROOYAB Website ==========
single<Retrofit>(scalarRetrofitQualifier) { // Add qualifier
Retrofit.Builder()
.baseUrl(Config.Darro_Url)
.client(get<OkHttpClient>())
.addConverterFactory(ScalarsConverterFactory.create())
.build()
}
single<DarooyabApiService> {
val retrofit: Retrofit = get(scalarRetrofitQualifier) // Get qualified instance
retrofit.create(DarooyabApiService::class.java)
}
factory { DrugHtmlParser() }
factory { DrugDetailParser() }
//repo
single<DrugRepository> {
DrugRepositoryImpl(get<DrugApiService>() , get<DarooyabApiService>() , get() , get())
}
//UseCase
single {
GetDrugSearchUseCase(get())
}
single {
SearchDrugsYabUseCase(get())
}
single{
GetDrugDetailUseCase(get())
}
single {
GetDarmanUseCase(get())
}
single {
GetTestGroupUseCase(get())
}
single {
GetPharmaciesUseCase(get())
}
single {
GetTestItemByGroupId(get())
}
single { LabRepositoryImpl(get(), get()) }
single {
DrugDetailYabUseCase(get())
}
single {
GetPharmacyDetailUseCase(get())
}
viewModel {
DrugDetailViewModel(get())
}
single { SearchTestsUseCase(get()) }
//view model
viewModel {
HomeViewModel(get() , get() , get())
}
viewModel {
SearchViewModel(get())
}
viewModel {
PharmacyViewModel(get() , get ())
}
viewModel {
LabViewModel(get() , get() , get())
}
}
@@ -0,0 +1,6 @@
package com.approagency.pharmacy.domain.model
data class DaroYabParams(
val query: String? = null,
val pageNumber: Int = 1,
)
@@ -0,0 +1,9 @@
package com.approagency.pharmacy.domain.model
data class DaroYabSearchResult(
val drugs: List<DrugSearchResult>,
val currentPage: Int,
val totalPages: Int,
val hasNextPage: Boolean,
val hasPreviousPage: Boolean
)
@@ -0,0 +1,81 @@
package com.approagency.pharmacy.domain.model
data class DrugDetail(
val genericId: String,
val persianName: String,
val englishName: String,
val drugClass: String?,
val therapeuticClass: String?,
val usage: String?,
val mechanism: String?,
val pharmacokinetics: String?,
val contraindications: String?,
val sideEffects: String?,
val interactions: String?,
val warnings: String?,
val recommendations: String?,
val pregnancyCategory: String?,
val pregnancyDescription: String?,
val dosageForms: List<DosageForm>,
val brandNames: List<BrandName>,
val similarDrugs: List<SimilarDrug>,
val categories: DrugCategories?,
val comments: List<Comment>,
val manufacturer: String? = null,
val genericInfo: GenericInfo? = null,
val otherBrandForms: List<DosageForm> = emptyList(),
val isGeneric: Boolean = true,
val generalInfo: String? = null,
val specializedInfo: String? = null,
)
data class GenericInfo(
val genericId: String,
val persianName: String,
val detailUrl: String
)
data class DosageForm(
val code: String,
val persianName: String,
val englishName: String,
val isHighRisk: Boolean,
val temperature: String?,
val isVital: Boolean,
val warningLabel: String?,
val detailUrl: String? = null
)
data class BrandName(
val persianName: String,
val englishName: String,
val manufacturer: String?,
val importer: String?,
val detailUrl: String
)
data class SimilarDrug(
val persianName: String,
val englishName: String?,
val genericId: String,
val detailUrl: String
)
data class DrugCategories(
val martindale: String?,
val martindaleUrl: String?,
val therapeutic: List<String>?,
val therapeuticUrls: List<String>?
)
data class Comment(
val author: String,
val date: String,
val text: String,
val response: CommentResponse?
)
data class CommentResponse(
val doctorName: String,
val doctorTitle: String,
val text: String,
val doctorUrl: String?
)
@@ -0,0 +1,10 @@
package com.approagency.pharmacy.domain.model
data class DrugSearchParams(
val query: String? = null,
val perPage: Int = 20,
val withRelations: Boolean = true,
val drugGroup: Int? = null,
val healGroup: Int? = null,
)
@@ -0,0 +1,8 @@
package com.approagency.pharmacy.domain.model
data class DrugSearchResult(
val genericId: String, // e.g., "2556" from "/G-2556/Casanthranol"
val persianName: String,
val englishName: String? = null, // "Casanthranol", might be null for some entries
val detailPageUrl: String // e.g., "/G-2556/Casanthranol" or "https://www.darooyab.ir/G-2556/Casanthranol"
)
@@ -0,0 +1,31 @@
package com.approagency.pharmacy.domain.model
data class PharmacySearchRequest(
val brandIrc: String = "0", // برای صفحات برند
val genericDrugId: String, // برای صفحات ژنریک
val provId: String, // ID استان (0 برای همه)
val cityId: String = "0" // ID شهر (0 برای همه)
)
data class PharmacyItem(
val brandName: String, // نام برند دارو
val brandUrl: String, // لینک صفحه برند
val brandId: String, // IRC برند
val pharmacyName: String, // نام داروخانه
val pharmacyUrl: String, // لینک داروخانه
val pharmacyId: String, // ID داروخانه
val province: String, // نام استان
val city: String , // نام شهر
val address: String? = null,
val phone: String? = null
)
// مدل برای اطلاعات کامل داروخانه
data class PharmacyDetail(
val name: String,
val address: String,
val phone: String,
val university: String? = null
)
@@ -0,0 +1,22 @@
package com.approagency.pharmacy.domain.model
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
data class TestGroup(
val id: Int,
val ename: String?,
val fname: String?,
val detail: String?,
val isParent: String?,
)
fun TestGroupEntity.toTestGroup(): TestGroup {
return TestGroup(
id = this.id,
ename = this.ename,
fname = this.fname,
detail = this.detail,
isParent = this.isParent
)
}
@@ -0,0 +1,20 @@
package com.approagency.pharmacy.domain.model
import com.approagency.pharmacy.data.local.entities.TestItemEntity
data class TestItem(
val id: Int,
val groupId: Int,
val title: String?,
val normalValue: 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
)
}
@@ -0,0 +1,23 @@
package com.approagency.pharmacy.domain.repository
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.domain.model.DaroYabParams
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
interface DrugRepository {
suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse>
suspend fun drugDetail(cod:Int): Result<DrugModels>
suspend fun getGorohDaroei(): Result<DarmanModel>
suspend fun searchDrugs(params:DaroYabParams):Result<DaroYabSearchResult>
suspend fun getDrugDetailFromYab(detailUrl: String):Result<DrugDetail>
suspend fun searchPharmacies(genericDrugId: String,provId: String): List<PharmacyItem>
suspend fun getPharmacyDetail(pharmacyUrl: String): PharmacyDetail
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.repository.DrugRepository
class DrugDetailYabUseCase (
private val repository: DrugRepository
) {
suspend operator fun invoke(detailUrl: String):Result<DrugDetail> {
return repository.getDrugDetailFromYab(detailUrl)
}
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetDarmanUseCase (
private val repository: DrugRepository
){
suspend operator fun invoke (): Result<DarmanModel>{
return repository.getGorohDaroei()
}
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetDrugDetailUseCase
(
private val repository: DrugRepository){
suspend operator fun invoke(cod:Int): Result<DrugModels>{
return repository.drugDetail(cod)
}
}
@@ -0,0 +1,13 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetDrugSearchUseCase (
private val repository: DrugRepository
){
suspend operator fun invoke(params: DrugSearchParams): Result<DrugListResponse> {
return repository.searchDrug(params)
}
}
@@ -0,0 +1,15 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.PharmacyItem
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetPharmaciesUseCase(
private val repository: DrugRepository
) {
suspend operator fun invoke(
genericDrugId: String,
provinceId: String
): List<PharmacyItem> {
return repository.searchPharmacies(genericDrugId, provinceId)
}
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetPharmacyDetailUseCase(
private val repository: DrugRepository
) {
suspend operator fun invoke(pharmacyUrl: String): PharmacyDetail {
return repository.getPharmacyDetail(pharmacyUrl)
}
}
@@ -0,0 +1,24 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.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
)
}
}
}
}
@@ -0,0 +1,25 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.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,
)
}
}
}
}
@@ -0,0 +1,14 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.DaroYabParams
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.repository.DrugRepository
class SearchDrugsYabUseCase (
private val repository: DrugRepository
) {
suspend operator fun invoke(query: String, pageNumber: Int = 1): Result<DaroYabSearchResult> {
val params = DaroYabParams(query = query, pageNumber = pageNumber)
return repository.searchDrugs(params)
}
}
@@ -0,0 +1,23 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.domain.model.TestItem
import com.approagency.pharmacy.domain.model.toTestGroup
import com.approagency.pharmacy.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,70 @@
package com.approagency.pharmacy.navigation
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.approagency.pharmacy.presentation.screens.DrugDetailScreen
import com.approagency.pharmacy.presentation.screens.HomeScreen
import com.approagency.pharmacy.presentation.screens.LabScreen
import com.approagency.pharmacy.presentation.screens.SearchScreen
@Composable
fun AppNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
// ───────────────
// BOTTOM TABS
// ───────────────
composable("home") {
MainContainer(navController) {
HomeScreen(navController , modifier = Modifier)
}
}
composable("search") {
MainContainer(navController) {
SearchScreen(navController,)
}
}
composable("lab") {
MainContainer(navController) {
LabScreen()
}
}
// ───────────────
// GLOBAL SCREEN
// ───────────────
composable(
route = Screen.DrugDetail.route,
arguments = listOf(
navArgument("detailUrl") {
type = NavType.StringType
}
)
) {
val detailUrl =
Uri.decode(it.arguments?.getString("detailUrl") ?: "")
DrugDetailScreen(
navController = navController,
detailUrl = detailUrl
)
}
}
}
@@ -0,0 +1,108 @@
package com.approagency.pharmacy.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocalPharmacy
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun BottomBar(navController: NavHostController) {
val items = listOf(
Triple("home", "عمومی", Icons.Filled.LocalPharmacy),
Triple("search", "جستجو",Icons.Filled.Search),
Triple("lab", "آزمایش", Icons.Filled.Science)
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Surface(
tonalElevation = 2.dp,
modifier = Modifier
.navigationBarsPadding()
.height(56.dp)
) {
Row(modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically) {
items.forEach { item ->
val selected = currentRoute == item.first
val color = if (selected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
val background = if (selected)
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else
Color.Transparent
Box(
modifier = Modifier
.weight(1f).padding(horizontal = 8.dp , vertical = 4.dp)
.clip(MaterialTheme.shapes.large)
.background(background)
.clickable {
if (!selected) {
navController.navigate(item.first) {
popUpTo("home") {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}.padding(horizontal = 10.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = item.third,
contentDescription = item.second,
tint = color,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = item.second,
color = color,
style = MaterialTheme.typography.labelMedium
)
}
}
}
}
}
}
@@ -0,0 +1,26 @@
package com.approagency.pharmacy.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
@Composable
fun MainContainer(
navController: NavHostController,
content: @Composable () -> Unit
) {
Scaffold(
bottomBar = {
BottomBar(navController)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
content()
}
}
}
@@ -0,0 +1,32 @@
package com.approagency.pharmacy.navigation
import android.net.Uri
sealed class MainRoute(
val route: String
) {
object HomeGraph : MainRoute("home_graph")
object SearchGraph : MainRoute("search_graph")
object LabGraph : MainRoute("lab_graph")
}
sealed class Screen(
val route: String
) {
object Home : Screen("home")
object Search : Screen("search")
object Lab : Screen("lab")
object DrugDetail : Screen("drug_detail/{detailUrl}") {
fun createRoute(detailUrl: String): String {
return "drug_detail/${Uri.encode(detailUrl)}"
}
}
}
@@ -0,0 +1,19 @@
//package com.approagency.drug.navigation.graphs
//
//import androidx.compose.ui.Modifier
//import androidx.navigation.NavGraphBuilder
//import androidx.navigation.NavHostController
//import androidx.navigation.compose.composable
//import androidx.navigation.navigation
//import com.approagency.drug.navigation.MainRoute
//import com.approagency.drug.navigation.Screen
//import com.approagency.drug.presentation.screens.HomeScreen
//
//fun NavGraphBuilder.homeGraph(navController: NavHostController) {
//
// composable(MainRoute.HomeGraph.route) {
// HomeScreen(
// navController = navController , modifier = Modifier
// )
// }
//}
@@ -0,0 +1,16 @@
//package com.approagency.drug.navigation.graphs
//
//import androidx.navigation.NavGraphBuilder
//import androidx.navigation.NavHostController
//import androidx.navigation.compose.composable
//import androidx.navigation.navigation
//import com.approagency.drug.navigation.MainRoute
//import com.approagency.drug.navigation.Screen
//import com.approagency.drug.presentation.screens.LabScreen
//
//fun NavGraphBuilder.labGraph(navController: NavHostController) {
//
// composable(MainRoute.LabGraph.route) {
// LabScreen()
// }
//}
@@ -0,0 +1,20 @@
//package com.approagency.drug.navigation.graphs
//
//import android.net.Uri
//import androidx.navigation.NavGraphBuilder
//import androidx.navigation.NavHostController
//import androidx.navigation.NavType
//import androidx.navigation.compose.composable
//import androidx.navigation.navArgument
//import androidx.navigation.navigation
//import com.approagency.drug.navigation.MainRoute
//import com.approagency.drug.navigation.Screen
//import com.approagency.drug.presentation.screens.DrugDetailScreen
//import com.approagency.drug.presentation.screens.SearchScreen
//
//fun NavGraphBuilder.searchGraph(navController: NavHostController) {
//
// composable(MainRoute.SearchGraph.route) {
// SearchScreen(navController)
// }
//}
@@ -0,0 +1,37 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.vada.caller.ui.theme.dime
import kotlinx.coroutines.CoroutineScope
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomModalBottomSheet(
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
scope: CoroutineScope = rememberCoroutineScope(),
onDismiss: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
ModalBottomSheet(
onDismissRequest = {
onDismiss?.invoke()
},
sheetState = sheetState,
modifier = modifier,
shape = RoundedCornerShape(topStart = MaterialTheme.dime.lg, topEnd = MaterialTheme.dime.lg)
) {
Box(
modifier = Modifier
.fillMaxWidth()
) {
content()
}
}
}
@@ -0,0 +1,25 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@Composable
fun CustomBox(
modifier: Modifier? = Modifier,
child: @Composable ()-> Unit
) {
if (modifier != null) {
Box(
modifier = modifier.clip(shape = MaterialTheme.shapes.large).background(
color = MaterialTheme.colorScheme.surfaceContainer
)
) {
child()
}
}
}
@@ -0,0 +1,39 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Dialog
import com.vada.caller.ui.theme.dime
@Composable
fun CustomModalDialog(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Dialog(
onDismissRequest = onDismissRequest
){
Surface(
modifier = modifier
.wrapContentHeight(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface,
tonalElevation = MaterialTheme.dime.sm
) {
Column(
modifier = Modifier
.padding(MaterialTheme.dime.lg),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.lg),
content = content
)
}
}
}
@@ -0,0 +1,182 @@
package com.approagency.pharmacy.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.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 = 1200L,
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
)
}
}
}
}
}
}
@@ -0,0 +1,77 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
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.sp
import com.vada.caller.ui.theme.LocalDime
@Composable
fun EmptySearchState(
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "جستجوی داروها",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
fontSize = 16.sp
)
Text(
text = "نام دارو را وارد کنید",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
fontSize = 14.sp,
modifier = Modifier.padding(top = dime.sm)
)
}
}
@Composable
fun ErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "خطا",
color = MaterialTheme.colorScheme.error,
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
Text(
text = message,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
fontSize = 14.sp,
modifier = Modifier.padding(top = dime.sm, bottom = dime.lg),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Button(
onClick = onRetry,
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("تلاش مجدد")
}
}
}
@@ -0,0 +1,56 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
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.vada.caller.ui.theme.LocalDime
@Composable
fun LoadingMoreIndicator(
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Box(
modifier = modifier
.fillMaxWidth()
.padding(dime.lg),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = MaterialTheme.colorScheme.primary
)
}
}
@Composable
fun EndOfListIndicator(
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Box(
modifier = modifier
.fillMaxWidth()
.padding(dime.xl),
contentAlignment = Alignment.Center
) {
Text(
text = "پایان نتایج",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
fontSize = 12.sp
)
}
}
@@ -0,0 +1,21 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.material3.SnackbarHostState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
object SnackbarManager {
private var snackbarHostState: SnackbarHostState? = null
private var coroutineScope: CoroutineScope? = null
fun init(scope: CoroutineScope, hostState: SnackbarHostState) {
coroutineScope = scope
snackbarHostState = hostState
}
fun showMessage(message: String) {
coroutineScope?.launch {
snackbarHostState?.showSnackbar(message)
}
}
}
@@ -0,0 +1,7 @@
package com.vada.caller.presentation.common
sealed interface UiState<out T> {
object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String) : UiState<Nothing>
}
@@ -0,0 +1,20 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun Loading(
color: Color = MaterialTheme.colorScheme.onPrimary,
modifier: Modifier = Modifier
) {
CircularProgressIndicator(
color = color,
modifier = modifier,
strokeWidth = 2.dp
)
}
@@ -0,0 +1,38 @@
package com.approagency.pharmacy.presentation.common
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun PrimaryButton(
text: String,
isLoading: Boolean?,
onClick: () -> Unit,
height: Int? = null
) {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().height(height?.dp ?: 50.dp),
onClick = {
onClick()
},
) {
if (isLoading == true) {
Text("... در حال جستجو" , textAlign = TextAlign.Right)
} else {
Text(text)
}
}
}
@@ -0,0 +1,122 @@
package com.approagency.pharmacy.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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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 com.approagency.pharmacy.data.dto.DarmanList
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.presentation.common.CustomBox
import com.vada.caller.ui.theme.dime
import kotlin.collections.emptyList
@Composable
fun DarmanContent(
darmanData: Result<DarmanModel?>?,
onDarmanClick: (Int) -> Unit,
onRetryClick:()-> Unit,
){
val drugs = darmanData?.getOrNull()?.data ?: emptyList()
if (drugs.isEmpty()){
RetryContent(
modifier = Modifier,
error = "مشکلی از سمت سرور پیش آمده",
onClick = {
onRetryClick()
}
)
} else {
LazyColumn {
items(drugs.count() , itemContent ={
i -> DarmanItem(
darman = darmanData?.getOrNull()!!.data[i] ,
onClick = { onDarmanClick(drugs[i].cod!!) }
)
} )
}
}
}
@Composable
fun DarmanItem(
darman: DarmanList, // Adjust this based on your actual data class
onClick: (Int?) -> Unit
) {
CustomBox(
modifier = Modifier
.padding(bottom = MaterialTheme.dime.sm)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick(darman.cod)
},
child = {
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(darman.nam_fa.toString() , style = MaterialTheme.typography.labelLarge , textAlign = TextAlign.Right )
Text(darman.nam_en.toString() , style = MaterialTheme.typography.bodySmall)
}
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
onClick(darman.cod)
}
) {
Row(
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.KeyboardArrowLeft
, contentDescription = "see more"
)
Text("مشاهده لیست")
}
}
}
}
}
}
)
// Button(
// onClick = onClick,
// modifier = Modifier
// .fillMaxWidth()
// .padding(vertical = 4.dp)
// ) {
// drug.nam_fa?.let { Text(text = it) } // Adjust based on your drug model
// }
}
@@ -0,0 +1,76 @@
package com.approagency.pharmacy.presentation.components
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.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.approagency.pharmacy.domain.model.DrugSearchResult
import com.approagency.pharmacy.presentation.common.CustomBox
import com.vada.caller.ui.theme.LocalDime
import com.vada.caller.ui.theme.dime
@Composable
fun DaroYabSearchResult(
drug: DrugSearchResult,
onClickDetail: (DrugSearchResult) -> Unit,
onClickDrugStore:(DrugSearchResult) ->Unit,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
CustomBox (
modifier = modifier
.padding(bottom = MaterialTheme.dime.sm)
.fillMaxWidth()
.clickable { onClickDetail(drug) },
) {
Row (
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Column(
modifier = Modifier.padding(dime.lg),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End
) {
Text(
text = drug.persianName,
style = MaterialTheme.typography.labelLarge
)
drug.englishName?.let {
Text(
text = it,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
Text(
text = "کد: ${drug.genericId}",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.primary
)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {onClickDetail(drug)} , content = {
Text("اطلاعات دارویی")
})
TextButton(onClick = {onClickDrugStore(drug)} , content = {
Text("موجودی داروخانه")
})
}
}
}
}
}
@@ -0,0 +1,152 @@
package com.approagency.pharmacy.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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Search
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import com.approagency.pharmacy.data.dto.DrugDetail
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.presentation.common.CustomBox
import com.vada.caller.ui.theme.dime
@Composable
fun DrugListContent(
drugsData: Result<DrugListResponse?>?,
onDrugClick: (Int) -> Unit
) {
val drugs = drugsData?.getOrNull()?.data ?: emptyList()
if (drugs.isEmpty()) {
CustomBox(
child = {
Row(
horizontalArrangement = Arrangement.Center,
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"
)
}
}
)
} else {
LazyColumn(
){
items(drugs.count() , itemContent ={
i -> DrugItem(
drug = drugs[i],
onClick = { onDrugClick(drugs[i].cod!!) }
)
} )
}
}
}
@Composable
fun DrugItem(
drug: DrugDetail, // Adjust this based on your actual data class
onClick: (Int?) -> Unit
) {
CustomBox(
modifier = Modifier
.padding(bottom = MaterialTheme.dime.sm)
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onClick(drug.cod)
},
child = {
Row(
modifier = Modifier.padding(all = MaterialTheme.dime.lg),
) {
Column (
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(drug.nam_en.toString() , style = MaterialTheme.typography.bodySmall)
Text(drug.nam_fa.toString() , style = MaterialTheme.typography.labelLarge,textAlign = TextAlign.Right)
}
if (drug.goroh_darmani != null )
Row(
modifier = Modifier.fillMaxWidth()
, horizontalArrangement = Arrangement.End
) {Text(
"گروه دارویی: ${drug.goroh_darmani.nam_fa.toString()}",
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Right
) }
Row (
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
onClick(drug.cod)
}
) {
Row(
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.AutoMirrored.TwoTone.KeyboardArrowLeft
, contentDescription = "see more"
)
Text("مشاهده جزییات")
}
}
}
}
}
}
)
// Button(
// onClick = onClick,
// modifier = Modifier
// .fillMaxWidth()
// .padding(vertical = 4.dp)
// ) {
// drug.nam_fa?.let { Text(text = it) } // Adjust based on your drug model
// }
}
@@ -0,0 +1,40 @@
package com.approagency.pharmacy.presentation.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
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 com.approagency.pharmacy.presentation.common.CustomBox
import com.vada.caller.ui.theme.dime
@Composable
fun EmptyStateContent() {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
, verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
modifier = Modifier.size(MaterialTheme.dime.iconLg)
)
CustomBox(
child = {
Text(
text = "برای جستجو، نام دارو را وارد کنید",
modifier = Modifier.padding(MaterialTheme.dime.sm)
)
}
)
}
}
@@ -0,0 +1,141 @@
package com.approagency.pharmacy.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material3.Icon
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.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PaginationControls(
currentPage: Int,
totalPages: Int,
onPageSelected: (Int) -> Unit,
onNextPage: () -> Unit,
onPreviousPage: () -> Unit,
modifier: Modifier = Modifier
) {
if (totalPages <= 1) return
Row(
modifier = modifier.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// Previous button
PaginationButton(
enabled = currentPage > 1,
onClick = onPreviousPage,
isPrevious = true
)
// Page numbers
val pageRange = getVisiblePageRange(currentPage, totalPages)
if (pageRange.first > 1) {
PageNumberButton(page = 1, isSelected = false, onClick = onPageSelected)
if (pageRange.first > 2) {
Text("...", modifier = Modifier.padding(horizontal = 4.dp))
}
}
pageRange.forEach { page ->
PageNumberButton(
page = page,
isSelected = page == currentPage,
onClick = onPageSelected
)
}
if (pageRange.last < totalPages) {
if (pageRange.last < totalPages - 1) {
Text("...", modifier = Modifier.padding(horizontal = 4.dp))
}
PageNumberButton(page = totalPages, isSelected = false, onClick = onPageSelected)
}
// Next button
PaginationButton(
enabled = currentPage < totalPages,
onClick = onNextPage,
isPrevious = false
)
}
}
@Composable
private fun PageNumberButton(
page: Int,
isSelected: Boolean,
onClick: (Int) -> Unit
) {
val backgroundColor = if (isSelected) Color(0xFF7D64BA) else Color.Transparent
val textColor = if (isSelected) Color.White else Color(0xFF7D64BA)
Text(
text = page.toString(),
modifier = Modifier
.padding(horizontal = 4.dp)
.clip(RoundedCornerShape(4.dp))
.background(backgroundColor)
.clickable { onClick(page) }
.padding(horizontal = 12.dp, vertical = 8.dp),
color = textColor,
fontSize = 14.sp
)
}
@Composable
private fun PaginationButton(
enabled: Boolean,
onClick: () -> Unit,
isPrevious: Boolean
) {
val icon = if (isPrevious) Icons.Default.ArrowBack else Icons.Default.ArrowForward
val color = if (enabled) Color(0xFF7D64BA) else Color.Gray
Icon(
imageVector = icon,
contentDescription = if (isPrevious) "Previous" else "Next",
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
.padding(8.dp),
tint = color
)
}
private fun getVisiblePageRange(currentPage: Int, totalPages: Int): IntRange {
val maxVisible = 5
val halfVisible = maxVisible / 2
var start = currentPage - halfVisible
var end = currentPage + halfVisible
if (start < 1) {
end += (1 - start)
start = 1
}
if (end > totalPages) {
start -= (end - totalPages)
end = totalPages
}
if (start < 1) start = 1
return start..end
}
@@ -0,0 +1,350 @@
package com.approagency.pharmacy.presentation.components
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
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 androidx.compose.ui.unit.sp
import com.approagency.pharmacy.domain.model.PharmacyItem
import com.approagency.pharmacy.presentation.common.CustomBox
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
import com.approagency.pharmacy.presentation.common.Loading
import com.approagency.pharmacy.presentation.viewModel.PharmacyDetailState
import com.approagency.pharmacy.presentation.viewModel.PharmacyState
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
import com.approagency.pharmacy.utils.provinces
import com.vada.caller.ui.theme.LocalDime
import com.vada.caller.ui.theme.dime
@SuppressLint("ConfigurationScreenWidthHeight")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PharmacyBottomSheet(
viewModel: PharmacyViewModel, genericDrugId: String, isOpen: Boolean, onDismiss: () -> Unit
) {
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = false, confirmValueChange = { true })
var selectedProvinceId by remember { mutableStateOf("40") } // پیش‌فرض تهران
var showProvinceDropdown by remember { mutableStateOf(false) }
val pharmacyState by viewModel.state.collectAsState()
// جستجوی اولیه با استان تهران
LaunchedEffect(Unit) {
if (genericDrugId.isNotBlank()) {
viewModel.search(genericDrugId, "40")
}
}
if (isOpen) {
CustomModalBottomSheet(
sheetState = sheetState, onDismiss = onDismiss, scope = scope
) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
LazyColumn(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.heightIn(min = 400.dp)
.padding(MaterialTheme.dime.md),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.xl)
) {
item {
Text(
text = "جستجوی دارو در داروخانه‌ها",
fontWeight = FontWeight.Bold,
fontSize = MaterialTheme.typography.titleMedium.fontSize
)
}
// عنوان
item {
ExposedDropdownMenuBox(
expanded = showProvinceDropdown,
onExpandedChange = { showProvinceDropdown = it }) {
OutlinedTextField(
value = provinces.find { it.id == selectedProvinceId }?.name
?: "انتخاب استان",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showProvinceDropdown) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
shape = MaterialTheme.shapes.large
)
ExposedDropdownMenu(
expanded = showProvinceDropdown,
onDismissRequest = { showProvinceDropdown = false }) {
provinces.forEach { province ->
DropdownMenuItem(text = { Text(province.name) }, onClick = {
selectedProvinceId = province.id
showProvinceDropdown = false
// جستجو با استان جدید
viewModel.search(genericDrugId, province.id)
})
}
}
}
}
// انتخاب استان
// نمایش نتایج
when (val state = pharmacyState) {
PharmacyState.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Loading()
Spacer(modifier = Modifier.height(MaterialTheme.dime.xl))
Text(
text = "در حال جستجو...",
fontSize = MaterialTheme.typography.bodySmall.fontSize
)
}
}
}
}
is PharmacyState.Success -> {
if (state.items.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "هیچ داروخانه‌ای یافت نشد",
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
} else {
items(state.items) { pharmacy ->
PharmacyResultItem(pharmacy = pharmacy, viewModel)
}
item {
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
Text(
text = "تعداد کل: ${state.items.size} داروخانه",
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.padding(bottom = MaterialTheme.dime.xs)
)
}
// LazyColumn(
// verticalArrangement = Arrangement.spacedBy(8.dp),
// modifier = Modifier,
// ) {
// items(state.items) { pharmacy ->
// PharmacyResultItem(pharmacy = pharmacy)
// }
//
//
// }
}
}
is PharmacyState.Error -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
fontSize = MaterialTheme.typography.bodyMedium.fontSize
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = { viewModel.retry() }) {
Text("تلاش مجدد")
}
}
}
}
}
PharmacyState.Idle -> {}
}
}
}
}
}
}
@Composable
fun PharmacyResultItem(
pharmacy: PharmacyItem, viewModel: PharmacyViewModel, modifier: Modifier = Modifier
) {
val dime = LocalDime.current
var showDetails by remember { mutableStateOf(false) }
// گرفتن State مخصوص این داروخانه از Map
val detailStates by viewModel.pharmacyDetailStates.collectAsState()
val detailState = detailStates[pharmacy.pharmacyUrl] ?: PharmacyDetailState.Idle
// Reset detail state when item changes
LaunchedEffect(pharmacy.pharmacyUrl) {
println(pharmacy.pharmacyUrl)
showDetails = false
}
// Load detail when expanded
LaunchedEffect(showDetails) {
if (showDetails) {
// فقط اگر Idle هست (هنوز لود نشده) درخواست بده
if (detailState is PharmacyDetailState.Idle) {
println(pharmacy.pharmacyUrl)
viewModel.loadPharmacyDetail(pharmacy.pharmacyUrl)
}
}
}
CustomBox(
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// نام برند
Text(
text = pharmacy.brandName,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
color = MaterialTheme.colorScheme.primary
)
// نام داروخانه
Text(
text = pharmacy.pharmacyName,
fontWeight = FontWeight.Bold,
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
// موقعیت
// موقعیت و دکمه اطلاعات تماس
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "${pharmacy.province} - ${pharmacy.city}",
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
TextButton(
onClick = { showDetails = !showDetails }) {
Text(
text = if (showDetails) "بستن" else "اطلاعات تماس",
fontSize = MaterialTheme.typography.bodySmall.fontSize
)
}
}
if (showDetails) {
when (detailState) {
is PharmacyDetailState.Loading -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
is PharmacyDetailState.Success -> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
HorizontalDivider(modifier = Modifier.padding(vertical = MaterialTheme.dime.xs))
// آدرس
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = detailState.detail.address,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
lineHeight = 18.sp
)
}
// تلفن
if (detailState.detail.phone.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Phone,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = detailState.detail.phone,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Medium
)
}
}
}
}
is PharmacyDetailState.Error -> {
Text(
text = "خطا در دریافت اطلاعات تماس",
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.error
)
}
else -> {}
}
}
}
}
}
@@ -0,0 +1,42 @@
package com.approagency.pharmacy.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.pharmacy.presentation.common.CustomBox
import com.approagency.pharmacy.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,204 @@
package com.approagency.pharmacy.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.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.domain.model.TestItem
import com.approagency.pharmacy.presentation.common.CustomBox
import com.approagency.pharmacy.presentation.common.Loading
import com.approagency.pharmacy.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
@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,222 @@
package com.approagency.pharmacy.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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import com.approagency.pharmacy.data.dto.DrugDetail // Import DrugDetail directly
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
import com.approagency.pharmacy.presentation.viewModel.DrugDetailState
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
import com.vada.caller.ui.theme.dime // Assuming this is your custom spacing/dimension object
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun DrugDetailSheet(
viewModel: HomeViewModel,
state: DrugDetailState // Assuming you pass the state containing the detail
) {
val detail: DrugDetail? = state.drugDetail?.getOrNull()?.data
// Only show the sheet if detail is not null
if (detail != null) {
CustomModalBottomSheet(
onDismiss = {
viewModel.closeDetail()
},
content = {
// Use a LazyColumn for scrollable content inside the sheet
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = MaterialTheme.dime.lg),
contentPadding = PaddingValues(bottom = MaterialTheme.dime.lg) // Add padding to the bottom of the content for better scrolling
) {
// --- 1. Main Title ---
item { // Use item {} for single composables within LazyColumn
Text(
text = detail.nam_fa ?: "جزئیات دارو",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = MaterialTheme.dime.md)
)
Text(
text = detail.nam_en ?: "N/A (English Name)",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = MaterialTheme.dime.lg)
)
// Divider for visual separation
HorizontalDivider(Modifier.padding(bottom = MaterialTheme.dime.md))
}
// --- 2. Key Groupings ---
// Goroh Darmani (Pharmacological Group)
detail.goroh_darmani?.let { group ->
item {
DetailRow(
title = "گروه درمانی",
value = group.nam_fa ?: "نامشخص"
)
}
group.nam_en?.let { enName ->
item {
DetailRow(
title = "Group (EN)",
value = enName
)
}
}
}
// Goroh Darmanei (Therapeutic Group)
detail.goroh_daroei?.let { group ->
item {
DetailRow(
title = "گروه دارویی",
value = group.nam ?: "نامشخص"
)
}
}
// Goroh Farmakologic (Pharmacological Class)
detail.goroh_farmakologic_cod?.let { cod ->
// NOTE: You might need a separate API call or mapping here
// if cod needs to be translated to a name. For now, we show the code.
item {
DetailRow(
title = "کلاس فارماکولوژیک",
value = cod.toString()
)
}
}
item { // Add a divider as a separate item
HorizontalDivider(Modifier.padding(vertical = MaterialTheme.dime.md))
}
// --- 3. Usage & Mechanism xw---
detail.mavaredmasraf?.let { usage ->
item {
DetailSection(title = "موارد مصرف", content = usage)
}
}
detail.mekanismtasir?.let { mechanism ->
item {
DetailSection(title = "مکانیسم اثر", content = mechanism)
}
}
// --- 4. Warnings & Storage ---
detail.hoshdar?.let { warning ->
item {
DetailSection(
title = "هشدارها",
content = warning,
isWarning = true
)
}
}
detail.sharayetnegahdari?.let { storage ->
item {
DetailSection(
title = "شرایط نگهداری",
content = storage
)
}
}
}
}
}
)
}
}
// --- Helper Composables for Clean Code ---
@Composable
fun DetailRow(
title: String,
value: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.dime.sm),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Right
)
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.End,
// Ensure text takes up available space and doesn't push other elements.
// Using weight(1f, fill = false) is good for aligning to the end if there's space.
modifier = Modifier.weight(1f, fill = false)
)
}
}
@Composable
fun DetailSection(
title: String,
content: String,
isWarning: Boolean = false
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.dime.md)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = MaterialTheme.dime.sm)
)
// Use a text block that respects line breaks (usually done with \n)
Text(
text = content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
// Consider adding softWrap = true if content might overflow
// softWrap = true
)
}
// Add divider after each section for better visual separation
HorizontalDivider(Modifier.padding(top = MaterialTheme.dime.sm))
}
// Make sure your DrugDetailState in ViewModel looks something like this:
// data class DrugDetailState(
// val isLoading: Boolean = false,
// val drugDetail: DrugModels? = null, // Changed to DrugModels? to match data access
// val isDetailVisible: Boolean = false,
// val error: String? = null
// )
@@ -0,0 +1,119 @@
package com.approagency.pharmacy.presentation.components
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.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
import com.approagency.pharmacy.presentation.viewModel.TestItemState
import com.vada.caller.ui.theme.dime
@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.pharmacy.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.pharmacy.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.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.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("مشاهده جزییات")
}
}
}
}
}
}
}
@@ -0,0 +1,504 @@
package com.approagency.pharmacy.presentation.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
import com.approagency.pharmacy.presentation.common.ErrorState
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
import com.approagency.pharmacy.presentation.viewModel.DrugDetailYabState
import com.vada.caller.ui.theme.LocalDime
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrugDetailBottomSheet(
detailUrl: String?,
onDismiss: () -> Unit,
viewModel: DrugDetailViewModel
) {
val dime = LocalDime.current
val detailState by viewModel.detailState.collectAsStateWithLifecycle()
// Load detail when sheet opens with a valid URL
LaunchedEffect(detailUrl) {
if (detailUrl != null) {
viewModel.loadDrugDetail(detailUrl)
}
}
// Reset when sheet is closed
LaunchedEffect(detailUrl) {
if (detailUrl == null) {
viewModel.reset()
}
}
if (detailUrl != null) {
CustomModalBottomSheet(
onDismiss = {
onDismiss()
viewModel.reset()
}
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
) {
when (val state = detailState) {
DrugDetailYabState.Idle -> {
// Initial state, nothing to show yet
}
DrugDetailYabState.Loading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.padding(dime.xl),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(dime.md))
Text(
text = "در حال دریافت اطلاعات دارو...",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
is DrugDetailYabState.Success -> {
DrugDetailContent(
drugDetail = state.drugDetail,
modifier = Modifier.padding(bottom = dime.lg)
)
}
is DrugDetailYabState.Error -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.padding(dime.xl),
contentAlignment = Alignment.Center
) {
ErrorState(
message = state.message,
onRetry = { viewModel.loadDrugDetail(detailUrl) }
)
}
}
}
}
}
}
}
@Composable
fun DrugDetailContent(
drugDetail: DrugDetail,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("عمومی", "تخصصی", "اشکال دارویی", "اسامی تجاری")
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = dime.md)
) {
// Header with close button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dime.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = drugDetail.persianName,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (drugDetail.englishName.isNotEmpty()) {
Text(
text = drugDetail.englishName,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
// Categories
if (drugDetail.drugClass != null || drugDetail.therapeuticClass != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dime.sm),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.sm)
) {
drugDetail.drugClass?.let {
Row {
Text(
text = "طبقه بندی: ",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
drugDetail.therapeuticClass?.let {
Spacer(modifier = Modifier.height(4.dp))
Row {
Text(
text = "طبقه درمانی: ",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Tabs
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = dime.sm)
) {
tabs.forEachIndexed { index, title ->
androidx.compose.material3.Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
modifier = Modifier.weight(1f),
text = {
Text(
text = title,
fontSize = 13.sp,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
)
}
)
}
}
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
// Tab content
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
.padding(vertical = dime.md)
) {
when (selectedTab) {
0 -> {
// General Information
item {
GeneralInfoTab(drugDetail)
}
}
1 -> {
// Specialized Information
drugDetail.usage?.let {
item {
InfoSection(title = "موارد مصرف", content = it)
}
}
drugDetail.mechanism?.let {
item {
InfoSection(title = "مکانیسم اثر", content = it)
}
}
drugDetail.contraindications?.let {
item {
InfoSection(title = "موارد منع مصرف", content = it)
}
}
drugDetail.sideEffects?.let {
item {
InfoSection(title = "عوارض جانبی", content = it)
}
}
drugDetail.interactions?.let {
item {
InfoSection(title = "تداخلات دارویی", content = it)
}
}
drugDetail.warnings?.let {
item {
InfoSection(title = "هشدارها", content = it)
}
}
drugDetail.recommendations?.let {
item {
InfoSection(title = "توصیه‌های دارویی", content = it)
}
}
}
2 -> {
// Dosage Forms
if (drugDetail.dosageForms.isNotEmpty()) {
item {
DosageFormsSection(dosageForms = drugDetail.dosageForms)
}
}
}
3 -> {
// Brand Names
if (drugDetail.brandNames.isNotEmpty()) {
item {
BrandNamesSection(brandNames = drugDetail.brandNames)
}
}
}
}
}
}
}
@Composable
fun GeneralInfoTab(drugDetail: DrugDetail) {
val dime = LocalDime.current
Column {
// Pregnancy Info
if (drugDetail.pregnancyCategory != null || drugDetail.pregnancyDescription != null) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dime.md),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFF3E0)
)
) {
Column(modifier = Modifier.padding(dime.md)) {
Text(
text = "مصرف در بارداری",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = Color(0xFFE65100)
)
Spacer(modifier = Modifier.height(dime.xs))
drugDetail.pregnancyCategory?.let {
Text(
text = "گروه: $it",
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
}
drugDetail.pregnancyDescription?.let {
Spacer(modifier = Modifier.height(dime.xs))
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
}
}
// Usage
drugDetail.usage?.let {
InfoSection(title = "موارد مصرف", content = it)
}
// Contraindications
drugDetail.contraindications?.let {
InfoSection(title = "موارد منع مصرف", content = it)
}
// Side Effects
drugDetail.sideEffects?.let {
InfoSection(title = "عوارض جانبی", content = it)
}
// Warnings
drugDetail.warnings?.let {
InfoSection(title = "هشدارها", content = it)
}
}
}
@Composable
fun InfoSection(
title: String,
content: String,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = dime.md)
) {
Text(
text = title,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(dime.xs))
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(modifier = Modifier.height(dime.xs))
Text(
text = content,
fontSize = 14.sp,
lineHeight = 22.sp,
textAlign = TextAlign.Justify,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f)
)
}
}
@Composable
fun DosageFormsSection(dosageForms: List<com.approagency.pharmacy.domain.model.DosageForm>) {
val dime = LocalDime.current
Column {
Text(
text = "اشکال دارویی",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = dime.sm)
)
dosageForms.forEach { form ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dime.sm),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(dime.sm)) {
Text(
text = form.persianName,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Text(
text = form.englishName,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Row(
modifier = Modifier.padding(top = dime.xs),
horizontalArrangement = Arrangement.spacedBy(dime.sm)
) {
if (form.isHighRisk) {
Card(
onClick = { },
content = { Text("پرخطر", fontSize = 10.sp) },
)
}
if (form.isVital) {
Card(
onClick = { },
content = { Text("حیاتی", fontSize = 10.sp) },
)
}
}
}
}
}
}
}
@Composable
fun BrandNamesSection(brandNames: List<com.approagency.pharmacy.domain.model.BrandName>) {
val dime = LocalDime.current
Column {
Text(
text = "اسامی تجاری",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = dime.sm)
)
brandNames.forEach { brand ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = dime.sm),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.padding(dime.sm)) {
Text(
text = brand.persianName,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
brand.manufacturer?.let {
Text(
text = "تولید کننده: $it",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
}
}
}
@@ -0,0 +1,776 @@
package com.approagency.pharmacy.presentation.screens
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.presentation.common.ErrorState
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
import com.approagency.pharmacy.presentation.viewModel.DrugDetailYabState
import com.vada.caller.ui.theme.LocalDime
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrugDetailScreen(
navController: NavController,
detailUrl: String,
viewModel: DrugDetailViewModel = koinViewModel()
) {
val dime = LocalDime.current
val detailState by viewModel.detailState.collectAsStateWithLifecycle()
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("عمومی", "تخصصی", "اشکال دارویی", "اسامی تجاری")
LaunchedEffect(detailUrl) {
viewModel.loadDrugDetail(detailUrl)
}
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Scaffold(
topBar = {
TopAppBar(
title = {
when (val state = detailState) {
is DrugDetailYabState.Success -> {
Column {
Text(
text = state.drugDetail.persianName,
style = MaterialTheme.typography.titleMedium
)
if (state.drugDetail.englishName.isNotEmpty()) {
Text(
text = state.drugDetail.englishName.take(30),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1
)
}
}
}
else -> {
Text("جزئیات دارو", fontSize = 16.sp)
}
}
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "بازگشت"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (val state = detailState) {
DrugDetailYabState.Idle -> {}
DrugDetailYabState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(dime.md))
Text(
text = "در حال دریافت اطلاعات دارو...",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
is DrugDetailYabState.Success -> {
val drugDetail = state.drugDetail
Column(modifier = Modifier.fillMaxSize()) {
// Manufacturer Info Card (برای صفحات برند)
if (!drugDetail.isGeneric && drugDetail.manufacturer != null) {
ManufacturerInfoCard(
manufacturer = drugDetail.manufacturer!!,
genericInfo = drugDetail.genericInfo,
modifier = Modifier.padding(
horizontal = dime.md,
vertical = dime.sm
)
)
}
// Tab Row
TabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
fontSize = 13.sp,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal,
maxLines = 1
)
}
)
}
}
// Tab Content
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dime.md)
.padding(top = dime.md),
verticalArrangement = Arrangement.spacedBy(dime.md)
) {
when (selectedTab) {
0 -> {
item { GeneralInfoTabContent(drugDetail) }
}
1 -> {
item { SpecializedInfoTabContent(drugDetail) }
}
2 -> {
item { DosageFormsTabContent(drugDetail) }
}
3 -> {
item { BrandNamesTabContent(drugDetail) }
}
}
}
}
}
is DrugDetailYabState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
ErrorState(
message = state.message,
onRetry = { viewModel.loadDrugDetail(detailUrl) }
)
}
}
}
}
}
}
}
@Composable
fun ManufacturerInfoCard(
manufacturer: String,
genericInfo: com.approagency.pharmacy.domain.model.GenericInfo?,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dime.xs)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "سازنده:",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = manufacturer,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
if (genericInfo != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dime.xs)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "ماده موثره:",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = genericInfo.persianName,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// آپدیت GeneralInfoTabContent برای صفحات Generic
@Composable
fun GeneralInfoTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
Column(verticalArrangement = Arrangement.spacedBy(dime.md)) {
if (drugDetail.isGeneric) {
// ========== صفحات ژنریک (Generic) ==========
// اگر generalInfo وجود دارد (از EtelaatOmomiVaTakhasosi یا EtelaatOmomi)
if (!drugDetail.generalInfo.isNullOrBlank()) {
InfoSectionCard(
title = "اطلاعات عمومی",
content = drugDetail.generalInfo
)
}
// اگر specializedInfo وجود دارد (از EtelaatTakhasosiContent)
else if (!drugDetail.specializedInfo.isNullOrBlank()) {
InfoSectionCard(
title = "اطلاعات تخصصی",
content = drugDetail.specializedInfo
)
}
// در غیر این صورت، اطلاعات پراکنده را نمایش بده
else {
// Categories Card
if (drugDetail.drugClass != null || drugDetail.therapeuticClass != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
drugDetail.drugClass?.let {
Row {
Text(
text = "طبقه بندی مارتیندل: ",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
drugDetail.therapeuticClass?.let {
Row {
Text(
text = "طبقه درمانی: ",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Pregnancy Card
if (drugDetail.pregnancyCategory != null || drugDetail.pregnancyDescription != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFF3E0)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = "مصرف در بارداری",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color(0xFFE65100)
)
drugDetail.pregnancyCategory?.let {
Text(
text = "گروه: $it",
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
drugDetail.pregnancyDescription?.let {
Spacer(modifier = Modifier.height(dime.xs))
Text(
text = it,
fontSize = 12.sp,
lineHeight = 18.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
}
}
// موارد مصرف
drugDetail.usage?.let {
InfoSectionCard(title = "موارد مصرف", content = it)
}
// موارد منع مصرف
drugDetail.contraindications?.let {
InfoSectionCard(title = "موارد منع مصرف", content = it)
}
// عوارض جانبی
drugDetail.sideEffects?.let {
InfoSectionCard(title = "عوارض جانبی", content = it)
}
// هشدارها
drugDetail.warnings?.let {
InfoSectionCard(title = "هشدارها", content = it)
}
// توصیه‌های دارویی
drugDetail.recommendations?.let {
InfoSectionCard(title = "توصیه‌های دارویی", content = it)
}
}
// اگر هیچ اطلاعاتی وجود نداشت
if (drugDetail.drugClass == null &&
drugDetail.therapeuticClass == null &&
drugDetail.pregnancyCategory == null &&
drugDetail.usage == null &&
drugDetail.contraindications == null &&
drugDetail.sideEffects == null &&
drugDetail.warnings == null &&
drugDetail.generalInfo.isNullOrBlank() &&
drugDetail.specializedInfo.isNullOrBlank()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "اطلاعات عمومی برای این دارو ثبت نشده است",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
} else {
// ========== صفحات برند (Brand) ==========
// نمایش اطلاعات تخصصی برند که در بخش brandAttrPersDesc است
drugDetail.usage?.let {
InfoSectionCard(title = "موارد مصرف", content = it)
}
drugDetail.contraindications?.let {
InfoSectionCard(title = "موارد منع مصرف", content = it)
}
drugDetail.sideEffects?.let {
InfoSectionCard(title = "عوارض جانبی", content = it)
}
drugDetail.warnings?.let {
InfoSectionCard(title = "هشدارها", content = it)
}
drugDetail.recommendations?.let {
InfoSectionCard(title = "توصیه‌های دارویی", content = it)
}
if (drugDetail.usage == null &&
drugDetail.contraindications == null &&
drugDetail.sideEffects == null &&
drugDetail.warnings == null
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "اطلاعات عمومی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
}
}
@Composable
fun SpecializedInfoTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
Column(verticalArrangement = Arrangement.spacedBy(dime.md)) {
// برای صفحات برند، اطلاعات تخصصی معمولاً در قسمت brandAttrPersDesc است
// که در پارسر ما به صورت usage, contraindications, sideEffects, warnings, recommendations ذخیره شده
drugDetail.usage?.let {
InfoSectionCard(title = "موارد مصرف", content = it)
}
drugDetail.mechanism?.let {
InfoSectionCard(title = "مکانیسم اثر", content = it)
}
drugDetail.pharmacokinetics?.let {
InfoSectionCard(title = "فارماکوکینتیک", content = it)
}
drugDetail.contraindications?.let {
InfoSectionCard(title = "موارد منع مصرف", content = it)
}
drugDetail.sideEffects?.let {
InfoSectionCard(title = "عوارض جانبی", content = it)
}
drugDetail.interactions?.let {
InfoSectionCard(title = "تداخلات دارویی", content = it)
}
drugDetail.warnings?.let {
InfoSectionCard(title = "هشدارها", content = it)
}
drugDetail.recommendations?.let {
InfoSectionCard(title = "توصیه‌های دارویی", content = it)
}
// اگر هیچ اطلاعات تخصصی وجود نداشت
if (drugDetail.usage == null &&
drugDetail.mechanism == null &&
drugDetail.contraindications == null &&
drugDetail.sideEffects == null &&
drugDetail.warnings == null
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (drugDetail.isGeneric)
"اطلاعات تخصصی برای این دارو ثبت نشده است"
else
"اطلاعات تخصصی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
fun DosageFormsTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
val dosageForms = drugDetail.dosageForms
if (dosageForms.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (drugDetail.isGeneric)
"اشکال دارویی برای این دارو ثبت نشده است"
else
"سایر اشکال دارویی این برند ثبت نشده است",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
// اگر صفحه برند است، عنوان متفاوت نشان بده
if (!drugDetail.isGeneric) {
Text(
text = "سایر محصولات این برند",
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = dime.xs)
)
}
dosageForms.forEach { form ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = form.persianName,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
if (form.englishName.isNotBlank()) {
Text(
text = form.englishName,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
// نشانگرهای خطر
if (form.isHighRisk || form.isVital) {
Row(
horizontalArrangement = Arrangement.spacedBy(dime.xs)
) {
if (form.isHighRisk) {
androidx.compose.material3.Surface(
shape = RoundedCornerShape(4.dp),
color = Color(0xFFFFEBEE)
) {
Text(
text = "پرخطر",
fontSize = 10.sp,
color = Color(0xFFC62828),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
if (form.isVital) {
androidx.compose.material3.Surface(
shape = RoundedCornerShape(4.dp),
color = Color(0xFFE8F5E9)
) {
Text(
text = "حیاتی",
fontSize = 10.sp,
color = Color(0xFF2E7D32),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
}
}
}
}
}
@Composable
fun BrandNamesTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
if (drugDetail.isGeneric && drugDetail.brandNames.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "اسامی تجاری برای این دارو ثبت نشده است",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
} else if (drugDetail.isGeneric && drugDetail.brandNames.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
drugDetail.brandNames.forEach { brand ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = brand.persianName,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
brand.manufacturer?.let {
Row {
Text(
text = "تولید کننده: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = it,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
brand.importer?.let {
Row {
Text(
text = "وارد کننده: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = it,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
} else {
// صفحه برند است - اسامی تجاری برای برند معنی ندارد
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "این صفحه مربوط به یک محصول تجاری است. برای مشاهده اسامی تجاری سایر برندها، به صفحه داروی ژنریک مراجعه کنید.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun InfoSectionCard(
title: String,
content: String,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = title,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.primary
)
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
Text(
text = content,
fontSize = 13.sp,
lineHeight = 20.sp,
textAlign = TextAlign.Justify,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f)
)
}
}
}
@@ -0,0 +1,210 @@
package com.approagency.pharmacy.presentation.screens
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.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.ExperimentalMaterial3Api
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.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.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavController
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.presentation.common.CustomModalDialog
import com.approagency.pharmacy.presentation.common.Loading
import com.approagency.pharmacy.presentation.components.DarmanContent
import com.approagency.pharmacy.presentation.components.DrugDetailSheet
import com.approagency.pharmacy.presentation.components.DrugListContent
import com.approagency.pharmacy.presentation.components.EmptyStateContent
import com.approagency.pharmacy.presentation.viewModel.CombineState
import com.approagency.pharmacy.presentation.viewModel.HomeEvent
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
import com.vada.caller.ui.theme.dime
import kotlinx.coroutines.flow.SharedFlow
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
navController : NavController,
modifier: Modifier,
viewModel: HomeViewModel = koinViewModel()
) {
val state by viewModel.uiState.collectAsState()
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
if (state.drugDetailState.isDetailVisible) {
DrugDetailSheet(
viewModel = viewModel,
state = state.drugDetailState
)
}
HomeContent(
modifier = modifier,
state = state,
viewModelEvent = viewModel.event,
onSearch = { value ->
if (value is Int) {
viewModel.searchDrugs(DrugSearchParams(healGroup = value))
} else if (value is String) {
viewModel.searchDrugs(DrugSearchParams(query = value))
}
keyboardController?.hide()
focusManager.clearFocus()},
onRetry = {
viewModel.getDarmani()
},
onDrugClick = { drugId -> viewModel.drugDetail(drugId , allDrugs = state.drugSearchState.drugsData) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeContent(
state: CombineState,
viewModelEvent: SharedFlow<HomeEvent>,
onSearch: (Any) -> Unit,
onRetry: () -> Unit,
onDrugClick: (Int) -> Unit,
modifier: Modifier = Modifier
) {
var searchText by remember { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize().padding(horizontal = MaterialTheme.dime.md)
.padding(top = MaterialTheme.dime.md),
horizontalAlignment = Alignment.Start,
) {
Column (
verticalArrangement = Arrangement.Center
){
// CustomTextFilled(
// value = searchText,
// onValueChange = { searchText = it },
// onSearch = { query ->
// if (query.isNotBlank()) {
// onSearch(query)
// }
// },
// 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))
// PrimaryButton(
// text = "جستجو",
// height = 40,
// isLoading = state.drugSearchState.isLoading,
// onClick = {
// if (searchText.isNotBlank()) {
// onSearch(searchText)
// }
// }
// )
// Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
Box(modifier = Modifier.fillMaxSize()) {
// Always draw the data if available
if (state.drugSearchState.drugsData != null && !state.showDarmanList) {
Column {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = {
onRetry()
}
) {
Text("مشاهده تمامی گروه های دارویی")
Icon(
imageVector = Icons.Default.KeyboardArrowRight,
contentDescription = "back"
)
Spacer(modifier = Modifier.width(MaterialTheme.dime.sm))
}
}
DrugListContent(
drugsData = state.drugSearchState.drugsData,
onDrugClick = onDrugClick
)
}
}
else if (state.darmanState.getDarmani != null ){
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Text("لیست گروه های دارویی" , style = MaterialTheme.typography.labelLarge.copy(
color = MaterialTheme.colorScheme.primary
) ,modifier= Modifier.padding(MaterialTheme.dime.md))
}
DarmanContent(
darmanData = state.darmanState.getDarmani,
onDarmanClick = { value ->
onSearch(value)
},
onRetryClick = {
onRetry()
}
)
}
} else {
EmptyStateContent()
}
// Draw the loading dialog on top if needed
if (state.drugSearchState.isLoading || state.drugDetailState.isLoading || state.darmanState.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))
)
}
}
}
}
}
@@ -0,0 +1,190 @@
package com.approagency.pharmacy.presentation.screens
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.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.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.text.style.TextAlign
import com.approagency.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.domain.model.TestItem
import com.approagency.pharmacy.presentation.common.CustomModalDialog
import com.approagency.pharmacy.presentation.common.CustomTextFilled
import com.approagency.pharmacy.presentation.common.Loading
import com.approagency.pharmacy.presentation.common.PrimaryButton
import com.approagency.pharmacy.presentation.components.RetryContent
import com.approagency.pharmacy.presentation.components.SearchResultsContent
import com.approagency.pharmacy.presentation.components.TestDetailSheet
import com.approagency.pharmacy.presentation.components.TestGroupItemContent
import com.approagency.pharmacy.presentation.viewModel.LabViewModel
import com.vada.caller.ui.theme.dime
import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel
@Composable
fun LabScreen(
modifier: Modifier = Modifier,
viewModel: LabViewModel = koinViewModel()
) {
val testGroupsState by viewModel.testGroups.collectAsState()
val testItemsState by viewModel.testItems.collectAsState()
val searchResultsState by viewModel.searchResults.collectAsState()
val isLoading = testGroupsState.isLoading || testItemsState.isLoading || searchResultsState.isLoading
val error = testGroupsState.error ?: searchResultsState.error
val testGroups = testGroupsState.testGroup
var selectedGroup by remember { mutableStateOf<TestGroup?>(null) }
var selectedItem by remember { mutableStateOf<TestItem?>(null) }
var showSheet by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxSize().padding(horizontal = MaterialTheme.dime.md)
.padding(top = MaterialTheme.dime.md),) {
CustomTextFilled(
value = searchText,
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
},
onItemClick = { item ->
selectedItem = item
// You can show item detail sheet here
// For now, we'll show the parent group
viewModel.getItemByGroupId(item.groupId)
showSheet = true
},
onClearSearch = {
searchText = ""
viewModel.clearSearch()
viewModel.loadTestGroups()
}
)
} else if(error != null){
RetryContent(modifier = modifier , error = error , onClick = {
viewModel.loadTestGroups()
})
}
else if (testGroups != null){
TestGroupsList(
testGroupsFlow = testGroups,
viewModel = viewModel,
onGroupClick = { group ->
selectedGroup = group
viewModel.getItemByGroupId(group.id)
showSheet = true
}
)
}
else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("هیچ داده‌ای موجود نیست")
}
}
}
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
fun TestGroupsList(
testGroupsFlow: Flow<List<TestGroup>>,
viewModel: LabViewModel,
onGroupClick: (TestGroup) -> Unit
) {
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()
.fillMaxSize().padding( vertical =MaterialTheme.dime.md )
) {
items(testGroups) { group ->
TestGroupItemContent(
group = group,
onClick = { onGroupClick(group) }
)
}
}
}
}
@@ -0,0 +1,124 @@
package com.approagency.pharmacy.presentation.screens
import android.annotation.SuppressLint
import android.app.Activity
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api
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.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
@SuppressLint("ConfigurationScreenWidthHeight")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RootScreen(navHostController: NavHostController, modifier: Modifier){
val pages = listOf(
"دارو" to Icons.Default.Search,
"آزمایش" to Icons.Default.Build,
"سرچ" to Icons.Rounded.Refresh
)
var seletedTab by remember { mutableStateOf(0) }
val context = LocalContext.current
val activity = context as Activity
Scaffold (
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl){
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text("دستیار سلامت" , textAlign = TextAlign.Right , style = MaterialTheme.typography.titleLarge , fontWeight = FontWeight.W700 )
}
)
}},
bottomBar = {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth()
.height(110.dp).padding(bottom = 2.dp).clip(MaterialTheme.shapes.extraLarge),
tonalElevation = 8.dp, // subtle shadow
windowInsets = WindowInsets.navigationBars
.only(WindowInsetsSides.Bottom)
) {
pages.forEachIndexed { index, item ->
val selected = seletedTab == index
NavigationBarItem(
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onPrimary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
),
selected = selected,
onClick = { seletedTab = index },
icon = {
Icon(
imageVector = item.second,
contentDescription = item.first,
modifier = Modifier.size(16.dp)
)
},
label = {
Text(
item.first,
style = MaterialTheme.typography.labelMedium
)
},
alwaysShowLabel = true
)
}
}
}
) {
padding -> when (seletedTab) {
0 -> HomeScreen( navController = navHostController,Modifier.padding(padding).consumeWindowInsets(padding))
1 -> LabScreen(Modifier.padding(padding))
2 -> SearchScreen(
navHostController,
modifier = Modifier.padding(padding).consumeWindowInsets(padding),
)
}
}
}
@@ -0,0 +1,244 @@
package com.approagency.pharmacy.presentation.screens
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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.navigation.NavController
import com.approagency.pharmacy.domain.model.DrugSearchResult
import com.approagency.pharmacy.navigation.Screen
import com.approagency.pharmacy.presentation.common.CustomTextFilled
import com.approagency.pharmacy.presentation.common.EmptySearchState
import com.approagency.pharmacy.presentation.common.EndOfListIndicator
import com.approagency.pharmacy.presentation.common.ErrorState
import com.approagency.pharmacy.presentation.common.LoadingMoreIndicator
import com.approagency.pharmacy.presentation.common.PrimaryButton
import com.approagency.pharmacy.presentation.components.DaroYabSearchResult
import com.approagency.pharmacy.presentation.components.DrugDetailBottomSheet
import com.approagency.pharmacy.presentation.components.PharmacyBottomSheet
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
import com.approgency.drug.presentation.viewModel.SearchState
import com.approgency.drug.presentation.viewModel.SearchViewModel
import com.vada.caller.ui.theme.LocalDime
import com.vada.caller.ui.theme.dime
import org.koin.androidx.compose.koinViewModel
@Composable
fun SearchScreen(
navController: NavController,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = koinViewModel(),
drugDetailViewModel: DrugDetailViewModel = koinViewModel(),
pharmacyViewModel: PharmacyViewModel = koinViewModel(),
) {
val dime = LocalDime.current
val keyboardController = LocalSoftwareKeyboardController.current
val searchText = viewModel.searchText
val state by viewModel.searchState.collectAsState()
val lazyListState = rememberLazyListState()
var selectedDrugUrl by remember { mutableStateOf<String?>(null) }
var showPharmacyBottomSheet by remember { mutableStateOf(false) }
var selectedDrugForPharmacy by remember { mutableStateOf<DrugSearchResult?>(null) }
// Auto-search for testing (remove in production)
// LaunchedEffect(Unit) {
// if (searchText.isEmpty()) {
// searchText = "انتی"
// viewModel.searchDrugs(searchText)
// }
// }
if (selectedDrugUrl != null) {
DrugDetailBottomSheet(
detailUrl = selectedDrugUrl,
onDismiss = { selectedDrugUrl = null },
viewModel = drugDetailViewModel
)
}
// Detect when user scrolls to the bottom to load more
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { lastVisibleIndex ->
if (lastVisibleIndex != null && state is SearchState.Success) {
val successState = state as SearchState.Success
val totalItems = successState.drugs.size
// Load more when user is 3 items from the end
if (lastVisibleIndex >= totalItems - 3 &&
successState.hasMorePages &&
!successState.isLoadingMore) {
viewModel.loadNextPage()
}
}
}
}
Column(modifier = modifier
.fillMaxSize()
.padding(horizontal = MaterialTheme.dime.md)
.padding(top = MaterialTheme.dime.md)) {
// Search input
CustomTextFilled(
value = searchText,
onValueChange = { viewModel.updateSearchText(it) },
onSearch = { query ->
if (query.isNotBlank()) {
keyboardController?.hide()
viewModel.searchDrugs(searchText)
}
},
placeholder = "جستجوی دارو",
showClearButton = true,
showSearchButton = true,
autoSearch = true,
height = 45
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
PrimaryButton(
text = "جستجو",
height = 40,
isLoading = state is SearchState.Loading,
onClick = {
if (searchText.isNotBlank()) {
viewModel.searchDrugs(searchText)
}
}
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
if (showPharmacyBottomSheet && selectedDrugForPharmacy != null) {
// استخراج genericDrugId از URL
// مثال: /G-799/Fexofenadine -> 799
val genericId = extractGenericIdFromUrl(selectedDrugForPharmacy?.detailPageUrl ?: "")
PharmacyBottomSheet(
viewModel = pharmacyViewModel,
genericDrugId = genericId,
isOpen = showPharmacyBottomSheet,
onDismiss = {
showPharmacyBottomSheet = false
selectedDrugForPharmacy = null
}
)
}
// Results area
when (val currentState = state) {
is SearchState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
Text(
text = "در حال جستجو...",
modifier = Modifier.padding(top = dime.md),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
is SearchState.LoadingMore -> {
// Show existing items with loading indicator at bottom
LazyColumn(state = lazyListState , modifier = modifier.padding(vertical = MaterialTheme.dime.xs)) {
items(currentState.currentItems) { drug ->
DaroYabSearchResult(
drug = drug,
onClickDetail = { selectedDrug ->
navController.navigate(
Screen.DrugDetail.createRoute(
selectedDrug.detailPageUrl
)
)
},
onClickDrugStore = { selectedDrug ->
selectedDrugForPharmacy = selectedDrug
showPharmacyBottomSheet = true
}
)
}
item {
LoadingMoreIndicator()
}
}
}
is SearchState.Success -> {
if (currentState.drugs.isEmpty()) {
EmptySearchState(onRetry = { viewModel.retryLastSearch() })
} else {
LazyColumn(state = lazyListState) {
items(currentState.drugs) { drug ->
DaroYabSearchResult(
drug = drug,
onClickDetail = { selectedDrug ->
navController.navigate(
Screen.DrugDetail.createRoute(
selectedDrug.detailPageUrl
)
)
},
onClickDrugStore = { selectedDrug ->
selectedDrugForPharmacy = selectedDrug
showPharmacyBottomSheet = true
}
)
}
// Show loading indicator at bottom when loading more
if (currentState.isLoadingMore) {
item {
LoadingMoreIndicator()
}
}
// Show end of list indicator
if (!currentState.hasMorePages && currentState.drugs.isNotEmpty()) {
item {
EndOfListIndicator()
}
}
}
}
}
is SearchState.Error -> {
ErrorState(
message = currentState.message,
onRetry = { viewModel.retryLastSearch() }
)
}
SearchState.Idle -> {
EmptySearchState(onRetry = {})
}
}
}
}
// تابع کمکی برای استخراج ID از URL
private fun extractGenericIdFromUrl(url: String): String {
val pattern = "/G-(\\d+)/".toRegex()
return pattern.find(url)?.groupValues?.get(1) ?: ""
}
@@ -0,0 +1,40 @@
package com.approagency.pharmacy.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class DrugDetailViewModel(
private val drugDetailUseCase: DrugDetailYabUseCase
) : ViewModel() {
private val _detailState = MutableStateFlow<DrugDetailYabState>(DrugDetailYabState.Idle)
val detailState: StateFlow<DrugDetailYabState> = _detailState.asStateFlow()
fun loadDrugDetail(detailUrl: String) {
viewModelScope.launch {
_detailState.value = DrugDetailYabState.Loading
val result = drugDetailUseCase(detailUrl)
_detailState.value = when {
result.isSuccess -> DrugDetailYabState.Success(result.getOrNull()!!)
else -> DrugDetailYabState.Error(result.exceptionOrNull()?.message ?: "خطا در دریافت اطلاعات")
}
}
}
fun reset() {
_detailState.value = DrugDetailYabState.Idle
}
}
sealed class DrugDetailYabState {
object Idle : DrugDetailYabState()
object Loading : DrugDetailYabState()
data class Success(val drugDetail: DrugDetail) : DrugDetailYabState()
data class Error(val message: String) : DrugDetailYabState()
}
@@ -0,0 +1,170 @@
package com.approagency.pharmacy.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.usecase.GetDarmanUseCase
import com.approagency.pharmacy.domain.usecase.GetDrugDetailUseCase
import com.approagency.pharmacy.domain.usecase.GetDrugSearchUseCase
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class HomeViewModel (
private val getDrugSearchUseCase: GetDrugSearchUseCase,
private val getDrugDetailUseCase: GetDrugDetailUseCase,
private val getDarmanUseCase: GetDarmanUseCase
): ViewModel() {
private val _uiState = MutableStateFlow(CombineState())
val uiState : StateFlow<CombineState> = _uiState
// Add SharedFlow for one-time events
private val _event = MutableSharedFlow<HomeEvent>()
val event = _event.asSharedFlow()
init {
getDarmani()
}
fun getDarmani() {
_uiState.update {
it.copy(
darmanState = it.darmanState.copy(isLoading = true),
showDarmanList = true
)
}
viewModelScope.launch {
try {
val result = getDarmanUseCase.invoke()
_uiState.update {
it.copy(
darmanState = it.darmanState.copy(
isLoading = false,
getDarmani = result
)
)
}
}catch (e: Exception) {
handleError(e.message)
}catch (e: Exception) {
handleError(e.localizedMessage ?: "Unknown error")
}
}
}
fun searchDrugs(drugSearchParams: DrugSearchParams) {
_uiState.update { it.copy(drugSearchState = it.drugSearchState.copy(
isLoading = true
) , showDarmanList = false) }
viewModelScope.launch {
try {
val result = getDrugSearchUseCase.invoke(drugSearchParams)
println(result)
_uiState.update { it.copy(
drugSearchState = it.drugSearchState.copy(
isLoading = false,
drugsData = result
)
) }
} catch (e: Exception) {
println(e.message)
handleError(e.message)
}catch (e: Exception) {
println(e.message)
handleError(e.localizedMessage ?: "Unknown error")
}
}
}
fun drugDetail(cod: Int , allDrugs:Result<DrugListResponse?>?) {
_uiState.update { it.copy(drugDetailState = it.drugDetailState.copy(
isLoading = true
) , showDarmanList = false) }
viewModelScope.launch {
try {
val result = getDrugDetailUseCase.invoke(cod)
_uiState.update {
it.copy(
drugDetailState = it.drugDetailState.copy(
isLoading = false,
drugDetail = result,
isDetailVisible = true
),
drugSearchState = it.drugSearchState.copy(
isLoading = false,
drugsData = allDrugs
)
)
}
println(result)
} catch (e: Exception) {
handleError(e.message)
}catch (e: Exception) {
handleError(e.localizedMessage ?: "Unknown error")
}
}
}
fun closeDetail() {
_uiState.update {
it.copy(
drugDetailState = it.drugDetailState.copy(
isDetailVisible = false,
drugDetail = null // optional, depends if you want to keep last detail cached
)
)
}
}
private suspend fun handleError(message: String?) {
_uiState.update { it.copy(drugSearchState = it.drugSearchState.copy(
isLoading = false
) , drugDetailState = it.drugDetailState.copy(
isLoading = false
)) }
_event.emit(HomeEvent.ShowError(message ?: "Error occurred"))
}
// Clear error when consumed
fun clearError() {
// _uiState.update { it.copy(error = null) }
}
}
sealed class HomeEvent {
data class ShowError(val message: String) : HomeEvent()
data class ShowDetailDrug(val drugDetail:Result<DrugModels?>?): HomeEvent()
}
data class CombineState(
val drugSearchState: DrugSearchState = DrugSearchState() ,
val drugDetailState: DrugDetailState =DrugDetailState(),
val darmanState: GetDarmaniState = GetDarmaniState(),
val showDarmanList: Boolean = true
)
data class DrugSearchState(
val isLoading: Boolean = false,
val drugsData: Result<DrugListResponse?>? = null,
val drugDetail: Result<DrugModels?>? = null,
val error:String? = null
)
data class DrugDetailState(
val isLoading: Boolean = false,
val drugDetail:Result<DrugModels?>? = null,
val isDetailVisible: Boolean = false,
val error:String? = null
)
data class GetDarmaniState(
val isLoading: Boolean = false,
val getDarmani:Result<DarmanModel?>? = null,
val error:String? = null
)
@@ -0,0 +1,172 @@
package com.approagency.pharmacy.presentation.viewModel
import android.net.http.HttpException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.domain.model.TestItem
import com.approagency.pharmacy.domain.usecase.GetTestGroupUseCase
import com.approagency.pharmacy.domain.usecase.GetTestItemByGroupId
import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class LabViewModel (
private val getTestGroupUseCase: GetTestGroupUseCase,
private val getTestItemByGroupId: GetTestItemByGroupId,
private val searchTestsUseCase: SearchTestsUseCase
) : ViewModel(){
private val _testGroups = MutableStateFlow(TestGroupItem())
val testGroups: StateFlow<TestGroupItem> = _testGroups.asStateFlow()
private val _testItems = MutableStateFlow(TestItemState())
val testItems: StateFlow<TestItemState> = _testItems.asStateFlow()
private val _searchResults = MutableStateFlow(SearchResultState())
val searchResults: StateFlow<SearchResultState> = _searchResults.asStateFlow()
private val searchQuery = MutableStateFlow("")
init {
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(){
_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 clearSearch() {
searchQuery.value = ""
_searchResults.update {
SearchResultState()
}
}
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
)
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,92 @@
package com.approagency.pharmacy.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
import com.approagency.pharmacy.domain.usecase.GetPharmaciesUseCase
import com.approagency.pharmacy.domain.usecase.GetPharmacyDetailUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class PharmacyDetailState {
object Idle : PharmacyDetailState()
object Loading : PharmacyDetailState()
data class Success(val detail: PharmacyDetail) : PharmacyDetailState()
data class Error(val message: String) : PharmacyDetailState()
}
sealed class PharmacyState {
object Idle : PharmacyState()
object Loading : PharmacyState()
data class Success(val items: List<PharmacyItem>) : PharmacyState()
data class Error(val message: String) : PharmacyState()
}
class PharmacyViewModel(
private val getPharmaciesUseCase: GetPharmaciesUseCase,
private val getPharmacyDetailUseCase: GetPharmacyDetailUseCase
) : ViewModel() {
private val _state = MutableStateFlow<PharmacyState>(PharmacyState.Idle)
val state: StateFlow<PharmacyState> = _state.asStateFlow()
// استفاده از Map برای نگهداری State هر داروخانه به صورت جداگانه
private val _pharmacyDetailStates = MutableStateFlow<Map<String, PharmacyDetailState>>(emptyMap())
val pharmacyDetailStates: StateFlow<Map<String, PharmacyDetailState>> = _pharmacyDetailStates.asStateFlow()
private var currentGenericDrugId: String = ""
private var currentProvinceId: String = "0"
fun loadPharmacyDetail(pharmacyUrl: String) {
// اگر در حال حاضر لود می‌شود یا موفقیت آمیز بوده، دوباره درخواست نده
val currentState = _pharmacyDetailStates.value[pharmacyUrl]
if (currentState is PharmacyDetailState.Loading || currentState is PharmacyDetailState.Success) {
return
}
viewModelScope.launch {
// تنظیم Loading برای این داروخانه خاص
_pharmacyDetailStates.value = _pharmacyDetailStates.value.toMutableMap().apply {
put(pharmacyUrl, PharmacyDetailState.Loading)
}
try {
val detail = getPharmacyDetailUseCase(pharmacyUrl)
_pharmacyDetailStates.value = _pharmacyDetailStates.value.toMutableMap().apply {
put(pharmacyUrl, PharmacyDetailState.Success(detail))
}
} catch (e: Exception) {
_pharmacyDetailStates.value = _pharmacyDetailStates.value.toMutableMap().apply {
put(pharmacyUrl, PharmacyDetailState.Error(e.message ?: "خطا در دریافت اطلاعات"))
}
}
}
}
fun search(genericDrugId: String, provinceId: String) {
if (genericDrugId.isBlank()) return
currentGenericDrugId = genericDrugId
currentProvinceId = provinceId
viewModelScope.launch {
_state.value = PharmacyState.Loading
try {
val result = getPharmaciesUseCase(genericDrugId, provinceId)
_state.value = PharmacyState.Success(result)
} catch (e: Exception) {
_state.value = PharmacyState.Error(e.message ?: "خطا در دریافت اطلاعات")
}
}
}
fun retry() {
if (currentGenericDrugId.isNotBlank()) {
search(currentGenericDrugId, currentProvinceId)
}
}
}
@@ -0,0 +1,125 @@
package com.approgency.drug.presentation.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.domain.model.DrugSearchResult
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class SearchViewModel(
private val searchDrugsUseCase: SearchDrugsYabUseCase
) : ViewModel() {
private val _searchState = MutableStateFlow<SearchState>(SearchState.Idle)
val searchState: StateFlow<SearchState> = _searchState.asStateFlow()
var searchText by mutableStateOf("")
private set
fun updateSearchText(value: String) {
searchText = value
}
private var currentQuery = ""
private var currentPage = 1
private var totalPages = 1
private val allDrugs = mutableListOf<DrugSearchResult>()
private var isLoadingMore = false
private var hasMorePages = true
fun searchDrugs(query: String, isNewSearch: Boolean = true) {
if (query.length < 3) {
_searchState.value = SearchState.Error("لطفاً حداقل ۳ حرف وارد کنید")
return
}
if (isNewSearch) {
// Reset everything for new search
currentQuery = query
currentPage = 1
allDrugs.clear()
isLoadingMore = false
hasMorePages = true
_searchState.value = SearchState.Loading(isNew = true)
} else {
// Don't load if already loading or no more pages
if (isLoadingMore || !hasMorePages) return
isLoadingMore = true
_searchState.value = SearchState.LoadingMore(
currentItems = allDrugs.toList(),
currentPage = currentPage
)
}
viewModelScope.launch {
val result = searchDrugsUseCase(currentQuery, currentPage)
_searchState.value = when {
result.isSuccess -> {
val searchResult = result.getOrNull()!!
totalPages = searchResult.totalPages
hasMorePages = currentPage < totalPages
allDrugs.addAll(searchResult.drugs)
isLoadingMore = false
SearchState.Success(
drugs = allDrugs.toList(),
currentPage = searchResult.currentPage,
totalPages = searchResult.totalPages,
isLoadingMore = false,
hasMorePages = hasMorePages
)
}
else -> {
isLoadingMore = false
SearchState.Error(result.exceptionOrNull()?.message ?: "خطا در جستجو")
}
}
}
}
fun loadNextPage() {
if (hasMorePages && !isLoadingMore && currentPage < totalPages) {
currentPage++
searchDrugs(currentQuery, isNewSearch = false)
}
}
fun resetSearch() {
currentQuery = ""
currentPage = 1
totalPages = 1
allDrugs.clear()
isLoadingMore = false
hasMorePages = true
_searchState.value = SearchState.Idle
}
fun retryLastSearch() {
if (currentQuery.isNotEmpty()) {
searchDrugs(currentQuery, isNewSearch = true)
}
}
}
sealed class SearchState {
object Idle : SearchState()
data class Loading(val isNew: Boolean) : SearchState()
data class LoadingMore(
val currentItems: List<DrugSearchResult>,
val currentPage: Int
) : SearchState()
data class Success(
val drugs: List<DrugSearchResult>,
val currentPage: Int,
val totalPages: Int,
val isLoadingMore: Boolean = false,
val hasMorePages: Boolean = true
) : SearchState()
data class Error(val message: String) : SearchState()
}
@@ -0,0 +1,11 @@
package com.approagency.pharmacy.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,20 @@
package com.vada.caller.ui.theme
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class Dime (
val xxs: Dp = 2.dp,
val xs: Dp = 4.dp,
val sm: Dp = 8.dp,
val md: Dp = 12.dp,
val lg: Dp = 16.dp,
val xl: Dp = 20.dp,
val xxl: Dp = 24.dp,
val iconSm: Dp = 20.dp,
val iconMd: Dp = 24.dp,
val iconLg: Dp = 32.dp,
)
@@ -0,0 +1,5 @@
package com.vada.caller.ui.theme
import androidx.compose.runtime.staticCompositionLocalOf
val LocalDime = staticCompositionLocalOf { Dime() }
@@ -0,0 +1,8 @@
package com.vada.caller.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
val MaterialTheme.dime: Dime
@Composable
get() =LocalDime.current
@@ -0,0 +1,12 @@
package com.fabirt.podcastapp.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(20.dp)
)
@@ -0,0 +1,112 @@
package com.approagency.pharmacy.ui.theme
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.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.fabirt.podcastapp.ui.theme.Shapes
import com.vada.caller.ui.theme.Dime
import com.vada.caller.ui.theme.LocalDime
object MedicalColors {
val Primary = Color(0xFF0F766E)
val PrimaryContainer = Color(0xFFD7F3EF)
val Secondary = Color(0xFF5B8E8A)
val SecondaryContainer = Color(0xFFE3F3F1)
val Tertiary = Color(0xFF8FBFB8)
val Background = Color(0xFFF8FCFB)
val Surface = Color(0xFFFFFFFF)
val DarkBackground = Color(0xFF0F1720)
val DarkSurface = Color(0xFF17232B)
val OnDark = Color(0xFFF5FAF9)
val OnLight = Color(0xFF102A2A)
val Error = Color(0xFFD64545)
val Outline = Color(0xFFD0E2DF)
}
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD4C3),
onPrimary = Color(0xFF003733),
secondary = Color(0xFF8CC9C1),
onSecondary = Color(0xFF0D2B29),
tertiary = Color(0xFFA7DCD4),
onTertiary = Color(0xFF102A2A),
background = MedicalColors.DarkBackground,
onBackground = MedicalColors.OnDark,
surface = MedicalColors.DarkSurface,
onSurface = MedicalColors.OnDark,
primaryContainer = Color(0xFF124A45),
secondaryContainer = Color(0xFF1D3D3A),
error = Color(0xFFFF8A80)
)
private val LightColorScheme = lightColorScheme(
primary = MedicalColors.Primary,
onPrimary = Color.White,
secondary = MedicalColors.Secondary,
onSecondary = Color.White,
tertiary = MedicalColors.Tertiary,
onTertiary = MedicalColors.OnLight,
background = MedicalColors.Background,
onBackground = MedicalColors.OnLight,
surface = MedicalColors.Surface,
onSurface = MedicalColors.OnLight,
primaryContainer = MedicalColors.PrimaryContainer,
secondaryContainer = MedicalColors.SecondaryContainer,
outline = MedicalColors.Outline,
error = MedicalColors.Error
)
@Composable
fun DrugTheme(
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
}
CompositionLocalProvider(
LocalDime provides Dime()
) {
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content,
shapes = Shapes,
)
}
}
@@ -0,0 +1,118 @@
package com.approagency.pharmacy.ui.theme
import com.approagency.pharmacy.R
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Dana = FontFamily(
Font(R.font.dana_regular),
Font(R.font.dana_regular, weight = FontWeight.Light),
Font(R.font.dana_medium, weight = FontWeight.Medium),
Font(R.font.dana_bold, weight = FontWeight.Bold),
)
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
)
val AppTypography = Typography(
displayLarge = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp
),
displaySmall = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Bold,
fontSize = 36.sp,
lineHeight = 44.sp
),
headlineLarge = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp
),
titleSmall = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodyLarge = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
bodySmall = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.Light,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelLarge = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelMedium = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp
),
labelSmall = TextStyle(
fontFamily = Dana,
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp,
lineHeight = 16.sp
)
)
@@ -0,0 +1,6 @@
package com.approagency.pharmacy.utils
object Config {
const val BASE_URL = "https://drug.approagency.ir/api/"
const val Darro_Url = "https://www.darooyab.ir/"
}
@@ -0,0 +1,42 @@
package com.approagency.pharmacy.utils
val provinces = listOf(
Province("0", "همه استان ها"),
Province("1", "اصفهان"),
Province("40", "تهران"),
Province("137", "آذربایجان شرقی"),
Province("138", "آذربایجان غربی"),
Province("139", "اردبیل"),
Province("140", "البرز"),
Province("141", "ایلام"),
Province("142", "بوشهر"),
Province("143", "چهارمحال و بختیاری"),
Province("144", "خراسان جنوبی"),
Province("145", "خراسان رضوی"),
Province("146", "خراسان شمالی"),
Province("147", "خوزستان"),
Province("148", "زنجان"),
Province("149", "سمنان"),
Province("150", "سیستان و بلوچستان"),
Province("151", "فارس"),
Province("152", "قزوین"),
Province("153", "قم"),
Province("154", "کردستان"),
Province("155", "کرمان"),
Province("156", "کرمانشاه"),
Province("157", "کهگیلویه و بویراحمد"),
Province("158", "گلستان"),
Province("159", "گیلان"),
Province("160", "لرستان"),
Province("161", "مازندران"),
Province("162", "مرکزی"),
Province("163", "هرمزگان"),
Province("164", "همدان"),
Province("165", "یزد")
)
data class Province(
val id: String,
val name: String
)
@@ -0,0 +1,29 @@
package com.approagency.pharmacy.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)
}
}