feat: crawl data
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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()
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user