From 4480b26e07f963f2199056d6ef9d79c3d09dc33b Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 5 Jun 2026 10:49:32 +0330 Subject: [PATCH] feat: add pharmacy detail --- .../drug/data/remote/DarooyabApiService.kt | 12 +- .../drug/data/remote/PharmacyHtmlParser.kt | 67 ++++++- .../data/repository/DrugRepositoryImpl.kt | 12 ++ .../java/com/approagency/drug/di/AppModule.kt | 7 +- .../drug/domain/model/PharmacyModels.kt | 12 +- .../drug/domain/repository/DrugRepository.kt | 3 + .../usecase/GetPharmacyDetailUseCase.kt | 12 ++ .../components/PharmacyBottomSheet.kt | 182 ++++++++++++++---- .../viewModel/PharmacyViewModel.kt | 44 ++++- 9 files changed, 308 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/approagency/drug/domain/usecase/GetPharmacyDetailUseCase.kt diff --git a/app/src/main/java/com/approagency/drug/data/remote/DarooyabApiService.kt b/app/src/main/java/com/approagency/drug/data/remote/DarooyabApiService.kt index fcb595a..892ea91 100644 --- a/app/src/main/java/com/approagency/drug/data/remote/DarooyabApiService.kt +++ b/app/src/main/java/com/approagency/drug/data/remote/DarooyabApiService.kt @@ -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( diff --git a/app/src/main/java/com/approagency/drug/data/remote/PharmacyHtmlParser.kt b/app/src/main/java/com/approagency/drug/data/remote/PharmacyHtmlParser.kt index 00db616..906146a 100644 --- a/app/src/main/java/com/approagency/drug/data/remote/PharmacyHtmlParser.kt +++ b/app/src/main/java/com/approagency/drug/data/remote/PharmacyHtmlParser.kt @@ -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 { val items = mutableListOf() 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 + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/data/repository/DrugRepositoryImpl.kt b/app/src/main/java/com/approagency/drug/data/repository/DrugRepositoryImpl.kt index baaaa15..a6866fa 100644 --- a/app/src/main/java/com/approagency/drug/data/repository/DrugRepositoryImpl.kt +++ b/app/src/main/java/com/approagency/drug/data/repository/DrugRepositoryImpl.kt @@ -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("", "", "") + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/di/AppModule.kt b/app/src/main/java/com/approagency/drug/di/AppModule.kt index c46774d..70b36b8 100644 --- a/app/src/main/java/com/approagency/drug/di/AppModule.kt +++ b/app/src/main/java/com/approagency/drug/di/AppModule.kt @@ -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 { diff --git a/app/src/main/java/com/approagency/drug/domain/model/PharmacyModels.kt b/app/src/main/java/com/approagency/drug/domain/model/PharmacyModels.kt index c59984d..5c87107 100644 --- a/app/src/main/java/com/approagency/drug/domain/model/PharmacyModels.kt +++ b/app/src/main/java/com/approagency/drug/domain/model/PharmacyModels.kt @@ -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 ) \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/domain/repository/DrugRepository.kt b/app/src/main/java/com/approagency/drug/domain/repository/DrugRepository.kt index 73331ed..16092a3 100644 --- a/app/src/main/java/com/approagency/drug/domain/repository/DrugRepository.kt +++ b/app/src/main/java/com/approagency/drug/domain/repository/DrugRepository.kt @@ -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 suspend fun searchPharmacies(genericDrugId: String,provId: String): List + suspend fun getPharmacyDetail(pharmacyUrl: String): PharmacyDetail + } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/domain/usecase/GetPharmacyDetailUseCase.kt b/app/src/main/java/com/approagency/drug/domain/usecase/GetPharmacyDetailUseCase.kt new file mode 100644 index 0000000..70b7028 --- /dev/null +++ b/app/src/main/java/com/approagency/drug/domain/usecase/GetPharmacyDetailUseCase.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/components/PharmacyBottomSheet.kt b/app/src/main/java/com/approagency/drug/presentation/components/PharmacyBottomSheet.kt index 2b3eed8..14852c5 100644 --- a/app/src/main/java/com/approagency/drug/presentation/components/PharmacyBottomSheet.kt +++ b/app/src/main/java/com/approagency/drug/presentation/components/PharmacyBottomSheet.kt @@ -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) ) diff --git a/app/src/main/java/com/approagency/drug/presentation/viewModel/PharmacyViewModel.kt b/app/src/main/java/com/approagency/drug/presentation/viewModel/PharmacyViewModel.kt index cb0cd82..4eeb759 100644 --- a/app/src/main/java/com/approagency/drug/presentation/viewModel/PharmacyViewModel.kt +++ b/app/src/main/java/com/approagency/drug/presentation/viewModel/PharmacyViewModel.kt @@ -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.Idle) val state: StateFlow = _state.asStateFlow() + + // استفاده از Map برای نگهداری State هر داروخانه به صورت جداگانه + private val _pharmacyDetailStates = MutableStateFlow>(emptyMap()) + val pharmacyDetailStates: StateFlow> = _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