From ee20eca3e03404b79388f87be4f9abfc43a55fac Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Tue, 26 May 2026 02:04:13 +0330 Subject: [PATCH] feat: crawl data --- app/build.gradle.kts | 1 + .../drug/data/remote/DarooyabApiService.kt | 18 +- .../drug/data/remote/DrugHtmlParser.kt | 74 +++++-- .../data/remote/StringConverterFactory.kt | 20 ++ .../data/repository/DrugRepositoryImpl.kt | 23 +- .../java/com/approagency/drug/di/AppModule.kt | 37 +++- .../drug/domain/model/DaroYabParams.kt | 6 + .../drug/domain/model/DaroYabSearchResult.kt | 9 + .../drug/domain/model/DrugSearchParams.kt | 1 + .../drug/domain/repository/DrugRepository.kt | 4 +- .../domain/usecase/SearchDrugsYabUseCase.kt | 6 +- .../presentation/common/EmptySearchState.kt | 78 +++++++ .../common/LoadingMoreIndicator.kt | 56 +++++ .../components/DaroYabResultContent.kt | 61 ++++++ .../components/PaginationControls.kt | 141 ++++++++++++ .../drug/presentation/screens/RootScreen.kt | 5 +- .../drug/presentation/screens/SearchScreen.kt | 200 ++++++++++++++++++ .../presentation/viewModel/HomeViewModel.kt | 49 ++--- .../presentation/viewModel/SearchViewModel.kt | 118 +++++++++++ gradle/libs.versions.toml | 3 +- 20 files changed, 835 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/approagency/drug/data/remote/StringConverterFactory.kt create mode 100644 app/src/main/java/com/approagency/drug/domain/model/DaroYabParams.kt create mode 100644 app/src/main/java/com/approagency/drug/domain/model/DaroYabSearchResult.kt create mode 100644 app/src/main/java/com/approagency/drug/presentation/common/EmptySearchState.kt create mode 100644 app/src/main/java/com/approagency/drug/presentation/common/LoadingMoreIndicator.kt create mode 100644 app/src/main/java/com/approagency/drug/presentation/components/DaroYabResultContent.kt create mode 100644 app/src/main/java/com/approagency/drug/presentation/components/PaginationControls.kt create mode 100644 app/src/main/java/com/approagency/drug/presentation/screens/SearchScreen.kt create mode 100644 app/src/main/java/com/approagency/drug/presentation/viewModel/SearchViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb054eb..93eb7ca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,4 +92,5 @@ dependencies { //crawl implementation(libs.jsoup) + implementation(libs.retrofit.scalars) } \ No newline at end of file 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 17242b5..62ead62 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 @@ -1,17 +1,23 @@ package com.approagency.drug.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.Query interface DarooyabApiService { - @GET("Search") + @FormUrlEncoded + @POST("Home/PartialNewSearch") @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", - "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Language: en-US,en;q=0.5" + "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( - @Query("SearchText") searchText: String - ): String // Returns raw HTML for the first page + @Field("autocomplete") searchText: String, + @Field("DrugName_pageNumber") pageNumber: Int = 1 + ): String } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/data/remote/DrugHtmlParser.kt b/app/src/main/java/com/approagency/drug/data/remote/DrugHtmlParser.kt index f940d76..bcf4023 100644 --- a/app/src/main/java/com/approagency/drug/data/remote/DrugHtmlParser.kt +++ b/app/src/main/java/com/approagency/drug/data/remote/DrugHtmlParser.kt @@ -1,58 +1,102 @@ package com.approagency.drug.data.remote +import com.approagency.drug.domain.model.DaroYabSearchResult import com.approagency.drug.domain.model.DrugSearchResult -import okio.IOException import org.jsoup.Jsoup import org.jsoup.nodes.Document class DrugHtmlParser { - fun parseSearchResults(html: String): List { + fun parseSearchResultsWithPagination(html: String): DaroYabSearchResult { val document: Document = Jsoup.parse(html) - val results = mutableListOf() + 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 { + val results = mutableListOf() val rows = document.select("#tbody_DrugList tr") + println("Found ${rows.size} rows in the response") + if (rows.isEmpty()) { - // Handle case where no results are found + println("No rows found. HTML snippet: ${document.html().take(500)}") return emptyList() } for (row in rows) { try { - // --- Extract Persian Name and Detail Page URL --- - val nameCell = row.selectFirst("td:eq(0)") // First td + val nameCell = row.selectFirst("td:eq(0)") 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 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 genericId = genericIdRegex.find(detailPageRelativeUrl)?.groupValues?.get(1) ?: "" - // --- Extract English Name --- - // The last cell contains the English name link + 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 = genericId, + genericId = if (genericId.isNotEmpty()) genericId else brandId, persianName = persianName, englishName = englishName, detailPageUrl = detailPageUrl ) results.add(drugResult) + println("Parsed: $persianName -> $englishName") } catch (e: Exception) { - // Log error for a specific row but continue with others 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 + ) } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/data/remote/StringConverterFactory.kt b/app/src/main/java/com/approagency/drug/data/remote/StringConverterFactory.kt new file mode 100644 index 0000000..a39dc48 --- /dev/null +++ b/app/src/main/java/com/approagency/drug/data/remote/StringConverterFactory.kt @@ -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, + retrofit: Retrofit + ): Converter? { + return if (type == String::class.java) { + Converter { value -> value.string() } + } else { + null + } + } +} \ 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 c0f1e91..aadb5ef 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 @@ -6,6 +6,8 @@ import com.approagency.drug.data.dto.DrugModels import com.approagency.drug.data.remote.DarooyabApiService import com.approagency.drug.data.remote.DrugApiService 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.DrugSearchResult import com.approagency.drug.domain.repository.DrugRepository @@ -30,8 +32,10 @@ class DrugRepositoryImpl( override suspend fun drugDetail(cod: Int): Result { return try { val response= apiService.getDrugDetail(cod = cod) + println(response.message) return Result.success(response) }catch (e: Exception){ + println(e.message) Result.failure(e) } } @@ -45,25 +49,26 @@ class DrugRepositoryImpl( } } - override suspend fun searchDrugs(params: DrugSearchParams): Result> { + override suspend fun searchDrugs(params: DaroYabParams): Result { return withContext(Dispatchers.IO) { try { - // 1. Fetch raw HTML from the website - val htmlResponse = darooyabApiService.searchDrugs(params.query!!) + val htmlResponse = darooyabApiService.searchDrugs( + searchText = params.query ?: "", + pageNumber = params.pageNumber + ) - // 2. Parse the HTML to extract drug data - val drugList = parser.parseSearchResults(htmlResponse) + println("HTML Response length: ${htmlResponse.length}") - if (drugList.isNotEmpty()) { - Result.success(drugList) + 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) { - // Network error Result.failure(Exception("Network error: ${e.message}", e)) } catch (e: Exception) { - // Parsing or other error Result.failure(Exception("An error occurred: ${e.message}", e)) } } 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 3b7b5cc..57b6d69 100644 --- a/app/src/main/java/com/approagency/drug/di/AppModule.kt +++ b/app/src/main/java/com/approagency/drug/di/AppModule.kt @@ -19,6 +19,7 @@ import com.approagency.drug.domain.usecase.SearchTestsUseCase import com.approagency.drug.presentation.viewModel.HomeViewModel import com.approagency.drug.presentation.viewModel.LabViewModel import com.approagency.drug.utils.Config +import com.approgency.drug.presentation.viewModel.SearchViewModel import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import org.koin.android.ext.koin.androidContext @@ -26,6 +27,9 @@ import org.koin.core.module.dsl.viewModel 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 appModule= module { @@ -35,17 +39,23 @@ val appModule= module { single { get().testGroupDao() } single { get().testItemDao() } + // Shared OkHttpClient for both APIs single { + val cookieManager = CookieManager() + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL) + OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC + level = HttpLoggingInterceptor.Level.BODY // Change to BODY for debugging }) + // Add cookie support .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) .build() } - // ========== Retrofit for YOUR API ========== + // ========== Retrofit for YOUR JSON API ========== single { Retrofit.Builder() .baseUrl(Config.BASE_URL) @@ -55,19 +65,24 @@ val appModule= module { } single { - get().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 { Retrofit.Builder() - .baseUrl(Config.Darro_Url) // Darooyab base URL + .baseUrl(Config.Darro_Url) .client(get()) - .addConverterFactory(GsonConverterFactory.create()) + .addConverterFactory(ScalarsConverterFactory.create()) // This handles String responses .build() - .let { retrofit -> - retrofit.create(DarooyabApiService::class.java) - } + } + + // Create the API service from the Scalars-based Retrofit instance + single { + 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()) } + viewModel { + SearchViewModel(get()) + } + viewModel { LabViewModel(get() , get() , get()) } diff --git a/app/src/main/java/com/approagency/drug/domain/model/DaroYabParams.kt b/app/src/main/java/com/approagency/drug/domain/model/DaroYabParams.kt new file mode 100644 index 0000000..e8ca8ef --- /dev/null +++ b/app/src/main/java/com/approagency/drug/domain/model/DaroYabParams.kt @@ -0,0 +1,6 @@ +package com.approagency.drug.domain.model + +data class DaroYabParams( + val query: String? = null, + val pageNumber: Int = 1, +) diff --git a/app/src/main/java/com/approagency/drug/domain/model/DaroYabSearchResult.kt b/app/src/main/java/com/approagency/drug/domain/model/DaroYabSearchResult.kt new file mode 100644 index 0000000..1684a5a --- /dev/null +++ b/app/src/main/java/com/approagency/drug/domain/model/DaroYabSearchResult.kt @@ -0,0 +1,9 @@ +package com.approagency.drug.domain.model + +data class DaroYabSearchResult( + val drugs: List, + val currentPage: Int, + val totalPages: Int, + val hasNextPage: Boolean, + val hasPreviousPage: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/domain/model/DrugSearchParams.kt b/app/src/main/java/com/approagency/drug/domain/model/DrugSearchParams.kt index 49b9762..2d4c109 100644 --- a/app/src/main/java/com/approagency/drug/domain/model/DrugSearchParams.kt +++ b/app/src/main/java/com/approagency/drug/domain/model/DrugSearchParams.kt @@ -6,4 +6,5 @@ data class DrugSearchParams( val withRelations: Boolean = true, val drugGroup: Int? = null, val healGroup: Int? = null, + ) 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 2033283..e3c9591 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 @@ -3,6 +3,8 @@ package com.approagency.drug.domain.repository import com.approagency.drug.data.dto.DarmanModel import com.approagency.drug.data.dto.DrugListResponse 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.DrugSearchResult @@ -10,5 +12,5 @@ interface DrugRepository { suspend fun searchDrug(params: DrugSearchParams): Result suspend fun drugDetail(cod:Int): Result suspend fun getGorohDaroei(): Result - suspend fun searchDrugs(params:DrugSearchParams):Result> + suspend fun searchDrugs(params:DaroYabParams):Result } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/domain/usecase/SearchDrugsYabUseCase.kt b/app/src/main/java/com/approagency/drug/domain/usecase/SearchDrugsYabUseCase.kt index 93ead65..d3dcd78 100644 --- a/app/src/main/java/com/approagency/drug/domain/usecase/SearchDrugsYabUseCase.kt +++ b/app/src/main/java/com/approagency/drug/domain/usecase/SearchDrugsYabUseCase.kt @@ -1,6 +1,8 @@ package com.approagency.drug.domain.usecase 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.DrugSearchResult import com.approagency.drug.domain.repository.DrugRepository @@ -8,8 +10,8 @@ import com.approagency.drug.domain.repository.DrugRepository class SearchDrugsYabUseCase ( private val repository: DrugRepository ) { - suspend operator fun invoke(query: String): Result> { - val params = DrugSearchParams(query = query) + suspend operator fun invoke(query: String, pageNumber: Int = 1): Result { + val params = DaroYabParams(query = query, pageNumber = pageNumber) return repository.searchDrugs(params) } } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/common/EmptySearchState.kt b/app/src/main/java/com/approagency/drug/presentation/common/EmptySearchState.kt new file mode 100644 index 0000000..fb17e8a --- /dev/null +++ b/app/src/main/java/com/approagency/drug/presentation/common/EmptySearchState.kt @@ -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("تلاش مجدد") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/common/LoadingMoreIndicator.kt b/app/src/main/java/com/approagency/drug/presentation/common/LoadingMoreIndicator.kt new file mode 100644 index 0000000..a03b632 --- /dev/null +++ b/app/src/main/java/com/approagency/drug/presentation/common/LoadingMoreIndicator.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/components/DaroYabResultContent.kt b/app/src/main/java/com/approagency/drug/presentation/components/DaroYabResultContent.kt new file mode 100644 index 0000000..b409e92 --- /dev/null +++ b/app/src/main/java/com/approagency/drug/presentation/components/DaroYabResultContent.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/components/PaginationControls.kt b/app/src/main/java/com/approagency/drug/presentation/components/PaginationControls.kt new file mode 100644 index 0000000..466c9f2 --- /dev/null +++ b/app/src/main/java/com/approagency/drug/presentation/components/PaginationControls.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/screens/RootScreen.kt b/app/src/main/java/com/approagency/drug/presentation/screens/RootScreen.kt index d39df5d..41a8513 100644 --- a/app/src/main/java/com/approagency/drug/presentation/screens/RootScreen.kt +++ b/app/src/main/java/com/approagency/drug/presentation/screens/RootScreen.kt @@ -19,6 +19,7 @@ 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 @@ -54,7 +55,8 @@ import androidx.navigation.NavHostController fun RootScreen(navHostController: NavHostController, modifier: Modifier){ val pages = listOf( "دارو" to Icons.Default.Search, - "آزمایش" to Icons.Default.Build + "آزمایش" to Icons.Default.Build, + "سرچ" to Icons.Rounded.Refresh ) var seletedTab by remember { mutableStateOf(0) } val context = LocalContext.current @@ -121,6 +123,7 @@ fun RootScreen(navHostController: NavHostController, modifier: Modifier){ 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)) } } } diff --git a/app/src/main/java/com/approagency/drug/presentation/screens/SearchScreen.kt b/app/src/main/java/com/approagency/drug/presentation/screens/SearchScreen.kt new file mode 100644 index 0000000..e6b5cfb --- /dev/null +++ b/app/src/main/java/com/approagency/drug/presentation/screens/SearchScreen.kt @@ -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 = {}) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/approagency/drug/presentation/viewModel/HomeViewModel.kt b/app/src/main/java/com/approagency/drug/presentation/viewModel/HomeViewModel.kt index a62cc2d..1cc098e 100644 --- a/app/src/main/java/com/approagency/drug/presentation/viewModel/HomeViewModel.kt +++ b/app/src/main/java/com/approagency/drug/presentation/viewModel/HomeViewModel.kt @@ -42,37 +42,21 @@ class HomeViewModel ( ) } viewModelScope.launch { - retryWithBackoff( - maxRetries = 3, - onRetry = { attempt, delay -> - // Optional: log retry attempt - println("Retrying getDarmani, attempt $attempt, delay $delay ms") - } - ) { - getDarmanUseCase.invoke() - }.fold( - onSuccess = { result -> - _uiState.update { - it.copy( - darmanState = it.darmanState.copy( - isLoading = false, - getDarmani = result - ) + try { + val result = getDarmanUseCase.invoke() + _uiState.update { + it.copy( + darmanState = it.darmanState.copy( + isLoading = false, + getDarmani = result ) - } - }, - onFailure = { error -> - handleError(error.message ?: "Unknown error") - _uiState.update { - it.copy( - darmanState = it.darmanState.copy( - isLoading = false, - getDarmani = Result.failure(error) - ) - ) - } + ) } - ) + }catch (e: Exception) { + handleError(e.message) + }catch (e: Exception) { + handleError(e.localizedMessage ?: "Unknown error") + } } } @@ -83,6 +67,7 @@ class HomeViewModel ( viewModelScope.launch { try { val result = getDrugSearchUseCase.invoke(drugSearchParams) + println(result) _uiState.update { it.copy( drugSearchState = it.drugSearchState.copy( isLoading = false, @@ -90,9 +75,11 @@ class HomeViewModel ( ) ) } - } catch (e: HttpException) { + } catch (e: Exception) { + println(e.message) handleError(e.message) }catch (e: Exception) { + println(e.message) handleError(e.localizedMessage ?: "Unknown error") } } @@ -119,7 +106,7 @@ class HomeViewModel ( ) } println(result) - } catch (e: HttpException) { + } catch (e: Exception) { handleError(e.message) }catch (e: Exception) { handleError(e.localizedMessage ?: "Unknown error") diff --git a/app/src/main/java/com/approagency/drug/presentation/viewModel/SearchViewModel.kt b/app/src/main/java/com/approagency/drug/presentation/viewModel/SearchViewModel.kt new file mode 100644 index 0000000..11afd4f --- /dev/null +++ b/app/src/main/java/com/approagency/drug/presentation/viewModel/SearchViewModel.kt @@ -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.Idle) + val searchState: StateFlow = _searchState.asStateFlow() + + private var currentQuery = "" + private var currentPage = 1 + private var totalPages = 1 + private val allDrugs = mutableListOf() + 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, + val currentPage: Int + ) : SearchState() + data class Success( + val drugs: List, + val currentPage: Int, + val totalPages: Int, + val isLoadingMore: Boolean = false, + val hasMorePages: Boolean = true + ) : SearchState() + data class Error(val message: String) : SearchState() +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 394ef2d..6247af0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ room = "2.6.1" navigationCompose = "2.8.5" material3 = "1.4.0" jsoup = "1.18.3" +scalars = "2.11.0" [libraries] jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } 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-ktx = { module = "androidx.room:room-ktx", 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] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }