feat: crawl data

This commit is contained in:
2026-05-26 02:04:13 +03:30
parent 1e66246020
commit ee20eca3e0
20 changed files with 835 additions and 75 deletions
+1
View File
@@ -92,4 +92,5 @@ dependencies {
//crawl //crawl
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(libs.retrofit.scalars)
} }
@@ -1,17 +1,23 @@
package com.approagency.drug.data.remote package com.approagency.drug.data.remote
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Query import retrofit2.http.Query
interface DarooyabApiService { interface DarooyabApiService {
@GET("Search") @FormUrlEncoded
@POST("Home/PartialNewSearch")
@Headers( @Headers(
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "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",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "X-Requested-With: XMLHttpRequest",
"Accept-Language: en-US,en;q=0.5" "Accept: */*",
"Accept-Language: en-US,en;q=0.9,fa;q=0.8"
) )
suspend fun searchDrugs( suspend fun searchDrugs(
@Query("SearchText") searchText: String @Field("autocomplete") searchText: String,
): String // Returns raw HTML for the first page @Field("DrugName_pageNumber") pageNumber: Int = 1
): String
} }
@@ -1,58 +1,102 @@
package com.approagency.drug.data.remote package com.approagency.drug.data.remote
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchResult import com.approagency.drug.domain.model.DrugSearchResult
import okio.IOException
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
class DrugHtmlParser { class DrugHtmlParser {
fun parseSearchResults(html: String): List<DrugSearchResult> { fun parseSearchResultsWithPagination(html: String): DaroYabSearchResult {
val document: Document = Jsoup.parse(html) val document: Document = Jsoup.parse(html)
val results = mutableListOf<DrugSearchResult>() val drugs = parseDrugRows(document)
val paginationInfo = extractPaginationInfo(document)
// Select the table body that contains the drug rows 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") val rows = document.select("#tbody_DrugList tr")
println("Found ${rows.size} rows in the response")
if (rows.isEmpty()) { if (rows.isEmpty()) {
// Handle case where no results are found println("No rows found. HTML snippet: ${document.html().take(500)}")
return emptyList() return emptyList()
} }
for (row in rows) { for (row in rows) {
try { try {
// --- Extract Persian Name and Detail Page URL --- val nameCell = row.selectFirst("td:eq(0)")
val nameCell = row.selectFirst("td:eq(0)") // First td
val drugLink = nameCell?.selectFirst("a.ahref_Generic") val drugLink = nameCell?.selectFirst("a.ahref_Generic")
val persianName = drugLink?.text()?.trim() ?: continue // Skip if no name found val persianName = drugLink?.text()?.trim() ?: continue
val detailPageRelativeUrl = drugLink?.attr("href") ?: continue val detailPageRelativeUrl = drugLink?.attr("href") ?: continue
val detailPageUrl = "https://www.darooyab.ir$detailPageRelativeUrl" val detailPageUrl = "https://www.darooyab.ir$detailPageRelativeUrl"
// --- Extract Generic ID from URL ---
// URL format: /G-2556/Casanthranol or /G-2556/
val genericIdRegex = "/G-(\\d+)/?".toRegex() val genericIdRegex = "/G-(\\d+)/?".toRegex()
val genericId = genericIdRegex.find(detailPageRelativeUrl)?.groupValues?.get(1) ?: "" val genericId = genericIdRegex.find(detailPageRelativeUrl)?.groupValues?.get(1) ?: ""
// --- Extract English Name --- val brandIdRegex = "/B-(\\d+)/?".toRegex()
// The last cell contains the English name link val brandId = brandIdRegex.find(detailPageRelativeUrl)?.groupValues?.get(1) ?: ""
val lastCell = row.select("td").last() val lastCell = row.select("td").last()
val englishLink = lastCell?.selectFirst("a.ahref_Generic") val englishLink = lastCell?.selectFirst("a.ahref_Generic")
val englishName = englishLink?.text()?.trim() val englishName = englishLink?.text()?.trim()
val drugResult = DrugSearchResult( val drugResult = DrugSearchResult(
genericId = genericId, genericId = if (genericId.isNotEmpty()) genericId else brandId,
persianName = persianName, persianName = persianName,
englishName = englishName, englishName = englishName,
detailPageUrl = detailPageUrl detailPageUrl = detailPageUrl
) )
results.add(drugResult) results.add(drugResult)
println("Parsed: $persianName -> $englishName")
} catch (e: Exception) { } catch (e: Exception) {
// Log error for a specific row but continue with others
println("Error parsing row: ${e.message}") println("Error parsing row: ${e.message}")
} }
} }
return results 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,20 @@
package com.approagency.drug.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
}
}
}
@@ -6,6 +6,8 @@ import com.approagency.drug.data.dto.DrugModels
import com.approagency.drug.data.remote.DarooyabApiService import com.approagency.drug.data.remote.DarooyabApiService
import com.approagency.drug.data.remote.DrugApiService import com.approagency.drug.data.remote.DrugApiService
import com.approagency.drug.data.remote.DrugHtmlParser import com.approagency.drug.data.remote.DrugHtmlParser
import com.approagency.drug.domain.model.DaroYabParams
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchParams import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.domain.repository.DrugRepository import com.approagency.drug.domain.repository.DrugRepository
@@ -30,8 +32,10 @@ class DrugRepositoryImpl(
override suspend fun drugDetail(cod: Int): Result<DrugModels> { override suspend fun drugDetail(cod: Int): Result<DrugModels> {
return try { return try {
val response= apiService.getDrugDetail(cod = cod) val response= apiService.getDrugDetail(cod = cod)
println(response.message)
return Result.success(response) return Result.success(response)
}catch (e: Exception){ }catch (e: Exception){
println(e.message)
Result.failure(e) Result.failure(e)
} }
} }
@@ -45,25 +49,26 @@ class DrugRepositoryImpl(
} }
} }
override suspend fun searchDrugs(params: DrugSearchParams): Result<List<DrugSearchResult>> { override suspend fun searchDrugs(params: DaroYabParams): Result<DaroYabSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
// 1. Fetch raw HTML from the website val htmlResponse = darooyabApiService.searchDrugs(
val htmlResponse = darooyabApiService.searchDrugs(params.query!!) searchText = params.query ?: "",
pageNumber = params.pageNumber
)
// 2. Parse the HTML to extract drug data println("HTML Response length: ${htmlResponse.length}")
val drugList = parser.parseSearchResults(htmlResponse)
if (drugList.isNotEmpty()) { val searchResult = parser.parseSearchResultsWithPagination(htmlResponse)
Result.success(drugList)
if (searchResult.drugs.isNotEmpty()) {
Result.success(searchResult)
} else { } else {
Result.failure(Exception("No drugs found for query: '${params.query}'")) Result.failure(Exception("No drugs found for query: '${params.query}'"))
} }
} catch (e: IOException) { } catch (e: IOException) {
// Network error
Result.failure(Exception("Network error: ${e.message}", e)) Result.failure(Exception("Network error: ${e.message}", e))
} catch (e: Exception) { } catch (e: Exception) {
// Parsing or other error
Result.failure(Exception("An error occurred: ${e.message}", e)) Result.failure(Exception("An error occurred: ${e.message}", e))
} }
} }
@@ -19,6 +19,7 @@ import com.approagency.drug.domain.usecase.SearchTestsUseCase
import com.approagency.drug.presentation.viewModel.HomeViewModel import com.approagency.drug.presentation.viewModel.HomeViewModel
import com.approagency.drug.presentation.viewModel.LabViewModel import com.approagency.drug.presentation.viewModel.LabViewModel
import com.approagency.drug.utils.Config import com.approagency.drug.utils.Config
import com.approgency.drug.presentation.viewModel.SearchViewModel
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
@@ -26,6 +27,9 @@ import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.net.CookieManager
import java.net.CookiePolicy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
val appModule= module { val appModule= module {
@@ -35,17 +39,23 @@ val appModule= module {
single { get<LabDatabase>().testGroupDao() } single { get<LabDatabase>().testGroupDao() }
single { get<LabDatabase>().testItemDao() } single { get<LabDatabase>().testItemDao() }
// Shared OkHttpClient for both APIs
single { single {
val cookieManager = CookieManager()
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL)
OkHttpClient.Builder() OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply { .addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC level = HttpLoggingInterceptor.Level.BODY // Change to BODY for debugging
}) })
// Add cookie support
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.build() .build()
} }
// ========== Retrofit for YOUR API ========== // ========== Retrofit for YOUR JSON API ==========
single { single {
Retrofit.Builder() Retrofit.Builder()
.baseUrl(Config.BASE_URL) .baseUrl(Config.BASE_URL)
@@ -55,19 +65,24 @@ val appModule= module {
} }
single<DrugApiService> { single<DrugApiService> {
get<Retrofit>().create(DrugApiService::class.java) val retrofit: Retrofit = get()
retrofit.create(DrugApiService::class.java)
} }
// ========== Retrofit for DAROOYAB Website API ========== // ========== Retrofit for DAROOYAB Website (HTML response) ==========
// IMPORTANT: Use ScalarsConverterFactory for plain text/HTML, NOT Gson!
single { single {
Retrofit.Builder() Retrofit.Builder()
.baseUrl(Config.Darro_Url) // Darooyab base URL .baseUrl(Config.Darro_Url)
.client(get<OkHttpClient>()) .client(get<OkHttpClient>())
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(ScalarsConverterFactory.create()) // This handles String responses
.build() .build()
.let { retrofit ->
retrofit.create(DarooyabApiService::class.java)
} }
// Create the API service from the Scalars-based Retrofit instance
single<DarooyabApiService> {
val retrofit: Retrofit = get() // Gets the Scalars Retrofit instance
retrofit.create(DarooyabApiService::class.java)
} }
@@ -114,6 +129,10 @@ val appModule= module {
HomeViewModel(get() , get() , get()) HomeViewModel(get() , get() , get())
} }
viewModel {
SearchViewModel(get())
}
viewModel { viewModel {
LabViewModel(get() , get() , get()) LabViewModel(get() , get() , get())
} }
@@ -0,0 +1,6 @@
package com.approagency.drug.domain.model
data class DaroYabParams(
val query: String? = null,
val pageNumber: Int = 1,
)
@@ -0,0 +1,9 @@
package com.approagency.drug.domain.model
data class DaroYabSearchResult(
val drugs: List<DrugSearchResult>,
val currentPage: Int,
val totalPages: Int,
val hasNextPage: Boolean,
val hasPreviousPage: Boolean
)
@@ -6,4 +6,5 @@ data class DrugSearchParams(
val withRelations: Boolean = true, val withRelations: Boolean = true,
val drugGroup: Int? = null, val drugGroup: Int? = null,
val healGroup: Int? = null, val healGroup: Int? = null,
) )
@@ -3,6 +3,8 @@ package com.approagency.drug.domain.repository
import com.approagency.drug.data.dto.DarmanModel import com.approagency.drug.data.dto.DarmanModel
import com.approagency.drug.data.dto.DrugListResponse import com.approagency.drug.data.dto.DrugListResponse
import com.approagency.drug.data.dto.DrugModels import com.approagency.drug.data.dto.DrugModels
import com.approagency.drug.domain.model.DaroYabParams
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchParams import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult import com.approagency.drug.domain.model.DrugSearchResult
@@ -10,5 +12,5 @@ interface DrugRepository {
suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse> suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse>
suspend fun drugDetail(cod:Int): Result<DrugModels> suspend fun drugDetail(cod:Int): Result<DrugModels>
suspend fun getGorohDaroei(): Result<DarmanModel> suspend fun getGorohDaroei(): Result<DarmanModel>
suspend fun searchDrugs(params:DrugSearchParams):Result<List<DrugSearchResult>> suspend fun searchDrugs(params:DaroYabParams):Result<DaroYabSearchResult>
} }
@@ -1,6 +1,8 @@
package com.approagency.drug.domain.usecase package com.approagency.drug.domain.usecase
import com.approagency.drug.data.repository.DrugRepositoryImpl import com.approagency.drug.data.repository.DrugRepositoryImpl
import com.approagency.drug.domain.model.DaroYabParams
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchParams import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.domain.repository.DrugRepository import com.approagency.drug.domain.repository.DrugRepository
@@ -8,8 +10,8 @@ import com.approagency.drug.domain.repository.DrugRepository
class SearchDrugsYabUseCase ( class SearchDrugsYabUseCase (
private val repository: DrugRepository private val repository: DrugRepository
) { ) {
suspend operator fun invoke(query: String): Result<List<DrugSearchResult>> { suspend operator fun invoke(query: String, pageNumber: Int = 1): Result<DaroYabSearchResult> {
val params = DrugSearchParams(query = query) val params = DaroYabParams(query = query, pageNumber = pageNumber)
return repository.searchDrugs(params) return repository.searchDrugs(params)
} }
} }
@@ -0,0 +1,78 @@
package com.approagency.drug.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.dp
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.drug.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,61 @@
package com.approagency.drug.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchResult
import com.vada.caller.ui.theme.LocalDime
@Composable
fun DaroYabSearchResult(
drug: DrugSearchResult,
onClick: (DrugSearchResult) -> Unit,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Card(
modifier = modifier
.fillMaxWidth()
.padding(vertical = dime.xs)
.clickable { onClick(drug) },
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(dime.lg)
) {
Text(
text = drug.persianName,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
)
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
)
}
}
}
@@ -0,0 +1,141 @@
package com.approagency.drug.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
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -54,7 +55,8 @@ import androidx.navigation.NavHostController
fun RootScreen(navHostController: NavHostController, modifier: Modifier){ fun RootScreen(navHostController: NavHostController, modifier: Modifier){
val pages = listOf( val pages = listOf(
"دارو" to Icons.Default.Search, "دارو" to Icons.Default.Search,
"آزمایش" to Icons.Default.Build "آزمایش" to Icons.Default.Build,
"سرچ" to Icons.Rounded.Refresh
) )
var seletedTab by remember { mutableStateOf(0) } var seletedTab by remember { mutableStateOf(0) }
val context = LocalContext.current val context = LocalContext.current
@@ -121,6 +123,7 @@ fun RootScreen(navHostController: NavHostController, modifier: Modifier){
padding -> when (seletedTab) { padding -> when (seletedTab) {
0 -> HomeScreen( navController = navHostController,Modifier.padding(padding).consumeWindowInsets(padding)) 0 -> HomeScreen( navController = navHostController,Modifier.padding(padding).consumeWindowInsets(padding))
1 -> LabScreen(Modifier.padding(padding)) 1 -> LabScreen(Modifier.padding(padding))
2 -> SearchScreen(navHostController,modifier = Modifier.padding(padding).consumeWindowInsets(padding))
} }
} }
} }
@@ -0,0 +1,200 @@
package com.approagency.drug.presentation.screens
import androidx.compose.foundation.clickable
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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.presentation.common.CustomTextFilled
import com.approagency.drug.presentation.common.EmptySearchState
import com.approagency.drug.presentation.common.EndOfListIndicator
import com.approagency.drug.presentation.common.ErrorState
import com.approagency.drug.presentation.common.LoadingMoreIndicator
import com.approagency.drug.presentation.common.PrimaryButton
import com.approagency.drug.presentation.components.DaroYabSearchResult
import com.approagency.drug.presentation.components.PaginationControls
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 kotlinx.coroutines.flow.collect
import org.koin.androidx.compose.koinViewModel
@Composable
fun SearchScreen(
navController: NavController,
modifier: Modifier = Modifier,
viewModel: SearchViewModel = koinViewModel()
) {
val dime = LocalDime.current
val keyboardController = LocalSoftwareKeyboardController.current
var searchText by remember { mutableStateOf("") }
val state by viewModel.searchState.collectAsState()
val lazyListState = rememberLazyListState()
// Auto-search for testing (remove in production)
LaunchedEffect(Unit) {
if (searchText.isEmpty()) {
searchText = "انتی"
viewModel.searchDrugs(searchText)
}
}
// 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(dime.lg)) {
// Search input
CustomTextFilled(
value = searchText,
onValueChange = { searchText = it },
onSearch = { query ->
if (query.isNotBlank()) {
keyboardController?.hide()
viewModel.searchDrugs(searchText)
}
},
placeholder = "جستجوی دارو",
showClearButton = true,
showSearchButton = true,
autoSearch = true, // Set to true if you want search while typing
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)
}
}
)
// Results area
when (val currentState = state) {
is SearchState.Loading -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.material3.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) {
items(currentState.currentItems) { drug ->
DaroYabSearchResult(
drug = drug,
onClick = { selectedDrug ->
// Navigate to drug detail
// navController.navigate("drug_detail/${selectedDrug.genericId}")
}
)
}
item {
LoadingMoreIndicator()
}
}
}
is SearchState.Success -> {
if (currentState.drugs.isEmpty()) {
EmptySearchState(onRetry = { viewModel.retryLastSearch() })
} else {
LazyColumn(state = lazyListState) {
items(currentState.drugs) { drug ->
DaroYabSearchResult(
drug = drug,
onClick = { selectedDrug ->
// Navigate to drug detail
// navController.navigate("drug_detail/${selectedDrug.genericId}")
}
)
}
// 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 = {})
}
}
}
}
@@ -42,16 +42,8 @@ class HomeViewModel (
) )
} }
viewModelScope.launch { viewModelScope.launch {
retryWithBackoff( try {
maxRetries = 3, val result = getDarmanUseCase.invoke()
onRetry = { attempt, delay ->
// Optional: log retry attempt
println("Retrying getDarmani, attempt $attempt, delay $delay ms")
}
) {
getDarmanUseCase.invoke()
}.fold(
onSuccess = { result ->
_uiState.update { _uiState.update {
it.copy( it.copy(
darmanState = it.darmanState.copy( darmanState = it.darmanState.copy(
@@ -60,20 +52,12 @@ class HomeViewModel (
) )
) )
} }
}, }catch (e: Exception) {
onFailure = { error -> handleError(e.message)
handleError(error.message ?: "Unknown error") }catch (e: Exception) {
_uiState.update { handleError(e.localizedMessage ?: "Unknown error")
it.copy(
darmanState = it.darmanState.copy(
isLoading = false,
getDarmani = Result.failure(error)
)
)
} }
} }
)
}
} }
fun searchDrugs(drugSearchParams: DrugSearchParams) { fun searchDrugs(drugSearchParams: DrugSearchParams) {
@@ -83,6 +67,7 @@ class HomeViewModel (
viewModelScope.launch { viewModelScope.launch {
try { try {
val result = getDrugSearchUseCase.invoke(drugSearchParams) val result = getDrugSearchUseCase.invoke(drugSearchParams)
println(result)
_uiState.update { it.copy( _uiState.update { it.copy(
drugSearchState = it.drugSearchState.copy( drugSearchState = it.drugSearchState.copy(
isLoading = false, isLoading = false,
@@ -90,9 +75,11 @@ class HomeViewModel (
) )
) } ) }
} catch (e: HttpException) { } catch (e: Exception) {
println(e.message)
handleError(e.message) handleError(e.message)
}catch (e: Exception) { }catch (e: Exception) {
println(e.message)
handleError(e.localizedMessage ?: "Unknown error") handleError(e.localizedMessage ?: "Unknown error")
} }
} }
@@ -119,7 +106,7 @@ class HomeViewModel (
) )
} }
println(result) println(result)
} catch (e: HttpException) { } catch (e: Exception) {
handleError(e.message) handleError(e.message)
}catch (e: Exception) { }catch (e: Exception) {
handleError(e.localizedMessage ?: "Unknown error") handleError(e.localizedMessage ?: "Unknown error")
@@ -0,0 +1,118 @@
package com.approgency.drug.presentation.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.domain.usecase.SearchDrugsYabUseCase
import kotlinx.coroutines.delay
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()
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()
}
+2 -1
View File
@@ -21,6 +21,7 @@ room = "2.6.1"
navigationCompose = "2.8.5" navigationCompose = "2.8.5"
material3 = "1.4.0" material3 = "1.4.0"
jsoup = "1.18.3" jsoup = "1.18.3"
scalars = "2.11.0"
[libraries] [libraries]
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -55,7 +56,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3",
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
retrofit-scalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "scalars" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }