feat: add pharmacy detail

This commit is contained in:
2026-06-05 10:49:32 +03:30
parent 43427ae420
commit 4480b26e07
9 changed files with 308 additions and 43 deletions
@@ -7,6 +7,7 @@ import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
interface DarooyabApiService {
@FormUrlEncoded
@@ -33,7 +34,16 @@ interface DarooyabApiService {
@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(
@@ -1,10 +1,14 @@
// data/remote/PharmacyHtmlParser.kt
package com.approagency.drug.data.remote
import com.approagency.drug.domain.model.PharmacyDetail
import com.approagency.drug.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)
@@ -28,7 +32,7 @@ object PharmacyHtmlParser {
val brandCell = cells[brandIndex]
val brandLink = brandCell.select("a").first()
val brandName = brandLink?.text()?.trim() ?: ""
val brandUrl = brandLink?.attr("href")?.trim() ?: ""
val brandUrl = normalizeUrl(brandLink?.attr("href")?.trim() ?: "")
val brandId = extractIdFromUrl(brandUrl, "/B-(\\d+)/?")
// ستون داروخانه
@@ -40,7 +44,10 @@ object PharmacyHtmlParser {
!it.text().contains("اطلاعات تماس")
}
val pharmacyName = pharmacyLink?.text()?.trim() ?: ""
val pharmacyUrl = pharmacyLink?.attr("href")?.trim() ?: ""
var pharmacyUrl = pharmacyLink?.attr("href")?.trim() ?: ""
// نرمال کردن URL داروخانه
pharmacyUrl = normalizeUrl(pharmacyUrl)
val pharmacyId = extractIdFromUrl(pharmacyUrl, "/ph-(\\d+)/?")
// ستون موقعیت
@@ -70,6 +77,37 @@ object PharmacyHtmlParser {
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"
}
}
/**
* تشخیص وجود ستون تضمین موجودی با بررسی سربرگ جدول
*/
@@ -100,4 +138,29 @@ object PharmacyHtmlParser {
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
)
}
}
@@ -13,6 +13,7 @@ import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugDetail
import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.domain.model.PharmacyDetail
import com.approagency.drug.domain.model.PharmacyItem
import com.approagency.drug.domain.repository.DrugRepository
import kotlinx.coroutines.Dispatchers
@@ -110,4 +111,15 @@ class DrugRepositoryImpl(
}
}
}
override suspend fun getPharmacyDetail(pharmacyUrl: String): PharmacyDetail {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getPharmacyDetail(pharmacyUrl)
PharmacyHtmlParser.parsePharmacyDetail(html)
} catch (e: Exception) {
PharmacyDetail("", "", "")
}
}
}
}
@@ -15,6 +15,7 @@ import com.approagency.drug.domain.usecase.GetDarmanUseCase
import com.approagency.drug.domain.usecase.GetDrugDetailUseCase
import com.approagency.drug.domain.usecase.GetDrugSearchUseCase
import com.approagency.drug.domain.usecase.GetPharmaciesUseCase
import com.approagency.drug.domain.usecase.GetPharmacyDetailUseCase
import com.approagency.drug.domain.usecase.GetTestGroupUseCase
import com.approagency.drug.domain.usecase.GetTestItemByGroupId
import com.approagency.drug.domain.usecase.SearchDrugsYabUseCase
@@ -135,6 +136,10 @@ val appModule= module {
DrugDetailYabUseCase(get())
}
single {
GetPharmacyDetailUseCase(get())
}
viewModel {
DrugDetailViewModel(get())
}
@@ -149,7 +154,7 @@ val appModule= module {
SearchViewModel(get())
}
viewModel {
PharmacyViewModel(get())
PharmacyViewModel(get() , get ())
}
viewModel {
@@ -17,5 +17,15 @@ data class PharmacyItem(
val pharmacyId: String, // ID داروخانه
val province: String, // نام استان
val city: 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
)
@@ -8,6 +8,7 @@ import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugDetail
import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.domain.model.PharmacyDetail
import com.approagency.drug.domain.model.PharmacyItem
interface DrugRepository {
@@ -18,4 +19,6 @@ interface DrugRepository {
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.drug.domain.usecase
import com.approagency.drug.domain.model.PharmacyDetail
import com.approagency.drug.domain.repository.DrugRepository
class GetPharmacyDetailUseCase(
private val repository: DrugRepository
) {
suspend operator fun invoke(pharmacyUrl: String): PharmacyDetail {
return repository.getPharmacyDetail(pharmacyUrl)
}
}
@@ -3,8 +3,12 @@ package com.approagency.drug.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
@@ -14,10 +18,12 @@ 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.drug.domain.model.PharmacyItem
import com.approagency.drug.presentation.common.CustomBox
import com.approagency.drug.presentation.common.CustomModalBottomSheet
import com.approagency.drug.presentation.common.Loading
import com.approagency.drug.presentation.viewModel.PharmacyDetailState
import com.approagency.drug.presentation.viewModel.PharmacyState
import com.approagency.drug.presentation.viewModel.PharmacyViewModel
import com.approagency.drug.utils.provinces
@@ -29,16 +35,11 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PharmacyBottomSheet(
viewModel: PharmacyViewModel,
genericDrugId: String,
isOpen: Boolean,
onDismiss: () -> Unit
viewModel: PharmacyViewModel, genericDrugId: String, isOpen: Boolean, onDismiss: () -> Unit
) {
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = false,
confirmValueChange = { true }
)
skipPartiallyExpanded = false, confirmValueChange = { true })
var selectedProvinceId by remember { mutableStateOf("40") } // پیش‌فرض تهران
@@ -55,14 +56,14 @@ fun PharmacyBottomSheet(
if (isOpen) {
CustomModalBottomSheet(
sheetState = sheetState,
onDismiss = onDismiss,
scope = scope
sheetState = sheetState, onDismiss = onDismiss, scope = scope
) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
LazyColumn(
modifier = Modifier.fillMaxHeight()
.fillMaxWidth().heightIn(min = 400.dp)
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.heightIn(min = 400.dp)
.padding(MaterialTheme.dime.md),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.xl)
) {
@@ -78,11 +79,10 @@ fun PharmacyBottomSheet(
item {
ExposedDropdownMenuBox(
expanded = showProvinceDropdown,
onExpandedChange = { showProvinceDropdown = it }
) {
onExpandedChange = { showProvinceDropdown = it }) {
OutlinedTextField(
value = provinces.find { it.id == selectedProvinceId }?.name
?: "انتخاب استان",
?: "انتخاب استان",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showProvinceDropdown) },
@@ -94,18 +94,14 @@ fun PharmacyBottomSheet(
ExposedDropdownMenu(
expanded = showProvinceDropdown,
onDismissRequest = { showProvinceDropdown = false }
) {
onDismissRequest = { showProvinceDropdown = false }) {
provinces.forEach { province ->
DropdownMenuItem(
text = { Text(province.name) },
onClick = {
selectedProvinceId = province.id
showProvinceDropdown = false
// جستجو با استان جدید
viewModel.search(genericDrugId, province.id)
}
)
DropdownMenuItem(text = { Text(province.name) }, onClick = {
selectedProvinceId = province.id
showProvinceDropdown = false
// جستجو با استان جدید
viewModel.search(genericDrugId, province.id)
})
}
}
}
@@ -113,8 +109,6 @@ fun PharmacyBottomSheet(
// انتخاب استان
// نمایش نتایج
when (val state = pharmacyState) {
PharmacyState.Loading -> {
@@ -156,9 +150,8 @@ fun PharmacyBottomSheet(
}
} else {
items(state.items)
{ pharmacy ->
PharmacyResultItem(pharmacy = pharmacy)
items(state.items) { pharmacy ->
PharmacyResultItem(pharmacy = pharmacy, viewModel)
}
item {
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
@@ -199,8 +192,7 @@ fun PharmacyBottomSheet(
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = { viewModel.retry() }
) {
onClick = { viewModel.retry() }) {
Text("تلاش مجدد")
}
}
@@ -219,11 +211,30 @@ fun PharmacyBottomSheet(
@Composable
fun PharmacyResultItem(
pharmacy: PharmacyItem,
modifier: Modifier = Modifier
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(),
) {
@@ -248,8 +259,107 @@ fun PharmacyResultItem(
)
// موقعیت
// موقعیت و دکمه اطلاعات تماس
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) {
Divider(modifier = Modifier.padding(vertical = 4.dp))
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)
) {
// آدرس
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
)
}
}
// هشدار (اطلاعات از HTML)
Text(
text = "⚠️ دریافت دارو فقط با نسخه کامل و بصورت حضوری در محل داروخانه امکان پذیر است",
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 4.dp)
)
}
}
is PharmacyDetailState.Error -> {
Text(
text = "خطا در دریافت اطلاعات تماس",
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.error
)
}
else -> {}
}
}
Text(
text = "${pharmacy.province} - ${pharmacy.city}",
text = "${pharmacy.pharmacyUrl}",
fontSize = MaterialTheme.typography.bodySmall.fontSize,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -3,13 +3,20 @@ package com.approagency.drug.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.drug.domain.model.PharmacyDetail
import com.approagency.drug.domain.model.PharmacyItem
import com.approagency.drug.domain.usecase.GetPharmaciesUseCase
import com.approagency.drug.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()
@@ -18,15 +25,48 @@ sealed class PharmacyState {
}
class PharmacyViewModel(
private val getPharmaciesUseCase: GetPharmaciesUseCase
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