Compare commits

38 Commits
Author SHA1 Message Date
Amirmahdi 4a67bed0d3 feat: add terms and conditions 2026-06-14 21:47:22 +03:30
Amirmahdi cba6aa6153 feat: payment cafe 2026-06-14 21:17:51 +03:30
Amirmahdi f3b227f09a feat:vr2 2026-06-14 20:05:29 +03:30
Amirmahdi c76f8aa08f feat: add myket iap 2026-06-14 18:31:15 +03:30
Amirmahdi f266f4d142 feat: font scaler added 2026-06-14 15:59:48 +03:30
Amirmahdi 1971708b3f feat: remove code 2026-06-14 15:31:13 +03:30
Amirmahdi 27dc08fa68 feat: remove drug.approagncy api 2026-06-14 08:18:01 +03:30
Amirmahdi 1ed75fb823 feat: clean code 2026-06-12 23:39:17 +03:30
Amirmahdi 0a4b339481 feat: update free usage 2026-06-12 22:30:43 +03:30
Amirmahdi 5e7ce5bc6b feat: change ic launcher 2026-06-12 22:25:23 +03:30
Amirmahdi fd8eb0a373 feat: change drawer 2026-06-12 21:16:51 +03:30
Amirmahdi 675123309a feat: add otp reciver 2026-06-12 20:02:21 +03:30
Amirmahdi fdf15913a9 feat: theme manage and auth 2026-06-12 15:42:09 +03:30
Amirmahdi c3496e3de4 feat: login and otp added 2026-06-12 13:24:39 +03:30
Amirmahdi e73269889f feat: error handeling 2026-06-08 20:12:02 +03:30
Amirmahdi 34ae6a1b54 style: tab bar 2026-06-08 20:02:51 +03:30
Amirmahdi 16128e5304 feat: clean 2026-06-08 19:58:26 +03:30
Amirmahdi 0ae8fa19bc style 2026-06-07 23:53:51 +03:30
Amirmahdi d1718793d9 style: btn sheet color 2026-06-07 22:35:44 +03:30
Amirmahdi b46cd5b40c fix: size and dime 2026-06-07 22:29:08 +03:30
Amirmahdi 292b77f29b fix: lunch effect debaunce fixed 2026-06-07 22:00:02 +03:30
Amirmahdi 670dbf8ce2 fix: brand id and genetic id are fixed 2026-06-07 21:03:22 +03:30
Amirmahdi cbe77fbb9e feat: change package name 2026-06-05 18:02:48 +03:30
Amirmahdi 92b7ab49c4 Fix .gitignore to exclude APK files 2026-06-05 15:38:21 +03:30
Amirmahdi 098d5c8d5f feat: add icons and btn nav 2026-06-05 13:25:17 +03:30
Amirmahdi 885b960cc9 style: change style 2026-06-05 10:52:35 +03:30
Amirmahdi 4480b26e07 feat: add pharmacy detail 2026-06-05 10:49:32 +03:30
Amirmahdi 43427ae420 style: make better style 2026-06-05 01:36:57 +03:30
Amirmahdi 1ba6062c86 feat: province move ti utils 2026-06-05 00:19:16 +03:30
Amirmahdi 8516405daf feat: add pharmacy feature 2026-06-04 20:46:54 +03:30
Amirmahdi e0e4bb4eef fix 2026-05-29 12:38:07 +03:30
Amirmahdi 3ea8bf5542 fix 2026-05-29 12:37:18 +03:30
Amirmahdi e427bde0d0 fix:title 2026-05-29 12:35:47 +03:30
Amirmahdi 4a57954faf fix: title of app bar fixed 2026-05-28 19:43:39 +03:30
Amirmahdi c8044bfe42 feat: add main nav host best practice 2026-05-27 23:55:28 +03:30
Amirmahdi c09d79dbbc fix 2026-05-27 22:08:59 +03:30
Amirmahdi dd5cbaa3bc feat: clean text 2026-05-27 20:42:39 +03:30
Amirmahdi e65c699bcb feat: clean text 2026-05-27 20:42:16 +03:30
187 changed files with 8628 additions and 4684 deletions
+7
View File
@@ -9,6 +9,13 @@
/.idea/assetWizardSettings.xml
.DS_Store
/build
*.apk
*.aab
*.dm
/app/release/
release/
/build/
/app/build/
/captures
.externalNativeBuild
.cxx
+157
View File
@@ -0,0 +1,157 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="17" />
</component>
</project>
+1 -1
View File
@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-15T13:26:47.825150Z">
<DropdownSelection timestamp="2026-05-28T05:38:32.685363Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amirmahdi/.android/avd/Pixel_3a_API_33_arm64-v8a.avd" />
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
Generated
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>
+27 -5
View File
@@ -6,24 +6,36 @@ plugins {
}
android {
namespace = "com.approagency.drug"
namespace = "com.approagency.pharmacy"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.approagency.drug"
applicationId = "com.approagency.pharmacy"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.0.2+BZ"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// ---- Bazaar (Cafe Bazaar) in-app billing ----
val marketApplicationId = "com.farsitel.bazaar"
val marketBindAddress = "ir.cafebazaar.pardakht.InAppBillingService.BIND"
manifestPlaceholders["marketApplicationId"] = marketApplicationId
manifestPlaceholders["marketBindAddress"] = marketBindAddress
manifestPlaceholders["marketPermission"] = "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR"
buildConfigField(
"String",
"IAB_PUBLIC_KEY",
"\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwDzw+tRS/C//9JebsgTQOPAJU3Qwg7aIOhO4wqPVMNCjkGQZH7MckLU8hC6Rrag8gakkGBROSN/J58W4q1wxFbaUgRiTvyM04ElE7tHHa8CKtNH3NSUFzCQkhqd+9mLGflmj7JEj6EaZLoTLfZYhQgGDpWNXwW9HmO2lygXkua6BUkelpafgAmLz/ojlBm+ByLdSVkXIaaGqs2GLLR4qZHiksljQskbv0T0Ccax8pcCAwEAAQ==\""
)
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -39,6 +51,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}
@@ -77,6 +90,12 @@ dependencies {
// Location services
implementation(libs.location.services)
// SMS User Consent API (OTP auto-fill)
implementation(libs.play.services.auth.api.phone)
// Myket in-app billing
implementation("com.github.myketstore:myket-billing-client:1.6")
// Koin for DI
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose.v410)
@@ -93,4 +112,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.retrofit.scalars)
implementation(libs.androidx.compose.material.icons.extended)
}
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.approagency.pharmacy",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 2,
"versionName": "1.0.2+MK",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
@@ -1,4 +1,4 @@
package com.approagency.drug
package com.approagency.pharmacy
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.approagency.drug", appContext.packageName)
assertEquals("com.approagency.pharmacy", appContext.packageName)
}
}
+1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".DrugApp"
android:allowBackup="true"
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,25 +0,0 @@
package com.approagency.drug
import android.app.Application
import com.approagency.drug.data.local.database.LabDatabase
import com.approagency.drug.di.appModule
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class DrugApp : Application(){
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@DrugApp)
modules(appModule)
printLogger()
}
// Preload database (optional, for faster access)
GlobalScope.launch {
LabDatabase.getInstance(this@DrugApp)
}
}
}
@@ -1,52 +0,0 @@
package com.approagency.drug
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.navigation.compose.rememberNavController
import com.approagency.drug.navigation.AppNavGraph
import com.approagency.drug.presentation.screens.HomeScreen
import com.approagency.drug.ui.theme.DrugTheme
import com.vada.caller.ui.theme.dime
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DrugTheme {
val navController = rememberNavController()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(modifier = Modifier.fillMaxSize(),
) { innerPadding ->
AppNavGraph(
// navController = navController,
// modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@@ -1,31 +0,0 @@
package com.approagency.drug.data.remote
import com.approagency.drug.data.dto.DarmanModel
import com.approagency.drug.data.dto.DrugListResponse
import com.approagency.drug.data.dto.DrugModels
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Path
import retrofit2.http.Query
interface DrugApiService {
@GET("drugs/search")
suspend fun getDrugs(
@Query("q")query: String?,
@Query("per_page")perPage:Int? = 20,
@Query("with_relations")withRelations: Boolean? =true,
@Query("goroh_daroei_cod")drugGroup:Int?,
@Query("goroh_darmani_cod")healGroup:Int?,
): DrugListResponse
@GET("drugs/{cod}")
suspend fun getDrugDetail(
@Path("cod") cod: Int
): DrugModels
@GET("drugs/goroh-darmani")
suspend fun getGorohDaroei(): DarmanModel
}
@@ -1,92 +0,0 @@
package com.approagency.drug.data.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.data.remote.DarooyabApiService
import com.approagency.drug.data.remote.DrugApiService
import com.approagency.drug.data.remote.DrugDetailParser
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.DrugDetail
import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.drug.domain.repository.DrugRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
class DrugRepositoryImpl(
private val apiService: DrugApiService,
private val darooyabApiService: DarooyabApiService,
private val parser: DrugHtmlParser,
private val detailParser: DrugDetailParser,
): DrugRepository {
override suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse> {
return try {
val response = apiService.getDrugs(query = params.query , perPage = params.perPage , withRelations = params.withRelations , healGroup = params.healGroup , drugGroup = params.drugGroup )
return Result.success(response)
}catch (e: Exception){
Result.failure(e)
}
}
override suspend fun drugDetail(cod: Int): Result<DrugModels> {
return try {
val response= apiService.getDrugDetail(cod = cod)
println(response.message)
return Result.success(response)
}catch (e: Exception){
println(e.message)
Result.failure(e)
}
}
override suspend fun getGorohDaroei(): Result<DarmanModel> {
return try {
val response = apiService.getGorohDaroei()
println(response)
return Result.success(response)
}catch (e: Exception){
Result.failure(e)
}
}
override suspend fun searchDrugs(params: DaroYabParams): Result<DaroYabSearchResult> {
return withContext(Dispatchers.IO) {
try {
val htmlResponse = darooyabApiService.searchDrugs(
searchText = params.query ?: "",
pageNumber = params.pageNumber
)
println("HTML Response length: ${htmlResponse.length}")
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) {
Result.failure(Exception("Network error: ${e.message}", e))
} catch (e: Exception) {
Result.failure(Exception("An error occurred: ${e.message}", e))
}
}
}
override suspend fun getDrugDetailFromYab(detailUrl: String): Result<DrugDetail> {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getDrugDetail(detailUrl)
val detail = detailParser.parseDrugDetail(html)
Result.success(detail)
} catch (e: Exception) {
Result.failure(e)
}
}
}
}
@@ -1,149 +0,0 @@
package com.approagency.drug.di
import android.app.Application
import com.approagency.drug.data.local.database.LabDatabase
import com.approagency.drug.data.remote.DarooyabApiService
import com.approagency.drug.data.remote.DrugApiService
import com.approagency.drug.data.remote.DrugDetailParser
import com.approagency.drug.data.remote.DrugHtmlParser
import com.approagency.drug.data.repository.DrugRepositoryImpl
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.repository.DrugRepository
import com.approagency.drug.domain.usecase.DrugDetailYabUseCase
import com.approagency.drug.domain.usecase.GetDarmanUseCase
import com.approagency.drug.domain.usecase.GetDrugDetailUseCase
import com.approagency.drug.domain.usecase.GetDrugSearchUseCase
import com.approagency.drug.domain.usecase.GetTestGroupUseCase
import com.approagency.drug.domain.usecase.GetTestItemByGroupId
import com.approagency.drug.domain.usecase.SearchDrugsYabUseCase
import com.approagency.drug.domain.usecase.SearchTestsUseCase
import com.approagency.drug.presentation.viewModel.DrugDetailViewModel
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
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.qualifier
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 jsonRetrofitQualifier = qualifier("jsonRetrofit")
val scalarRetrofitQualifier = qualifier("scalarRetrofit")
val appModule= module {
single { LabDatabase.getInstance(androidContext()) }
single { get<LabDatabase>().testGroupDao() }
single { get<LabDatabase>().testItemDao() }
// Shared OkHttpClient for both APIs
single {
val cookieManager = CookieManager()
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL)
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // Change to BODY for debugging
})
// Add cookie support
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.build()
}
single<Retrofit>(jsonRetrofitQualifier) { // Add qualifier
Retrofit.Builder()
.baseUrl(Config.BASE_URL)
.client(get<OkHttpClient>())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single<DrugApiService> {
val retrofit: Retrofit = get(jsonRetrofitQualifier) // Get qualified instance
retrofit.create(DrugApiService::class.java)
}
// ========== Retrofit for DAROOYAB Website ==========
single<Retrofit>(scalarRetrofitQualifier) { // Add qualifier
Retrofit.Builder()
.baseUrl(Config.Darro_Url)
.client(get<OkHttpClient>())
.addConverterFactory(ScalarsConverterFactory.create())
.build()
}
single<DarooyabApiService> {
val retrofit: Retrofit = get(scalarRetrofitQualifier) // Get qualified instance
retrofit.create(DarooyabApiService::class.java)
}
factory { DrugHtmlParser() }
factory { DrugDetailParser() }
//repo
single<DrugRepository> {
DrugRepositoryImpl(get<DrugApiService>() , get<DarooyabApiService>() , get() , get())
}
//UseCase
single {
GetDrugSearchUseCase(get())
}
single {
SearchDrugsYabUseCase(get())
}
single{
GetDrugDetailUseCase(get())
}
single {
GetDarmanUseCase(get())
}
single {
GetTestGroupUseCase(get())
}
single {
GetTestItemByGroupId(get())
}
single { LabRepositoryImpl(get(), get()) }
single {
DrugDetailYabUseCase(get())
}
viewModel {
DrugDetailViewModel(get())
}
single { SearchTestsUseCase(get()) }
//view model
viewModel {
HomeViewModel(get() , get() , get())
}
viewModel {
SearchViewModel(get())
}
viewModel {
LabViewModel(get() , get() , get())
}
}
@@ -1,19 +0,0 @@
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.DrugDetail
import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.model.DrugSearchResult
interface DrugRepository {
suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse>
suspend fun drugDetail(cod:Int): Result<DrugModels>
suspend fun getGorohDaroei(): Result<DarmanModel>
suspend fun searchDrugs(params:DaroYabParams):Result<DaroYabSearchResult>
suspend fun getDrugDetailFromYab(detailUrl: String):Result<DrugDetail>
}
@@ -1,14 +0,0 @@
package com.approagency.drug.domain.usecase
import com.approagency.drug.domain.model.DaroYabParams
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugDetail
import com.approagency.drug.domain.repository.DrugRepository
class DrugDetailYabUseCase (
private val repository: DrugRepository
) {
suspend operator fun invoke(detailUrl: String):Result<DrugDetail> {
return repository.getDrugDetailFromYab(detailUrl)
}
}
@@ -1,13 +0,0 @@
package com.approagency.drug.domain.usecase
import com.approagency.drug.data.dto.DrugListResponse
import com.approagency.drug.domain.model.DrugSearchParams
import com.approagency.drug.domain.repository.DrugRepository
class GetDrugSearchUseCase (
private val repository: DrugRepository
){
suspend operator fun invoke(params: DrugSearchParams): Result<DrugListResponse> {
return repository.searchDrug(params)
}
}
@@ -1,17 +0,0 @@
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
class SearchDrugsYabUseCase (
private val repository: DrugRepository
) {
suspend operator fun invoke(query: String, pageNumber: Int = 1): Result<DaroYabSearchResult> {
val params = DaroYabParams(query = query, pageNumber = pageNumber)
return repository.searchDrugs(params)
}
}
@@ -1,23 +0,0 @@
package com.approagency.drug.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun AppNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "main_container"
) {
composable("main_container") {
MainContainer()
}
}
}
@@ -1,37 +0,0 @@
package com.approagency.drug.navigation
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.approagency.drug.navigation.graphs.homeGraph
import com.approagency.drug.navigation.graphs.labGraph
import com.approagency.drug.navigation.graphs.searchGraph
@Composable
fun MainContainer() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomBar(navController)
}
) { padding ->
NavHost(
navController = navController,
startDestination = MainRoute.HomeGraph.route,
modifier = Modifier.padding(padding)
) {
homeGraph(navController)
searchGraph(navController)
labGraph(navController)
}
}
}
@@ -1,28 +0,0 @@
package com.approagency.drug.navigation.graphs
import androidx.compose.ui.Modifier
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.approagency.drug.navigation.MainRoute
import com.approagency.drug.navigation.Screen
import com.approagency.drug.presentation.screens.HomeScreen
fun NavGraphBuilder.homeGraph(
navController: NavHostController
) {
navigation(
route = MainRoute.HomeGraph.route,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
navController = navController , modifier = Modifier
)
}
}
}
@@ -1,25 +0,0 @@
package com.approagency.drug.navigation.graphs
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import com.approagency.drug.navigation.MainRoute
import com.approagency.drug.navigation.Screen
import com.approagency.drug.presentation.screens.LabScreen
fun NavGraphBuilder.labGraph(
navController: NavHostController
) {
navigation(
route = MainRoute.LabGraph.route,
startDestination = Screen.Lab.route
) {
composable(Screen.Lab.route) {
LabScreen()
}
}
}
@@ -1,51 +0,0 @@
package com.approagency.drug.navigation.graphs
import android.net.Uri
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation
import com.approagency.drug.navigation.MainRoute
import com.approagency.drug.navigation.Screen
import com.approagency.drug.presentation.screens.DrugDetailScreen
import com.approagency.drug.presentation.screens.SearchScreen
fun NavGraphBuilder.searchGraph(
navController: NavHostController
) {
navigation(
route = MainRoute.SearchGraph.route,
startDestination = Screen.Search.route
) {
composable(Screen.Search.route) {
SearchScreen(
navController = navController
)
}
composable(
route = Screen.DrugDetail.route,
arguments = listOf(
navArgument("detailUrl") {
type = NavType.StringType
}
)
) {
val detailUrl =
Uri.decode(
it.arguments?.getString("detailUrl") ?: ""
)
DrugDetailScreen(
navController = navController,
detailUrl = detailUrl
)
}
}
}
@@ -1,26 +0,0 @@
package com.approagency.drug.presentation.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@Composable
fun CustomBox(
modifier: Modifier? = Modifier,
child: @Composable ()-> Unit
) {
if (modifier != null) {
Box(
modifier = modifier.clip(shape = MaterialTheme.shapes.large).background(
color = MaterialTheme.colorScheme.surfaceContainer
)
) {
child()
}
}
}
@@ -1,25 +0,0 @@
package com.approagency.drug.presentation.common
import android.content.res.Resources.Theme
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vada.caller.ui.theme.dime
@Composable
fun Loading(
color: Color = MaterialTheme.colorScheme.onPrimary,
modifier: Modifier = Modifier
) {
CircularProgressIndicator(
color = color,
modifier = modifier,
strokeWidth = 2.dp
)
}
@@ -1,83 +0,0 @@
package com.approagency.drug.presentation.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.approagency.drug.presentation.common.CustomBox
import com.vada.caller.ui.theme.LocalDime
import com.vada.caller.ui.theme.dime
@Composable
fun DaroYabSearchResult(
drug: DrugSearchResult,
onClickDetail: (DrugSearchResult) -> Unit,
onClickDrugStore:() ->Unit,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
CustomBox (
modifier = modifier
.padding(bottom = MaterialTheme.dime.sm)
.fillMaxWidth()
.clickable { onClickDetail(drug) },
) {
Row (
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Column(
modifier = Modifier.padding(dime.lg),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End
) {
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
)
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = {onClickDetail(drug)} , content = {
Text("اطلاعات دارویی")
})
TextButton(onClick = {onClickDetail(drug)} , content = {
Text("موجودی داروخانه")
})
}
}
}
}
}
@@ -1,768 +0,0 @@
package com.approagency.drug.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.approagency.drug.domain.model.DrugDetail
import com.approagency.drug.presentation.common.ErrorState
import com.approagency.drug.presentation.viewModel.DrugDetailViewModel
import com.approagency.drug.presentation.viewModel.DrugDetailYabState
import com.vada.caller.ui.theme.LocalDime
import com.vada.caller.ui.theme.dime
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrugDetailScreen(
navController: NavController,
detailUrl: String,
viewModel: DrugDetailViewModel = koinViewModel()
) {
val dime = LocalDime.current
val detailState by viewModel.detailState.collectAsStateWithLifecycle()
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf("عمومی", "تخصصی", "اشکال دارویی", "اسامی تجاری")
LaunchedEffect(detailUrl) {
viewModel.loadDrugDetail(detailUrl)
}
Scaffold(
topBar = {
TopAppBar(
title = {
when (val state = detailState) {
is DrugDetailYabState.Success -> {
Column {
Text(
text = state.drugDetail.persianName.take(25),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1
)
if (state.drugDetail.englishName.isNotEmpty()) {
Text(
text = state.drugDetail.englishName.take(30),
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
maxLines = 1
)
}
}
}
else -> {
Text("جزئیات دارو", fontSize = 16.sp)
}
}
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "بازگشت"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (val state = detailState) {
DrugDetailYabState.Idle -> {}
DrugDetailYabState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(dime.md))
Text(
text = "در حال دریافت اطلاعات دارو...",
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
is DrugDetailYabState.Success -> {
val drugDetail = state.drugDetail
Column(modifier = Modifier.fillMaxSize()) {
// Manufacturer Info Card (برای صفحات برند)
if (!drugDetail.isGeneric && drugDetail.manufacturer != null) {
ManufacturerInfoCard(
manufacturer = drugDetail.manufacturer!!,
genericInfo = drugDetail.genericInfo,
modifier = Modifier.padding(horizontal = dime.md, vertical = dime.sm)
)
}
// Tab Row
TabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
fontSize = 13.sp,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal,
maxLines = 1
)
}
)
}
}
// Tab Content
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dime.md)
.padding(top = dime.md),
verticalArrangement = Arrangement.spacedBy(dime.md)
) {
when (selectedTab) {
0 -> {
item { GeneralInfoTabContent(drugDetail) }
}
1 -> {
item { SpecializedInfoTabContent(drugDetail) }
}
2 -> {
item { DosageFormsTabContent(drugDetail) }
}
3 -> {
item { BrandNamesTabContent(drugDetail) }
}
}
}
}
}
is DrugDetailYabState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
ErrorState(
message = state.message,
onRetry = { viewModel.loadDrugDetail(detailUrl) }
)
}
}
}
}
}
}
@Composable
fun ManufacturerInfoCard(
manufacturer: String,
genericInfo: com.approagency.drug.domain.model.GenericInfo?,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dime.xs)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "سازنده:",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = manufacturer,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
if (genericInfo != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dime.xs)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "ماده موثره:",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = genericInfo.persianName,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// آپدیت GeneralInfoTabContent برای صفحات Generic
@Composable
fun GeneralInfoTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
Column(verticalArrangement = Arrangement.spacedBy(dime.md)) {
if (drugDetail.isGeneric) {
// ========== صفحات ژنریک (Generic) ==========
// اگر generalInfo وجود دارد (از EtelaatOmomiVaTakhasosi یا EtelaatOmomi)
if (!drugDetail.generalInfo.isNullOrBlank()) {
InfoSectionCard(
title = "اطلاعات عمومی",
content = drugDetail.generalInfo
)
}
// اگر specializedInfo وجود دارد (از EtelaatTakhasosiContent)
else if (!drugDetail.specializedInfo.isNullOrBlank()) {
InfoSectionCard(
title = "اطلاعات تخصصی",
content = drugDetail.specializedInfo
)
}
// در غیر این صورت، اطلاعات پراکنده را نمایش بده
else {
// Categories Card
if (drugDetail.drugClass != null || drugDetail.therapeuticClass != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
drugDetail.drugClass?.let {
Row {
Text(
text = "طبقه بندی مارتیندل: ",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
drugDetail.therapeuticClass?.let {
Row {
Text(
text = "طبقه درمانی: ",
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
Text(
text = it,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// Pregnancy Card
if (drugDetail.pregnancyCategory != null || drugDetail.pregnancyDescription != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFFFF3E0)
),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = "مصرف در بارداری",
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = Color(0xFFE65100)
)
drugDetail.pregnancyCategory?.let {
Text(
text = "گروه: $it",
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
drugDetail.pregnancyDescription?.let {
Spacer(modifier = Modifier.height(dime.xs))
Text(
text = it,
fontSize = 12.sp,
lineHeight = 18.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
}
}
// موارد مصرف
drugDetail.usage?.let {
InfoSectionCard(title = "موارد مصرف", content = it)
}
// موارد منع مصرف
drugDetail.contraindications?.let {
InfoSectionCard(title = "موارد منع مصرف", content = it)
}
// عوارض جانبی
drugDetail.sideEffects?.let {
InfoSectionCard(title = "عوارض جانبی", content = it)
}
// هشدارها
drugDetail.warnings?.let {
InfoSectionCard(title = "هشدارها", content = it)
}
// توصیه‌های دارویی
drugDetail.recommendations?.let {
InfoSectionCard(title = "توصیه‌های دارویی", content = it)
}
}
// اگر هیچ اطلاعاتی وجود نداشت
if (drugDetail.drugClass == null &&
drugDetail.therapeuticClass == null &&
drugDetail.pregnancyCategory == null &&
drugDetail.usage == null &&
drugDetail.contraindications == null &&
drugDetail.sideEffects == null &&
drugDetail.warnings == null &&
drugDetail.generalInfo.isNullOrBlank() &&
drugDetail.specializedInfo.isNullOrBlank()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "اطلاعات عمومی برای این دارو ثبت نشده است",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
} else {
// ========== صفحات برند (Brand) ==========
// نمایش اطلاعات تخصصی برند که در بخش brandAttrPersDesc است
drugDetail.usage?.let {
InfoSectionCard(title = "موارد مصرف", content = it)
}
drugDetail.contraindications?.let {
InfoSectionCard(title = "موارد منع مصرف", content = it)
}
drugDetail.sideEffects?.let {
InfoSectionCard(title = "عوارض جانبی", content = it)
}
drugDetail.warnings?.let {
InfoSectionCard(title = "هشدارها", content = it)
}
drugDetail.recommendations?.let {
InfoSectionCard(title = "توصیه‌های دارویی", content = it)
}
if (drugDetail.usage == null &&
drugDetail.contraindications == null &&
drugDetail.sideEffects == null &&
drugDetail.warnings == null
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "اطلاعات عمومی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
}
}
@Composable
fun SpecializedInfoTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
Column(verticalArrangement = Arrangement.spacedBy(dime.md)) {
// برای صفحات برند، اطلاعات تخصصی معمولاً در قسمت brandAttrPersDesc است
// که در پارسر ما به صورت usage, contraindications, sideEffects, warnings, recommendations ذخیره شده
drugDetail.usage?.let {
InfoSectionCard(title = "موارد مصرف", content = it)
}
drugDetail.mechanism?.let {
InfoSectionCard(title = "مکانیسم اثر", content = it)
}
drugDetail.pharmacokinetics?.let {
InfoSectionCard(title = "فارماکوکینتیک", content = it)
}
drugDetail.contraindications?.let {
InfoSectionCard(title = "موارد منع مصرف", content = it)
}
drugDetail.sideEffects?.let {
InfoSectionCard(title = "عوارض جانبی", content = it)
}
drugDetail.interactions?.let {
InfoSectionCard(title = "تداخلات دارویی", content = it)
}
drugDetail.warnings?.let {
InfoSectionCard(title = "هشدارها", content = it)
}
drugDetail.recommendations?.let {
InfoSectionCard(title = "توصیه‌های دارویی", content = it)
}
// اگر هیچ اطلاعات تخصصی وجود نداشت
if (drugDetail.usage == null &&
drugDetail.mechanism == null &&
drugDetail.contraindications == null &&
drugDetail.sideEffects == null &&
drugDetail.warnings == null
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (drugDetail.isGeneric)
"اطلاعات تخصصی برای این دارو ثبت نشده است"
else
"اطلاعات تخصصی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
fun DosageFormsTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
val dosageForms = drugDetail.dosageForms
if (dosageForms.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = if (drugDetail.isGeneric)
"اشکال دارویی برای این دارو ثبت نشده است"
else
"سایر اشکال دارویی این برند ثبت نشده است",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
// اگر صفحه برند است، عنوان متفاوت نشان بده
if (!drugDetail.isGeneric) {
Text(
text = "سایر محصولات این برند",
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = dime.xs)
)
}
dosageForms.forEach { form ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = form.persianName,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
if (form.englishName.isNotBlank()) {
Text(
text = form.englishName,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
// نشانگرهای خطر
if (form.isHighRisk || form.isVital) {
Row(
horizontalArrangement = Arrangement.spacedBy(dime.xs)
) {
if (form.isHighRisk) {
androidx.compose.material3.Surface(
shape = RoundedCornerShape(4.dp),
color = Color(0xFFFFEBEE)
) {
Text(
text = "پرخطر",
fontSize = 10.sp,
color = Color(0xFFC62828),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
if (form.isVital) {
androidx.compose.material3.Surface(
shape = RoundedCornerShape(4.dp),
color = Color(0xFFE8F5E9)
) {
Text(
text = "حیاتی",
fontSize = 10.sp,
color = Color(0xFF2E7D32),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
}
}
}
}
}
}
}
@Composable
fun BrandNamesTabContent(drugDetail: DrugDetail) {
val dime = LocalDime.current
if (drugDetail.isGeneric && drugDetail.brandNames.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "اسامی تجاری برای این دارو ثبت نشده است",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
} else if (drugDetail.isGeneric && drugDetail.brandNames.isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
drugDetail.brandNames.forEach { brand ->
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = brand.persianName,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
brand.manufacturer?.let {
Row {
Text(
text = "تولید کننده: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = it,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
brand.importer?.let {
Row {
Text(
text = "وارد کننده: ",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Text(
text = it,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
} else {
// صفحه برند است - اسامی تجاری برای برند معنی ندارد
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "این صفحه مربوط به یک محصول تجاری است. برای مشاهده اسامی تجاری سایر برندها، به صفحه داروی ژنریک مراجعه کنید.",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun InfoSectionCard(
title: String,
content: String,
modifier: Modifier = Modifier
) {
val dime = LocalDime.current
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(dime.sm)
) {
Column(
modifier = Modifier.padding(dime.md),
verticalArrangement = Arrangement.spacedBy(dime.xs)
) {
Text(
text = title,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = MaterialTheme.colorScheme.primary
)
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f))
Text(
text = content,
fontSize = 13.sp,
lineHeight = 20.sp,
textAlign = TextAlign.Justify,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f)
)
}
}
}
@@ -1,126 +0,0 @@
package com.approgency.drug.presentation.viewModel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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()
var searchText by mutableStateOf("")
private set
fun updateSearchText(value: String) {
searchText = value
}
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()
}
@@ -1,11 +0,0 @@
package com.approagency.drug.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -1,114 +0,0 @@
package com.approagency.drug.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.fabirt.podcastapp.ui.theme.Shapes
import com.vada.caller.ui.theme.Dime
import com.vada.caller.ui.theme.LocalDime
object MedicalColors {
val Primary = Color(0xFF0F766E)
val PrimaryContainer = Color(0xFFD7F3EF)
val Secondary = Color(0xFF5B8E8A)
val SecondaryContainer = Color(0xFFE3F3F1)
val Tertiary = Color(0xFF8FBFB8)
val Background = Color(0xFFF8FCFB)
val Surface = Color(0xFFFFFFFF)
val DarkBackground = Color(0xFF0F1720)
val DarkSurface = Color(0xFF17232B)
val OnDark = Color(0xFFF5FAF9)
val OnLight = Color(0xFF102A2A)
val Error = Color(0xFFD64545)
val Outline = Color(0xFFD0E2DF)
}
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD4C3),
onPrimary = Color(0xFF003733),
secondary = Color(0xFF8CC9C1),
onSecondary = Color(0xFF0D2B29),
tertiary = Color(0xFFA7DCD4),
onTertiary = Color(0xFF102A2A),
background = MedicalColors.DarkBackground,
onBackground = MedicalColors.OnDark,
surface = MedicalColors.DarkSurface,
onSurface = MedicalColors.OnDark,
primaryContainer = Color(0xFF124A45),
secondaryContainer = Color(0xFF1D3D3A),
error = Color(0xFFFF8A80)
)
private val LightColorScheme = lightColorScheme(
primary = MedicalColors.Primary,
onPrimary = Color.White,
secondary = MedicalColors.Secondary,
onSecondary = Color.White,
tertiary = MedicalColors.Tertiary,
onTertiary = MedicalColors.OnLight,
background = MedicalColors.Background,
onBackground = MedicalColors.OnLight,
surface = MedicalColors.Surface,
onSurface = MedicalColors.OnLight,
primaryContainer = MedicalColors.PrimaryContainer,
secondaryContainer = MedicalColors.SecondaryContainer,
outline = MedicalColors.Outline,
error = MedicalColors.Error
)
@Composable
fun DrugTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
CompositionLocalProvider(
LocalDime provides Dime()
) {
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content,
shapes = Shapes,
)
}
}
@@ -1,6 +0,0 @@
package com.approagency.drug.utils
object Config {
const val BASE_URL = "https://drug.approagency.ir/api/"
const val Darro_Url = "https://www.darooyab.ir/"
}
@@ -0,0 +1,31 @@
package com.approagency.pharmacy
import android.app.Application
import com.approagency.pharmacy.data.local.database.LabDatabase
import com.approagency.pharmacy.di.appModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class DrugApp : Application() {
// اسکوپ سطح‌اپ برای کارهای پس‌زمینه‌ی طول‌عمرِ برنامه (به‌جای GlobalScope).
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@DrugApp)
modules(appModule)
printLogger()
}
// پیش‌بارگذاری دیتابیس برای دسترسی سریع‌تر
applicationScope.launch {
LabDatabase.getInstance(this@DrugApp)
}
}
}
@@ -0,0 +1,130 @@
package com.approagency.pharmacy
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.navigation.AppNavGraph
import com.approagency.pharmacy.presentation.account.OtpAutoFillBus
import com.approagency.pharmacy.presentation.account.OtpAutofillController
import com.approagency.pharmacy.ui.theme.DrugTheme
import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class MainActivity : ComponentActivity(), OtpAutofillController {
private val session: SessionManager by inject()
private val authRepository: AuthRepository by inject()
private val otpAutoFillBus: OtpAutoFillBus by inject()
private var otpSmsReceiver: BroadcastReceiver? = null
// نتیجه‌ی دیالوگ رضایتِ خواندن پیامک: کد ۵ رقمی استخراج و به شیت ورود تحویل می‌شود.
private val smsConsentLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE)
val code = Regex("\\b\\d{5}\\b").find(message ?: "")?.value
code?.let { otpAutoFillBus.submit(it) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
val themeMode by session.themeMode.collectAsState()
val fontScale by session.fontScale.collectAsState()
DrugTheme(themeMode = themeMode) {
// مقیاسِ متنِ انتخابیِ کاربر روی fontScaleِ سیستم اعمال می‌شود
// تا کلِ متن‌های اپ یک‌جا بزرگ/کوچک شوند.
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(
density = density.density,
fontScale = density.fontScale * fontScale.scale
)
) {
AppNavGraph()
}
}
}
}
/**
* هنگام شروع اپ و هر بار بازگشت به اپ (مثلاً پس از بازگشت از درگاه پرداختِ
* مایکت/بازار) وضعیت اشتراک از سرور تازه‌سازی می‌شود تا کل اپ به‌روز بماند.
*/
override fun onResume() {
super.onResume()
if (session.isLoggedIn) {
lifecycleScope.launch { authRepository.refreshStatus() }
}
}
// ---------- خودکارپُرکُنِ کد پیامک (SMS User Consent API) ----------
/** آغاز گوش‌دادن به پیامکِ کد؛ کدِ یافت‌شده از طریق [OtpAutoFillBus] تحویل می‌شود. */
override fun startOtpAutofill() {
SmsRetriever.getClient(this).startSmsUserConsent(null)
if (otpSmsReceiver != null) return
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, received: Intent?) {
if (received?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
val extras = received.extras ?: return
val status = extras.get(SmsRetriever.EXTRA_STATUS) as? Status ?: return
if (status.statusCode != CommonStatusCodes.SUCCESS) return
val consentIntent: Intent? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT)
}
consentIntent?.let { runCatching { smsConsentLauncher.launch(it) } }
}
}
otpSmsReceiver = receiver
// این برودکست را Google Play services با مجوز SEND می‌فرستد؛ پس گیرنده باید
// همان مجوز را الزام کند تا پیامک به آن تحویل شود.
ContextCompat.registerReceiver(
this,
receiver,
IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION),
SmsRetriever.SEND_PERMISSION,
null,
ContextCompat.RECEIVER_EXPORTED,
)
}
override fun stopOtpAutofill() {
otpSmsReceiver?.let { runCatching { unregisterReceiver(it) } }
otpSmsReceiver = null
}
override fun onDestroy() {
super.onDestroy()
stopOtpAutofill()
}
}
@@ -0,0 +1,135 @@
package com.approagency.pharmacy.data.billing
import android.app.Activity
import android.content.Context
import com.approagency.pharmacy.BuildConfig
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.billing.PurchaseResult
import com.approagency.pharmacy.domain.model.SubscriptionProduct
import ir.myket.billingclient.IabHelper
import ir.myket.billingclient.util.Purchase
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* درگاهِ پرداختِ کافه‌بازار بر پایهٔ همان کتابخانهٔ `myket-billing-client`
* (IabHelper). فروشگاهِ مقصد با `manifestPlaceholders` در build.gradle مشخص
* می‌شود (com.farsitel.bazaar)، بنابراین کدِ IabHelper یکسان است و فقط مقصدِ
* اتصال و کلیدِ عمومی فرق می‌کند.
*
* جریان:
* 1. اتصال به سرویس بازار ([ensureSetup]).
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
* 3. توکن به سرور فرستاده می‌شود (با gateway = "bazaar") و پس از تأیید، خرید
* [consume] می‌شود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
*
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را می‌پذیرد، همهٔ فراخوانی‌ها
* با [mutex] ترتیبی می‌شوند.
*/
class BazaarPurchaseGateway(
context: Context,
private val session: SessionManager
) : PurchaseGateway {
// مقدارِ معتبرِ درگاه در سمتِ سرورِ آپرواجنسی برای کافه‌بازار «cafe» است.
override val name: String = "cafe"
override val isAvailable: Boolean = true
private val appContext = context.applicationContext
private val mutex = Mutex()
@Volatile private var helper: IabHelper? = null
@Volatile private var setupDone = false
/** خریدهای منتظرِ مصرف، برحسبِ توکن. */
private val pending = mutableMapOf<String, Purchase>()
override suspend fun purchase(
activity: Activity,
product: SubscriptionProduct
): Result<PurchaseResult> = mutex.withLock {
val sku = product.uuid?.takeIf { it.isNotBlank() }
?: return Result.failure(IllegalArgumentException("شناسهٔ محصول (SKU) نامعتبر است."))
ensureSetup().onFailure { return Result.failure(it) }
val helper = helper ?: return Result.failure(IllegalStateException("اتصال به بازار برقرار نشد."))
// developerPayload = شماره موبایلِ کاربر برای شناساییِ خریدار؛ در صورت نبود، توکن تصادفی.
val payload = session.account.value.mobile?.takeIf { it.isNotBlank() }
?: UUID.randomUUID().toString()
val outcome = runCatching {
suspendCancellableCoroutine { cont ->
// محصول‌های بازار از نوع «اشتراک» هستند.
helper.launchSubscriptionPurchaseFlow(
activity,
sku,
IabHelper.OnIabPurchaseFinishedListener { result, purchase ->
when {
result.isSuccess && purchase != null ->
cont.resume(PurchaseOutcome.Success(purchase))
result.response == IabHelper.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED ->
cont.resume(PurchaseOutcome.AlreadyOwned)
else ->
cont.resume(PurchaseOutcome.Error(result.message ?: "خطای پرداخت بازار"))
}
},
payload
)
}
}.getOrElse { return Result.failure(it) }
val purchase = when (outcome) {
is PurchaseOutcome.Success -> outcome.purchase
PurchaseOutcome.AlreadyOwned ->
return Result.failure(IllegalStateException("اشتراک شما از قبل فعال است."))
is PurchaseOutcome.Error -> return Result.failure(IllegalStateException(outcome.message))
}
val token = purchase.token
if (token.isNullOrBlank()) {
return Result.failure(IllegalStateException("توکن خرید دریافت نشد."))
}
pending[token] = purchase
Result.success(PurchaseResult(purchaseToken = token, orderId = purchase.orderId))
}
// اشتراکِ کافه‌بازار مصرف نمی‌شود؛ این متد بی‌اثر است.
override suspend fun consume(purchaseToken: String): Result<Unit> {
pending.remove(purchaseToken)
return Result.success(Unit)
}
/** اتصال به سرویس بازار (یک‌بار). */
private suspend fun ensureSetup(): Result<Unit> {
if (setupDone && helper != null) return Result.success(Unit)
val newHelper = IabHelper(appContext, BuildConfig.IAB_PUBLIC_KEY)
.apply { enableDebugLogging(BuildConfig.DEBUG) }
return runCatching {
suspendCancellableCoroutine { cont ->
newHelper.startSetup(IabHelper.OnIabSetupFinishedListener { result ->
if (result.isSuccess) cont.resume(Unit)
else cont.resumeWithException(
IllegalStateException(result.message ?: "اتصال به بازار ناموفق بود")
)
})
}
}.onSuccess {
helper = newHelper
setupDone = true
}.onFailure {
runCatching { newHelper.dispose() }
}
}
private sealed interface PurchaseOutcome {
data class Success(val purchase: Purchase) : PurchaseOutcome
data object AlreadyOwned : PurchaseOutcome
data class Error(val message: String) : PurchaseOutcome
}
}
@@ -0,0 +1,27 @@
package com.approagency.pharmacy.data.billing
import android.app.Activity
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.billing.PurchaseResult
import com.approagency.pharmacy.domain.model.SubscriptionProduct
/**
* پیاده‌سازی پیش‌فرض درگاه پرداخت روی شاخه‌ی `main`.
*
* هیچ درگاه واقعی‌ای ندارد؛ شاخه‌های `myket` و `bazar` این را با پیاده‌سازی
* فروشگاه خود جایگزین می‌کنند. تا آن زمان، خرید با پیام مناسب ناموفق می‌شود.
*/
class StubPurchaseGateway : PurchaseGateway {
override val name: String = "none"
override val isAvailable: Boolean = false
override suspend fun purchase(
activity: Activity,
product: SubscriptionProduct
): Result<PurchaseResult> =
Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست."))
override suspend fun consume(purchaseToken: String): Result<Unit> = Result.success(Unit)
}
@@ -0,0 +1,89 @@
package com.approagency.pharmacy.data.dto
import com.google.gson.annotations.SerializedName
/** پاسخ ارسال کد یک‌بارمصرف (login-otp). */
data class LoginOtpResponse(
@SerializedName("message") val message: String? = null,
@SerializedName("status") val status: String? = null
)
/** پاسخ تأیید کد (check-otp) که توکن نشست را برمی‌گرداند. */
data class CheckOtpResponse(
@SerializedName("token") val token: String? = null,
@SerializedName("message") val message: String? = null,
@SerializedName("user") val user: UserDto? = null
)
data class UserDto(
@SerializedName("id") val id: Int? = null,
@SerializedName("first_name") val firstName: String? = null,
@SerializedName("last_name") val lastName: String? = null,
@SerializedName("mobile") val mobile: String? = null,
@SerializedName("email") val email: String? = null
)
/** یک محصول اشتراک از `package-names/{name}/products`. */
data class ProductDto(
@SerializedName("id") val id: Int,
@SerializedName("package_name_id") val packageNameId: Int? = null,
@SerializedName("title") val title: String? = null,
@SerializedName("price") val price: Long? = null,
@SerializedName("type") val type: Int? = null,
@SerializedName("uuid") val uuid: String? = null,
@SerializedName("descriptions") val descriptions: String? = null,
@SerializedName("expires_at") val expiresAt: String? = null,
@SerializedName("expire_at") val expireAt: String? = null,
@SerializedName("pivot") val pivot: ProductPivot? = null
) {
/** تاریخ انقضای اشتراکِ این کاربر؛ از pivot یا فیلدهای مستقیمِ محصول. */
val resolvedExpireAt: String?
get() = pivot?.expiresAt ?: pivot?.expireAt ?: expiresAt ?: expireAt
}
/** اطلاعات رابطه‌ی کاربر-محصول (شامل تاریخ انقضای خرید). */
data class ProductPivot(
@SerializedName("expires_at") val expiresAt: String? = null,
@SerializedName("expire_at") val expireAt: String? = null,
@SerializedName("created_at") val createdAt: String? = null
)
/**
* پاسخ `GET /status` که همان پروفایل کاربر به‌همراه فهرست محصولاتِ خریداری‌شده است.
* اشتراک فعال یعنی [products] خالی نباشد؛ عنوان اشتراک از اولین محصول خوانده می‌شود.
*/
data class StatusDto(
@SerializedName("id") val id: Int? = null,
@SerializedName("first_name") val firstName: String? = null,
@SerializedName("last_name") val lastName: String? = null,
@SerializedName("full_name") val fullName: String? = null,
@SerializedName("mobile") val mobile: String? = null,
@SerializedName("email") val email: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("wallet") val wallet: Long? = null,
@SerializedName("products") val products: List<ProductDto>? = null
) {
/** کاربر در صورت داشتن حداقل یک محصول، اشتراک فعال دارد. */
val isSubscribed: Boolean get() = !products.isNullOrEmpty()
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
val subscriptionTitle: String? get() = products?.firstOrNull()?.title
/** تاریخ انقضای اشتراک جاری. */
val subscriptionExpireAt: String? get() = products?.firstOrNull()?.resolvedExpireAt
val displayName: String? get() = fullName?.takeIf { it.isNotBlank() }
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
}
/** بدنه‌ی درخواست خرید اشتراک. */
data class SubscribeRequest(
@SerializedName("purchase_token") val purchaseToken: String,
@SerializedName("gateway") val gateway: String
)
/** پاسخ خرید اشتراک. موفقیت اصلی با کد ۲xx مشخص می‌شود. */
data class SubscribeResponse(
@SerializedName("message") val message: String? = null,
@SerializedName("status") val status: String? = null
)
@@ -1,4 +1,4 @@
package com.approagency.drug.data.dto
package com.approagency.pharmacy.data.dto
data class DarmanModel(
val success : String,
@@ -1,4 +1,4 @@
package com.approagency.drug.data.dto
package com.approagency.pharmacy.data.dto
data class DrugModels(
val success: Boolean,
@@ -0,0 +1,185 @@
package com.approagency.pharmacy.data.local
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import com.approagency.pharmacy.data.dto.DarmanList
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugDetail
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.data.dto.GorohDaroei
import com.approagency.pharmacy.data.dto.GorohDarmani
import com.approagency.pharmacy.data.dto.Meta
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileOutputStream
import kotlin.math.ceil
/**
* منبعِ دادهٔ دارو روی پایگاه‌دادهٔ همراهِ اپ (`assets/DrugStore.db`).
*
* تنها منبعِ اطلاعاتِ دارو (جست‌وجو، جزئیات، گروه‌های درمانی) است و فقط خواندنی
* است. از SQLite خام استفاده می‌کند (نه Room) تا اعتبارسنجیِ سخت‌گیرانهٔ شِمای
* Room روی این دیتابیسِ ثابتِ ازپیش‌ساخته (با کلیدهای خارجی) دردسر نسازد.
*/
class LocalDrugDataSource(private val context: Context) {
@Volatile
private var db: SQLiteDatabase? = null
private fun database(): SQLiteDatabase {
db?.let { return it }
return synchronized(this) {
db ?: openReadOnly().also { db = it }
}
}
private fun openReadOnly(): SQLiteDatabase {
val out = context.getDatabasePath(DB_NAME)
if (!out.exists()) {
out.parentFile?.mkdirs()
context.assets.open(DB_NAME).use { input ->
FileOutputStream(out).use { output -> input.copyTo(output) }
}
}
return SQLiteDatabase.openDatabase(out.path, null, SQLiteDatabase.OPEN_READONLY)
}
/** جست‌وجوی دارو با فیلترهای نام، گروه دارویی و گروه درمانی. */
suspend fun searchDrugs(
query: String?,
drugGroup: Int?,
healGroup: Int?,
perPage: Int
): DrugListResponse = withContext(Dispatchers.IO) {
val where = StringBuilder("1=1")
val args = mutableListOf<String>()
if (!query.isNullOrBlank()) {
where.append(" AND (d.nam_fa LIKE ? OR d.nam_en LIKE ?)")
args.add("%$query%"); args.add("%$query%")
}
if (drugGroup != null) { where.append(" AND d.goroh_daroei_cod = ?"); args.add(drugGroup.toString()) }
if (healGroup != null) { where.append(" AND d.goroh_darmani_cod = ?"); args.add(healGroup.toString()) }
val total = database().rawQuery(
"SELECT COUNT(*) FROM DrugInfo d WHERE $where", args.toTypedArray()
).use { c -> if (c.moveToFirst()) c.getInt(0) else 0 }
val items = database().rawQuery(
"$SELECT_WITH_RELATIONS WHERE $where ORDER BY d.nam_fa LIMIT ?",
(args + perPage.toString()).toTypedArray()
).use { c -> buildList { while (c.moveToNext()) add(c.toDrugDetail()) } }
DrugListResponse(
success = true,
message = null,
data = items,
meta = Meta(
current_page = 1,
last_page = if (perPage > 0) ceil(total.toDouble() / perPage).toInt() else 1,
per_page = perPage,
total = total
)
)
}
/** جزئیات یک دارو بر اساس [cod]. اگر یافت نشود استثناء پرتاب می‌شود. */
suspend fun drugDetail(cod: Int): DrugModels = withContext(Dispatchers.IO) {
val detail = database().rawQuery(
"$SELECT_WITH_RELATIONS WHERE d.cod = ?", arrayOf(cod.toString())
).use { c -> if (c.moveToFirst()) c.toDrugDetail() else null }
?: throw NoSuchElementException("دارویی با شناسهٔ $cod در پایگاه‌دادهٔ محلی یافت نشد.")
DrugModels(success = true, message = null, data = detail, meta = null)
}
/** فهرست گروه‌های درمانی (goroh_darmani). */
suspend fun darmanGroups(): DarmanModel = withContext(Dispatchers.IO) {
val groups = database().rawQuery(
"SELECT cod, nam_fa, nam_en FROM goroh_darmani ORDER BY nam_fa", null
).use { c ->
buildList {
while (c.moveToNext()) {
add(
DarmanList(
cod = c.getInt(0),
nam_fa = c.getStringOrEmpty(1),
nam_en = c.getStringOrEmpty(2)
)
)
}
}
}
DarmanModel(success = "true", message = "", data = groups)
}
private fun Cursor.toDrugDetail(): DrugDetail {
val daroeiCod = getIntOrNull("goroh_daroei_cod")
val darmaniCod = getIntOrNull("goroh_darmani_cod")
return DrugDetail(
cod = getIntOrNull("cod"),
goroh_darmani_detail_cod = getIntOrNull("goroh_darmani_detail_cod"),
goroh_daroei_cod = daroeiCod,
goroh_farmakologic_cod = getIntOrNull("goroh_farmakologic_cod"),
goroh_darmani_cod = darmaniCod,
nam_fa = getStringOrNull("nam_fa"),
nam_en = getStringOrNull("nam_en"),
mavaredmasraf = getStringOrNull("mavaredmasraf"),
meghdarmasraf = getStringOrNull("meghdarmasraf"),
masrafdarhamelegi = getStringOrNull("masrafdarhamelegi"),
masrafdarshirdehi = getStringOrNull("masrafdarshirdehi"),
manemasraf = getStringOrNull("manemasraf"),
avarez = getStringOrNull("avarez"),
tadakhol = getStringOrNull("tadakhol"),
mekanismtasir = getStringOrNull("mekanismtasir"),
nokte = getStringOrNull("nokte"),
hoshdar = getStringOrNull("hoshdar"),
sharayetnegahdari = getStringOrNull("sharayetnegahdari"),
ashkal_daroei = getStringOrNull("ashkal_daroei"),
created_at = null,
updated_at = null,
goroh_daroei = getStringOrNull("gd_nam")?.let { GorohDaroei(cod = daroeiCod, nam = it, created_at = null, updated_at = null) },
goroh_darmani = getStringOrNull("gm_nam_fa")?.let {
GorohDarmani(
cod = darmaniCod,
nam_fa = it,
nam_en = getStringOrNull("gm_nam_en"),
giyahi_ya_shimiyaei = getStringOrNull("gm_giyahi"),
image = getStringOrNull("gm_image"),
created_at = null,
updated_at = null
)
},
goroh_darmani_detail = null
)
}
private fun Cursor.getStringOrNull(name: String): String? {
val i = getColumnIndex(name)
return if (i >= 0 && !isNull(i)) getString(i) else null
}
private fun Cursor.getStringOrEmpty(index: Int): String =
if (!isNull(index)) getString(index) else ""
private fun Cursor.getIntOrNull(name: String): Int? {
val i = getColumnIndex(name)
return if (i >= 0 && !isNull(i)) getInt(i) else null
}
private companion object {
const val DB_NAME = "DrugStore.db"
const val SELECT_WITH_RELATIONS = """
SELECT d.*,
gd.nam AS gd_nam,
gm.nam_fa AS gm_nam_fa,
gm.nam_en AS gm_nam_en,
gm.giyahi_ya_shimiyaei AS gm_giyahi,
gm.image AS gm_image
FROM DrugInfo d
LEFT JOIN goroh_daroei gd ON gd.cod = d.goroh_daroei_cod
LEFT JOIN goroh_darmani gm ON gm.cod = d.goroh_darmani_cod
"""
}
}
@@ -0,0 +1,167 @@
package com.approagency.pharmacy.data.local
import android.content.Context
import android.content.SharedPreferences
import com.approagency.pharmacy.domain.model.FontScale
import com.approagency.pharmacy.domain.model.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** وضعیت حساب کاربر که در کل اپ مشاهده می‌شود. */
data class AccountState(
val isLoggedIn: Boolean = false,
val mobile: String? = null,
val displayName: String? = null,
val isSubscribed: Boolean = false,
val subscriptionTitle: String? = null,
val subscriptionExpireAt: String? = null
)
/**
* تنها منبعِ حقیقتِ نشست کاربر روی دستگاه (SharedPreferences).
*
* مسئولیت‌ها:
* - توکن احراز هویت آپرواجنسی
* - شمارنده‌ی جستجوهای رایگان دارویاب
* - کشِ پایدارِ وضعیت حساب/اشتراک ([AccountState]) که در کل اپ به‌صورت واکنشی
* مشاهده می‌شود و هنگام شروع اپ یا بازگشت از درگاه پرداخت به‌روزرسانی می‌گردد
*/
class SessionManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// ---------- توکن احراز هویت ----------
private var token: String? = prefs.getString(KEY_TOKEN, null)
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
fun saveToken(token: String) {
this.token = token
prefs.edit().putString(KEY_TOKEN, token).apply()
publishAccount()
}
fun getToken(): String? = token
// ---------- شمارنده‌ی جستجوی رایگان ----------
private val _freeSearchCount = MutableStateFlow(prefs.getInt(KEY_FREE_SEARCH_COUNT, 0))
val freeSearchCount: StateFlow<Int> = _freeSearchCount.asStateFlow()
fun incrementFreeSearchCount() {
val next = _freeSearchCount.value + 1
prefs.edit().putInt(KEY_FREE_SEARCH_COUNT, next).apply()
_freeSearchCount.value = next
}
fun resetFreeSearchCount() {
prefs.edit().putInt(KEY_FREE_SEARCH_COUNT, 0).apply()
_freeSearchCount.value = 0
}
fun testForIncrease (){
prefs.edit().putInt(KEY_FREE_SEARCH_COUNT, -1).apply()
_freeSearchCount.value = -1
}
// ---------- وضعیت حساب/اشتراک (پایدار و واکنشی) ----------
private val _account = MutableStateFlow(readAccount())
val account: StateFlow<AccountState> = _account.asStateFlow()
val isSubscribed: Boolean get() = _account.value.isSubscribed
/** به‌روزرسانی وضعیت حساب از روی پاسخ `/status` و ذخیره‌ی پایدار آن. */
fun updateAccount(
mobile: String?,
displayName: String?,
isSubscribed: Boolean,
subscriptionTitle: String?,
subscriptionExpireAt: String?
) {
prefs.edit()
.putString(KEY_MOBILE, mobile)
.putString(KEY_DISPLAY_NAME, displayName)
.putBoolean(KEY_IS_SUBSCRIBED, isSubscribed)
.putString(KEY_SUB_TITLE, subscriptionTitle)
.putString(KEY_SUB_EXPIRE, subscriptionExpireAt)
.apply()
publishAccount()
}
/** ذخیره‌ی شماره‌ی موبایل واردشده (پیش از تکمیل ورود). */
fun saveMobile(mobile: String) {
prefs.edit().putString(KEY_MOBILE, mobile).apply()
publishAccount()
}
/** پاک‌سازی کامل نشست هنگام خروج از حساب. */
fun clear() {
token = null
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_MOBILE)
.remove(KEY_DISPLAY_NAME)
.remove(KEY_IS_SUBSCRIBED)
.remove(KEY_SUB_TITLE)
.remove(KEY_SUB_EXPIRE)
.apply()
publishAccount()
}
private fun publishAccount() {
_account.value = readAccount()
}
// ---------- حالت نمایش (تم) ----------
private val _themeMode = MutableStateFlow(readThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
private fun readThemeMode(): ThemeMode =
runCatching { ThemeMode.valueOf(prefs.getString(KEY_THEME_MODE, null) ?: ThemeMode.SYSTEM.name) }
.getOrDefault(ThemeMode.SYSTEM)
// ---------- اندازهٔ متن ----------
private val _fontScale = MutableStateFlow(readFontScale())
val fontScale: StateFlow<FontScale> = _fontScale.asStateFlow()
fun setFontScale(scale: FontScale) {
prefs.edit().putString(KEY_FONT_SCALE, scale.name).apply()
_fontScale.value = scale
}
private fun readFontScale(): FontScale =
runCatching { FontScale.valueOf(prefs.getString(KEY_FONT_SCALE, null) ?: FontScale.NORMAL.name) }
.getOrDefault(FontScale.NORMAL)
private fun readAccount() = AccountState(
isLoggedIn = !token.isNullOrBlank(),
mobile = prefs.getString(KEY_MOBILE, null),
displayName = prefs.getString(KEY_DISPLAY_NAME, null),
isSubscribed = prefs.getBoolean(KEY_IS_SUBSCRIBED, false),
subscriptionTitle = prefs.getString(KEY_SUB_TITLE, null),
subscriptionExpireAt = prefs.getString(KEY_SUB_EXPIRE, null)
)
private companion object {
const val PREFS_NAME = "approagency_session"
const val KEY_TOKEN = "auth_token"
const val KEY_MOBILE = "mobile"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_FREE_SEARCH_COUNT = "free_search_count"
const val KEY_IS_SUBSCRIBED = "is_subscribed"
const val KEY_SUB_TITLE = "subscription_title"
const val KEY_SUB_EXPIRE = "subscription_expire_at"
const val KEY_THEME_MODE = "theme_mode"
const val KEY_FONT_SCALE = "font_scale"
}
}
@@ -1,10 +1,10 @@
package com.approagency.drug.data.local.dao
package com.approagency.pharmacy.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
import kotlinx.coroutines.flow.Flow
@Dao
@@ -1,10 +1,10 @@
package com.approagency.drug.data.local.dao
package com.approagency.pharmacy.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.approagency.drug.data.local.entities.TestItemEntity
import com.approagency.pharmacy.data.local.entities.TestItemEntity
import kotlinx.coroutines.flow.Flow
@Dao
@@ -1,13 +1,13 @@
package com.approagency.drug.data.local.database
package com.approagency.pharmacy.data.local.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.approagency.drug.data.local.dao.TestGroupDao
import com.approagency.drug.data.local.dao.TestItemDao
import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.drug.data.local.entities.TestItemEntity
import com.approagency.pharmacy.data.local.dao.TestGroupDao
import com.approagency.pharmacy.data.local.dao.TestItemDao
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
import com.approagency.pharmacy.data.local.entities.TestItemEntity
@Database(
entities = [
@@ -1,4 +1,4 @@
package com.approagency.drug.data.local.entities
package com.approagency.pharmacy.data.local.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
@@ -1,8 +1,7 @@
package com.approagency.drug.data.local.entities
package com.approagency.pharmacy.data.local.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@@ -0,0 +1,61 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.data.dto.CheckOtpResponse
import com.approagency.pharmacy.data.dto.LoginOtpResponse
import com.approagency.pharmacy.data.dto.ProductDto
import com.approagency.pharmacy.data.dto.StatusDto
import com.approagency.pharmacy.data.dto.SubscribeRequest
import com.approagency.pharmacy.data.dto.SubscribeResponse
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/** بک‌اند اشتراک/احراز هویت آپرواجنسی ([Config.AUTH_BASE_URL]). */
interface ApproApiService {
/** ارسال کد یک‌بارمصرف به موبایل. */
@FormUrlEncoded
@POST("auth/login-otp")
suspend fun loginOtp(
@Field("mobile") mobile: String,
@Field("package_name") packageName: String,
@Field("fcm_token") fcmToken: String? = null
): LoginOtpResponse
/** تأیید کد و دریافت توکن نشست. */
@FormUrlEncoded
@POST("auth/check-otp")
suspend fun checkOtp(
@Field("mobile") mobile: String,
@Field("token") token: String
): CheckOtpResponse
/** خروج از حساب (توکن از طریق اینترسپتور ارسال می‌شود). */
@PUT("auth/logout")
suspend fun logout()
/** وضعیت اشتراک کاربر برای این پکیج. */
@GET("status")
suspend fun getStatus(
@Query("package_name") packageName: String
): StatusDto
/** فهرست محصولات اشتراک قابل خرید برای این پکیج. */
@GET("package-names/{name}/products")
suspend fun getProducts(
@Path("name") packageName: String
): List<ProductDto>
/** ثبت خرید اشتراک پس از پرداخت موفق در درگاه (مایکت/بازار). */
@PUT("package-names/{name}/products/{product_id}/subscribe")
suspend fun subscribe(
@Path("name") packageName: String,
@Path("product_id") productId: Int,
@Body body: SubscribeRequest
): SubscribeResponse
}
@@ -0,0 +1,28 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.data.local.SessionManager
import okhttp3.Interceptor
import okhttp3.Response
/**
* هدرهای لازم برای بک‌اند آپرواجنسی را اضافه می‌کند:
* - `Accept: application/json`
* - `Authorization: Bearer <token>` در صورت وجود توکن
*
* این اینترسپتور فقط روی کلاینت اختصاصیِ آپرواجنسی نصب می‌شود؛ بنابراین توکن
* هرگز به دارویاب یا بک‌اند جستجوی دارو ارسال نمی‌شود.
*/
class AuthInterceptor(
private val sessionManager: SessionManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().newBuilder()
.header("Accept", "application/json")
sessionManager.getToken()?.takeIf { it.isNotBlank() }?.let { token ->
builder.header("Authorization", "Bearer $token")
}
return chain.proceed(builder.build())
}
}
@@ -1,4 +1,4 @@
package com.approagency.drug.data.remote
package com.approagency.pharmacy.data.remote
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
@@ -6,7 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
interface DarooyabApiService {
@FormUrlEncoded
@@ -32,4 +32,29 @@ interface DarooyabApiService {
suspend fun getDrugDetail(
@Path(value = "detailUrl", encoded = true) detailUrl: String
): String
@GET
@Headers(
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9",
"Accept-Language: en-US,en;q=0.9,fa;q=0.8"
)
// @GET("{pharmacyPath}")
suspend fun getPharmacyDetail(
@Url url: String
): String
@POST("Home/partialPatientReferralList")
@FormUrlEncoded
@Headers(
"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 getPharmacies(
@Field("brandIrc") brandIrc: String,
@Field("genericDrugId") genericDrugId: String,
@Field("provId") provId: String,
@Field("cityId") cityId: String
): String // returns HTML
}
@@ -1,10 +1,9 @@
package com.approagency.drug.data.remote
package com.approagency.pharmacy.data.remote
import com.approagency.drug.domain.model.*
import com.approagency.pharmacy.domain.model.*
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Evaluator
class DrugDetailParser {
@@ -87,8 +86,9 @@ class DrugDetailParser {
private fun extractPersianName(document: Document): String {
val titleElement = document.select("h1.EnglishNumericFont").first()
val fullText = titleElement?.text() ?: ""
// Remove the suffix "چیست و برای چه مواردی استفاده می شود؟"
return fullText.replace(Regex("\\s*چیست و برای چه مواردی استفاده می شود\\?\\s*"), "").trim()
val regexFullText = fullText.replace(Regex("\\s*چیست و برای چه مواردی استفاده می شود؟\\s*"), "").trim()
println("regexFullText: $regexFullText")
return regexFullText
}
private fun extractEnglishName(document: Document): String {
@@ -535,23 +535,94 @@ class DrugDetailParser {
* Converts block elements to newlines for better readability.
*/
private fun cleanText(element: Element): String {
// Clone to avoid affecting the original
val clone = element.clone()
val output = StringBuilder()
// Replace block-level elements with newlines for structure
clone.select("p, div, h2, h3, h4, li, br").before("\n")
// Process all nodes in order (both elements and text nodes)
processNode(clone, output)
// Get the text and clean it up
var text = clone.text()
.replace(Regex("\\n\\s*\\n+"), "\n\n") // Collapse multiple newlines
.replace(Regex("[ \\t]+"), " ") // Collapse spaces/tabs
return output.toString()
.replace(Regex("\\n{3,}"), "\n\n") // Collapse 3+ newlines to 2
.trim()
// Ensure lists look decent
if (element.tagName() == "li") {
text = "$text"
}
return text
/**
* Recursively process nodes to build formatted text
*/
private fun processNode(node: org.jsoup.nodes.Node, output: StringBuilder) {
when (node) {
is Element -> {
when (node.tagName()) {
"h2" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("\n\n📌 $text\n\n")
output.append("".repeat(minOf(30, text.length))).append("\n\n")
}
}
"h3" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("\n\n🔹 $text\n\n")
}
}
"h4" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("\n$text\n")
}
}
"p" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append(text).append("\n\n")
}
}
"ul", "ol" -> {
for (li in node.select("li")) {
val liText = li.text().trim()
if (liText.isNotEmpty()) {
output.append("$liText\n")
}
}
output.append("\n")
}
"li" -> {
// Handled by ul/ol processing, but if standalone:
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("$text\n")
}
}
"br" -> {
output.append("\n")
}
"strong", "b" -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append("**$text**")
}
}
"div", "section", "article" -> {
// Process children of container elements
for (child in node.childNodes()) {
processNode(child, output)
}
}
else -> {
// For other elements, just process their children
for (child in node.childNodes()) {
processNode(child, output)
}
}
}
}
is org.jsoup.nodes.TextNode -> {
val text = node.text().trim()
if (text.isNotEmpty()) {
output.append(text)
}
}
}
}
}
@@ -1,7 +1,7 @@
package com.approagency.drug.data.remote
package com.approagency.pharmacy.data.remote
import com.approagency.drug.domain.model.DaroYabSearchResult
import com.approagency.drug.domain.model.DrugSearchResult
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.model.DrugSearchResult
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
@@ -0,0 +1,168 @@
// data/remote/PharmacyHtmlParser.kt
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
import org.jsoup.Jsoup
object PharmacyHtmlParser {
// private const val BASE_URL = "https://www.darooyab.ir"
fun parse(html: String): List<PharmacyItem> {
val items = mutableListOf<PharmacyItem>()
val doc = Jsoup.parse(html)
val table = doc.select("table#TBL_patientReferral").first() ?: return emptyList()
// ساختار جدول متغیر است:
// - صفحات ژنریک: [نام برند, نام داروخانه, شهرستان] (گاهی با ستون «تضمین موجودی»)
// - صفحات برند: [نام داروخانه, شهرستان] (بدون ستون برند)
// بنابراین ایندکس ستون‌ها را از روی متن سربرگ تشخیص می‌دهیم نه موقعیت ثابت
val headers = table.select("thead th")
var brandIndex = -1
var pharmacyIndex = -1
var locationIndex = -1
headers.forEachIndexed { index, th ->
val text = th.text().trim()
when {
brandIndex == -1 && text.contains("برند") -> brandIndex = index
pharmacyIndex == -1 && text.contains("داروخانه") -> pharmacyIndex = index
locationIndex == -1 && (text.contains("شهرستان") || text.contains("استان")) -> locationIndex = index
}
}
val rows = table.select("tbody tr")
for (row in rows) {
val cells = row.select("td")
if (cells.isEmpty()) continue
// ستون داروخانه (ضروری) — اگر از سربرگ پیدا نشد، سلولی که لینک ph- دارد
val pIdx = if (pharmacyIndex in cells.indices) {
pharmacyIndex
} else {
cells.indexOfFirst { cell ->
cell.select("a").any { it.attr("href").contains("ph-") }
}
}
if (pIdx < 0) continue
val pharmacyCell = cells[pIdx]
// پیدا کردن لینک داروخانه (لینکی که "اطلاعات تماس" نباشد)
val pharmacyLink = pharmacyCell.select("a").firstOrNull {
!it.text().contains("اطلاعات تماس")
}
val pharmacyName = pharmacyLink?.text()?.trim() ?: ""
val pharmacyUrl = normalizeUrl(pharmacyLink?.attr("href")?.trim() ?: "")
val pharmacyId = extractIdFromUrl(pharmacyUrl, "/ph-(\\d+)/?")
if (pharmacyName.isBlank()) continue
// ستون برند (در صفحات برند وجود ندارد)
val brandCell = brandIndex.takeIf { it in cells.indices }?.let { cells[it] }
val brandLink = brandCell?.select("a")?.first()
val brandName = brandLink?.text()?.trim() ?: ""
val brandUrl = normalizeUrl(brandLink?.attr("href")?.trim() ?: "")
val brandId = extractIdFromUrl(brandUrl, "/B-(\\d+)/?")
// ستون موقعیت — اگر از سربرگ پیدا نشد، سلولی که «استان» دارد
val locIdx = if (locationIndex in cells.indices) {
locationIndex
} else {
cells.indexOfFirst { it.text().contains("استان") }
}
val locationText = locIdx.takeIf { it in cells.indices }?.let { cells[it].text().trim() } ?: ""
val province = extractProvince(locationText)
val city = extractCity(locationText)
items.add(
PharmacyItem(
brandName = brandName,
brandUrl = brandUrl,
brandId = brandId,
pharmacyName = pharmacyName,
pharmacyUrl = pharmacyUrl,
pharmacyId = pharmacyId,
province = province,
city = city
)
)
}
return items
}
/**
* نرمال کردن URL: تبدیل مسیرهای نسبی به URL کامل
* مثال: ~/../../../../../ph-12012/... -> https://www.darooyab.ir/ph-12012/...
*/
private fun normalizeUrl(url: String): String {
if (url.isBlank()) return ""
// اگر absolute بود domain را حذف کن
if (url.startsWith("http://") || url.startsWith("https://")) {
return url
.replace("https://www.darooyab.ir", "")
.replace("http://www.darooyab.ir", "")
}
var cleanUrl = url
if (cleanUrl.startsWith("~/")) {
cleanUrl = cleanUrl.removePrefix("~/")
}
while (cleanUrl.startsWith("../")) {
cleanUrl = cleanUrl.removePrefix("../")
}
return if (cleanUrl.startsWith("/")) {
cleanUrl
} else {
"/$cleanUrl"
}
}
private fun extractProvince(locationText: String): String {
// الگو: "استان تهران" یا "استان اصفهان"
val provincePattern = "استان\\s*(\\S+)".toRegex()
val match = provincePattern.find(locationText)
return match?.groupValues?.get(1)?.trim() ?: ""
}
private fun extractCity(locationText: String): String {
// الگو: "شهر تهران" یا "شهر اصفهان" یا "شهر پردیس"
val cityPattern = "شهر\\s*(.+)".toRegex()
val match = cityPattern.find(locationText)
return match?.groupValues?.get(1)?.trim() ?: ""
}
private fun extractIdFromUrl(url: String, pattern: String): String {
val regex = pattern.toRegex()
return regex.find(url)?.groupValues?.get(1) ?: ""
}
fun parsePharmacyDetail(html: String): PharmacyDetail {
val doc = Jsoup.parse(html)
// استخراج نام داروخانه
val pharmacyTitle = doc.select("h2#pharmacyTitle").first()?.text()?.trim() ?: ""
// استخراج آدرس
val addressElement = doc.select("h2#h2Address").first()
val address = addressElement?.text()?.trim() ?: ""
// استخراج تلفن
val phoneElement = doc.select("h3#h3Phone a").first()
val phone = phoneElement?.text()?.trim() ?: ""
// استخراج دانشگاه (اختیاری)
val university = doc.select("div.alert.alert-success > span").first()?.text()?.trim()
return PharmacyDetail(
name = pharmacyTitle,
address = address,
phone = phone,
university = university
)
}
}
@@ -1,4 +1,4 @@
package com.approagency.drug.data.remote
package com.approagency.pharmacy.data.remote
import okhttp3.ResponseBody
import retrofit2.Converter
@@ -0,0 +1,114 @@
package com.approagency.pharmacy.data.repository
import com.approagency.pharmacy.data.dto.SubscribeRequest
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.data.remote.ApproApiService
import com.approagency.pharmacy.domain.model.SubscriptionProduct
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.utils.Config
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AuthRepositoryImpl(
private val api: ApproApiService,
private val session: SessionManager
) : AuthRepository {
override suspend fun loginOtp(mobile: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
api.loginOtp(mobile = mobile, packageName = Config.PACKAGE_NAME)
session.saveMobile(mobile)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun checkOtp(mobile: String, code: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val response = api.checkOtp(mobile = mobile, token = code)
val token = response.token
if (token.isNullOrBlank()) {
Result.failure(IllegalStateException("توکن دریافت نشد."))
} else {
session.saveToken(token)
session.saveMobile(mobile)
// بلافاصله وضعیت اشتراک را بخوان تا حساب به‌روز شود.
runCatching { fetchAndStoreStatus() }
Result.success(Unit)
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout(): Result<Unit> = withContext(Dispatchers.IO) {
try {
runCatching { api.logout() } // حتی اگر سرور خطا داد، نشست محلی پاک شود
session.clear()
Result.success(Unit)
} catch (e: Exception) {
session.clear()
Result.failure(e)
}
}
override suspend fun refreshStatus(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
Result.success(fetchAndStoreStatus())
} catch (e: Exception) {
Result.failure(e)
}
}
/** `/status` را می‌خواند، حساب را ذخیره می‌کند و وضعیت اشتراک را برمی‌گرداند. */
private suspend fun fetchAndStoreStatus(): Boolean {
val dto = api.getStatus(packageName = Config.PACKAGE_NAME)
session.updateAccount(
mobile = dto.mobile ?: session.account.value.mobile,
displayName = dto.displayName,
isSubscribed = dto.isSubscribed,
subscriptionTitle = dto.subscriptionTitle,
subscriptionExpireAt = dto.subscriptionExpireAt
)
return dto.isSubscribed
}
override suspend fun getProducts(): Result<List<SubscriptionProduct>> =
withContext(Dispatchers.IO) {
try {
val products = api.getProducts(packageName = Config.PACKAGE_NAME).map {
SubscriptionProduct(
id = it.id,
title = it.title ?: "",
price = it.price ?: 0L,
uuid = it.uuid,
description = it.descriptions
)
}
Result.success(products)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun subscribe(
productId: Int,
purchaseToken: String,
gateway: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
api.subscribe(
packageName = Config.PACKAGE_NAME,
productId = productId,
body = SubscribeRequest(purchaseToken = purchaseToken, gateway = gateway)
)
// پس از ثبت خرید، وضعیت واقعی را از سرور بخوان.
runCatching { fetchAndStoreStatus() }
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,121 @@
package com.approagency.pharmacy.data.repository
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.data.local.LocalDrugDataSource
import com.approagency.pharmacy.data.remote.DarooyabApiService
import com.approagency.pharmacy.data.remote.DrugDetailParser
import com.approagency.pharmacy.data.remote.DrugHtmlParser
import com.approagency.pharmacy.data.remote.PharmacyHtmlParser
import com.approagency.pharmacy.domain.model.DaroYabParams
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
import com.approagency.pharmacy.domain.repository.DrugRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
class DrugRepositoryImpl(
private val darooyabApiService: DarooyabApiService,
private val parser: DrugHtmlParser,
private val detailParser: DrugDetailParser,
private val localDrugDataSource: LocalDrugDataSource,
): DrugRepository {
// اطلاعات دارو همیشه از پایگاه‌دادهٔ محلیِ همراهِ اپ (assets/DrugStore.db) خوانده می‌شود.
override suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse> {
return try {
Result.success(
localDrugDataSource.searchDrugs(
query = params.query,
drugGroup = params.drugGroup,
healGroup = params.healGroup,
perPage = params.perPage
)
)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun drugDetail(cod: Int): Result<DrugModels> {
return try {
Result.success(localDrugDataSource.drugDetail(cod))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getGorohDaroei(): Result<DarmanModel> {
return try {
Result.success(localDrugDataSource.darmanGroups())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun searchDrugs(params: DaroYabParams): Result<DaroYabSearchResult> {
return withContext(Dispatchers.IO) {
try {
val htmlResponse = darooyabApiService.searchDrugs(
searchText = params.query ?: "",
pageNumber = params.pageNumber
)
val searchResult = parser.parseSearchResultsWithPagination(htmlResponse)
Result.success(searchResult)
} catch (e: IOException) {
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}
}
override suspend fun getDrugDetailFromYab(detailUrl: String): Result<DrugDetail> {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getDrugDetail(detailUrl)
val detail = detailParser.parseDrugDetail(html)
Result.success(detail)
} catch (e: Exception) {
Result.failure(e)
}
}
}
override suspend fun searchPharmacies(
genericDrugId: String,
brandIrc: String,
provId: String
): List<PharmacyItem> {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getPharmacies(
brandIrc = brandIrc,
genericDrugId = genericDrugId,
provId = provId,
cityId = "0"
)
PharmacyHtmlParser.parse(html)
} catch (e: Exception) {
emptyList()
}
}
}
override suspend fun getPharmacyDetail(pharmacyUrl: String): PharmacyDetail {
return withContext(Dispatchers.IO) {
try {
val html = darooyabApiService.getPharmacyDetail(pharmacyUrl)
PharmacyHtmlParser.parsePharmacyDetail(html)
} catch (e: Exception) {
PharmacyDetail("", "", "")
}
}
}
}
@@ -1,9 +1,9 @@
package com.approagency.drug.data.repository
package com.approagency.pharmacy.data.repository
import com.approagency.drug.data.local.dao.TestGroupDao
import com.approagency.drug.data.local.dao.TestItemDao
import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.drug.data.local.entities.TestItemEntity
import com.approagency.pharmacy.data.local.dao.TestGroupDao
import com.approagency.pharmacy.data.local.dao.TestItemDao
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
import com.approagency.pharmacy.data.local.entities.TestItemEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -0,0 +1,208 @@
package com.approagency.pharmacy.di
import com.approagency.pharmacy.data.billing.BazaarPurchaseGateway
import com.approagency.pharmacy.data.local.LocalDrugDataSource
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.data.local.database.LabDatabase
import com.approagency.pharmacy.data.remote.ApproApiService
import com.approagency.pharmacy.data.remote.AuthInterceptor
import com.approagency.pharmacy.data.remote.DarooyabApiService
import com.approagency.pharmacy.data.remote.DrugDetailParser
import com.approagency.pharmacy.data.remote.DrugHtmlParser
import com.approagency.pharmacy.data.repository.AuthRepositoryImpl
import com.approagency.pharmacy.data.repository.DrugRepositoryImpl
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.domain.repository.DrugRepository
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
import com.approagency.pharmacy.domain.usecase.GetDarmanUseCase
import com.approagency.pharmacy.domain.usecase.GetDrugDetailUseCase
import com.approagency.pharmacy.domain.usecase.GetDrugSearchUseCase
import com.approagency.pharmacy.domain.usecase.GetPharmaciesUseCase
import com.approagency.pharmacy.domain.usecase.GetPharmacyDetailUseCase
import com.approagency.pharmacy.domain.usecase.GetTestGroupUseCase
import com.approagency.pharmacy.domain.usecase.GetTestItemByGroupId
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase
import com.approagency.pharmacy.presentation.account.AccountSheetController
import com.approagency.pharmacy.presentation.account.OtpAutoFillBus
import com.approagency.pharmacy.presentation.legal.TermsController
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
import com.approagency.pharmacy.presentation.viewModel.LabViewModel
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
import com.approagency.pharmacy.utils.Config
import com.approagency.pharmacy.presentation.viewModel.SearchViewModel
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.qualifier
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 scalarRetrofitQualifier = qualifier("scalarRetrofit")
val authRetrofitQualifier = qualifier("authRetrofit")
val appModule= module {
single { LabDatabase.getInstance(androidContext()) }
// نشست کاربر روی دستگاه (توکن، موبایل، سهمیه‌ی جستجوی رایگان، وضعیت اشتراک)
single { SessionManager(androidContext()) }
single { get<LabDatabase>().testGroupDao() }
single { get<LabDatabase>().testItemDao() }
// Shared OkHttpClient for both APIs
single {
val cookieManager = CookieManager()
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL)
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
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 DAROOYAB Website ==========
single<Retrofit>(scalarRetrofitQualifier) { // Add qualifier
Retrofit.Builder()
.baseUrl(Config.Darro_Url)
.client(get<OkHttpClient>())
.addConverterFactory(ScalarsConverterFactory.create())
.build()
}
single<DarooyabApiService> {
val retrofit: Retrofit = get(scalarRetrofitQualifier) // Get qualified instance
retrofit.create(DarooyabApiService::class.java)
}
// ========== Retrofit for APPROAGENCY auth/subscription backend ==========
// کلاینت مجزا با AuthInterceptor تا توکن فقط به این بک‌اند ارسال شود.
single<Retrofit>(authRetrofitQualifier) {
val authClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(get()))
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
Retrofit.Builder()
.baseUrl(Config.AUTH_BASE_URL)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single<ApproApiService> {
val retrofit: Retrofit = get(authRetrofitQualifier)
retrofit.create(ApproApiService::class.java)
}
single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
// درگاه پرداخت: روی شاخهٔ bazar پیاده‌سازی کافه‌بازار.
single<PurchaseGateway> { BazaarPurchaseGateway(androidContext(), get()) }
factory { DrugHtmlParser() }
factory { DrugDetailParser() }
// منبع آفلاین دارو (assets/DrugStore.db) برای fallback هنگام خطای شبکه
single { LocalDrugDataSource(androidContext()) }
//repo
single<DrugRepository> {
DrugRepositoryImpl(get<DarooyabApiService>() , get() , get() , get())
}
//UseCase
single {
GetDrugSearchUseCase(get())
}
single {
SearchDrugsYabUseCase(get())
}
single{
GetDrugDetailUseCase(get())
}
single {
GetDarmanUseCase(get())
}
single {
GetTestGroupUseCase(get())
}
single {
GetPharmaciesUseCase(get())
}
single {
GetTestItemByGroupId(get())
}
single { LabRepositoryImpl(get(), get()) }
single {
DrugDetailYabUseCase(get())
}
single {
GetPharmacyDetailUseCase(get())
}
viewModel {
DrugDetailViewModel(get())
}
single { SearchTestsUseCase(get()) }
//view model
viewModel {
HomeViewModel(get() , get() , get())
}
viewModel {
SearchViewModel(get(), get(), get())
}
// کنترلر سراسری نمایش شیت حساب
single { AccountSheetController() }
// پلِ تحویل کدِ خوانده‌شده از پیامک به شیت ورود
single { OtpAutoFillBus() }
// کنترلر نمایش صفحهٔ شرایط و قوانین
single { TermsController() }
viewModel {
AccountViewModel(get(), get(), get())
}
viewModel {
PharmacyViewModel(get() , get ())
}
viewModel {
LabViewModel(get() , get() , get())
}
}
@@ -0,0 +1,42 @@
package com.approagency.pharmacy.domain.billing
import android.app.Activity
import com.approagency.pharmacy.domain.model.SubscriptionProduct
/**
* انتزاع درگاه پرداخت درون‌برنامه‌ای.
*
* این اینترفیس مرز میان منطقِ مشترکِ اشتراک و پیاده‌سازیِ مخصوصِ هر فروشگاه است:
* - شاخه‌ی `myket` → پیاده‌سازی مایکت (Myket IAB)
* - شاخه‌ی `bazar` → پیاده‌سازی کافه‌بازار (Poolakey)
*
* جریان: درگاه خرید را انجام می‌دهد و یک [PurchaseResult.purchaseToken] برمی‌گرداند؛
* سپس لایه‌ی اشتراک آن توکن را با `gateway = [name]` به سرور آپرواجنسی می‌فرستد.
*/
interface PurchaseGateway {
/** نام درگاه که به سرور ارسال می‌شود (مثلاً "myket" یا "bazaar"). */
val name: String
/** آیا این درگاه روی این بیلد فعال/پیکربندی شده است؟ */
val isAvailable: Boolean
/**
* خرید [product] را در فروشگاه آغاز می‌کند.
* نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد.
*/
suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result<PurchaseResult>
/**
* مصرفِ خرید پس از تأییدِ سرور، تا کاربر بتواند دوباره (دورهٔ بعد) خرید کند.
* فقط برای درگاه‌هایی که مدلِ مصرف‌شدنی دارند (مثل مایکت) معنا دارد؛ برای
* بقیه می‌تواند بی‌اثر باشد.
*/
suspend fun consume(purchaseToken: String): Result<Unit>
}
/** نتیجه‌ی یک خرید موفق در فروشگاه. */
data class PurchaseResult(
val purchaseToken: String,
val orderId: String? = null
)
@@ -1,4 +1,4 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
data class DaroYabParams(
val query: String? = null,
@@ -1,4 +1,4 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
data class DaroYabSearchResult(
val drugs: List<DrugSearchResult>,
@@ -1,4 +1,4 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
data class DrugDetail(
val genericId: String,
@@ -1,4 +1,4 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
data class DrugSearchParams(
val query: String? = null,
@@ -1,4 +1,4 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
data class DrugSearchResult(
val genericId: String, // e.g., "2556" from "/G-2556/Casanthranol"
@@ -0,0 +1,11 @@
package com.approagency.pharmacy.domain.model
/**
* اندازهٔ متنِ انتخابیِ کاربر (به‌ویژه برای کاربرانِ مسن‌تر).
* [scale] ضریبی است که روی fontScaleِ دستگاه اعمال می‌شود.
*/
enum class FontScale(val scale: Float, val label: String) {
NORMAL(1.0f, "معمولی"),
LARGE(1.2f, "بزرگ"),
XLARGE(1.45f, "خیلی بزرگ")
}
@@ -0,0 +1,31 @@
package com.approagency.pharmacy.domain.model
data class PharmacySearchRequest(
val brandIrc: String = "0", // برای صفحات برند
val genericDrugId: String, // برای صفحات ژنریک
val provId: String, // ID استان (0 برای همه)
val cityId: String = "0" // ID شهر (0 برای همه)
)
data class PharmacyItem(
val brandName: String, // نام برند دارو
val brandUrl: String, // لینک صفحه برند
val brandId: String, // IRC برند
val pharmacyName: String, // نام داروخانه
val pharmacyUrl: String, // لینک داروخانه
val pharmacyId: String, // ID داروخانه
val province: String, // نام استان
val city: String , // نام شهر
val address: String? = null,
val phone: String? = null
)
// مدل برای اطلاعات کامل داروخانه
data class PharmacyDetail(
val name: String,
val address: String,
val phone: String,
val university: String? = null
)
@@ -0,0 +1,10 @@
package com.approagency.pharmacy.domain.model
/** محصول اشتراک قابل‌نمایش در صفحه‌ی خرید. */
data class SubscriptionProduct(
val id: Int,
val title: String,
val price: Long,
val uuid: String?,
val description: String?
)
@@ -1,6 +1,6 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
import com.approagency.drug.data.local.entities.TestGroupEntity
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
data class TestGroup(
val id: Int,
@@ -1,6 +1,6 @@
package com.approagency.drug.domain.model
package com.approagency.pharmacy.domain.model
import com.approagency.drug.data.local.entities.TestItemEntity
import com.approagency.pharmacy.data.local.entities.TestItemEntity
data class TestItem(
val id: Int,
@@ -0,0 +1,4 @@
package com.approagency.pharmacy.domain.model
/** حالت نمایش انتخابی کاربر؛ پیش‌فرض پیروی از سیستم است. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }
@@ -0,0 +1,27 @@
package com.approagency.pharmacy.domain.repository
import com.approagency.pharmacy.domain.model.SubscriptionProduct
interface AuthRepository {
/** ارسال کد یک‌بارمصرف به [mobile]. */
suspend fun loginOtp(mobile: String): Result<Unit>
/** تأیید [code] برای [mobile]؛ در صورت موفقیت توکن ذخیره می‌شود. */
suspend fun checkOtp(mobile: String, code: String): Result<Unit>
/** خروج از حساب و پاک‌سازی نشست. */
suspend fun logout(): Result<Unit>
/**
* خواندن `/status` و به‌روزرسانی [SessionManager.account].
* مقدار بازگشتی نشان می‌دهد کاربر اشتراک فعال دارد یا نه.
*/
suspend fun refreshStatus(): Result<Boolean>
/** فهرست محصولات اشتراک قابل خرید. */
suspend fun getProducts(): Result<List<SubscriptionProduct>>
/** ثبت خرید اشتراک پس از پرداخت موفق در درگاه. */
suspend fun subscribe(productId: Int, purchaseToken: String, gateway: String): Result<Unit>
}
@@ -0,0 +1,23 @@
package com.approagency.pharmacy.domain.repository
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.domain.model.DaroYabParams
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.model.PharmacyItem
interface DrugRepository {
suspend fun searchDrug(params: DrugSearchParams): Result<DrugListResponse>
suspend fun drugDetail(cod:Int): Result<DrugModels>
suspend fun getGorohDaroei(): Result<DarmanModel>
suspend fun searchDrugs(params:DaroYabParams):Result<DaroYabSearchResult>
suspend fun getDrugDetailFromYab(detailUrl: String):Result<DrugDetail>
suspend fun searchPharmacies(genericDrugId: String, brandIrc: String, provId: String): List<PharmacyItem>
suspend fun getPharmacyDetail(pharmacyUrl: String): PharmacyDetail
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.DrugDetail
import com.approagency.pharmacy.domain.repository.DrugRepository
class DrugDetailYabUseCase (
private val repository: DrugRepository
) {
suspend operator fun invoke(detailUrl: String):Result<DrugDetail> {
return repository.getDrugDetailFromYab(detailUrl)
}
}
@@ -1,7 +1,7 @@
package com.approagency.drug.domain.usecase
package com.approagency.pharmacy.domain.usecase
import com.approagency.drug.data.dto.DarmanModel
import com.approagency.drug.domain.repository.DrugRepository
import com.approagency.pharmacy.data.dto.DarmanModel
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetDarmanUseCase (
private val repository: DrugRepository
@@ -1,7 +1,7 @@
package com.approagency.drug.domain.usecase
package com.approagency.pharmacy.domain.usecase
import com.approagency.drug.data.dto.DrugModels
import com.approagency.drug.domain.repository.DrugRepository
import com.approagency.pharmacy.data.dto.DrugModels
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetDrugDetailUseCase
(
@@ -0,0 +1,13 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.data.dto.DrugListResponse
import com.approagency.pharmacy.domain.model.DrugSearchParams
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetDrugSearchUseCase (
private val repository: DrugRepository
){
suspend operator fun invoke(params: DrugSearchParams): Result<DrugListResponse> {
return repository.searchDrug(params)
}
}
@@ -0,0 +1,16 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.PharmacyItem
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetPharmaciesUseCase(
private val repository: DrugRepository
) {
suspend operator fun invoke(
genericDrugId: String,
brandIrc: String,
provinceId: String
): List<PharmacyItem> {
return repository.searchPharmacies(genericDrugId, brandIrc, provinceId)
}
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.PharmacyDetail
import com.approagency.pharmacy.domain.repository.DrugRepository
class GetPharmacyDetailUseCase(
private val repository: DrugRepository
) {
suspend operator fun invoke(pharmacyUrl: String): PharmacyDetail {
return repository.getPharmacyDetail(pharmacyUrl)
}
}
@@ -1,7 +1,7 @@
package com.approagency.drug.domain.usecase
package com.approagency.pharmacy.domain.usecase
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.model.TestGroup
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.model.TestGroup
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -1,8 +1,7 @@
package com.approagency.drug.domain.usecase
package com.approagency.pharmacy.domain.usecase
import com.approagency.drug.data.local.entities.TestItemEntity
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.model.TestItem
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.model.TestItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -0,0 +1,14 @@
package com.approagency.pharmacy.domain.usecase
import com.approagency.pharmacy.domain.model.DaroYabParams
import com.approagency.pharmacy.domain.model.DaroYabSearchResult
import com.approagency.pharmacy.domain.repository.DrugRepository
class SearchDrugsYabUseCase (
private val repository: DrugRepository
) {
suspend operator fun invoke(query: String, pageNumber: Int = 1): Result<DaroYabSearchResult> {
val params = DaroYabParams(query = query, pageNumber = pageNumber)
return repository.searchDrugs(params)
}
}
@@ -1,11 +1,11 @@
package com.approagency.drug.domain.usecase
package com.approagency.pharmacy.domain.usecase
import com.approagency.drug.data.repository.LabRepositoryImpl
import com.approagency.drug.domain.model.TestGroup
import com.approagency.drug.domain.model.TestItem
import com.approagency.drug.domain.model.toTestGroup
import com.approagency.drug.domain.model.toTestItem
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.model.TestGroup
import com.approagency.pharmacy.domain.model.TestItem
import com.approagency.pharmacy.domain.model.toTestGroup
import com.approagency.pharmacy.domain.model.toTestItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -0,0 +1,71 @@
package com.approagency.pharmacy.navigation
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.approagency.pharmacy.R
import com.approagency.pharmacy.data.local.AccountState
/**
* نوار بالای اپ: دکمه‌ی منو، اطلاعات حساب (موبایل + وضعیت اشتراک) و کنشِ
* ورود/خرید اشتراک.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountAppBar(
account: AccountState,
onMenuClick: () -> Unit,
onAccountClick: () -> Unit
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onMenuClick) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "منو"
)
}
},
title = {
if (account.isLoggedIn) {
Column {
Text(
text = account.mobile ?: account.displayName.orEmpty(),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = account.subscriptionTitle ?: "بدون اشتراک",
style = MaterialTheme.typography.labelSmall,
color = if (account.isSubscribed)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
Text(text = stringResource(R.string.app_name), style = MaterialTheme.typography.titleMedium)
}
},
actions = {
if (!account.isLoggedIn) {
ElevatedButton(onClick = onAccountClick) {
Text("ورود", textAlign = TextAlign.Center)
}
} else if (!account.isSubscribed) {
ElevatedButton(onClick = onAccountClick) {
Text("خرید اشتراک", textAlign = TextAlign.Center)
}
}
}
)
}
@@ -0,0 +1,70 @@
package com.approagency.pharmacy.navigation
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.approagency.pharmacy.presentation.screens.DrugDetailScreen
import com.approagency.pharmacy.presentation.screens.HomeScreen
import com.approagency.pharmacy.presentation.screens.LabScreen
import com.approagency.pharmacy.presentation.screens.SearchScreen
@Composable
fun AppNavGraph() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "search"
) {
// ───────────────
// BOTTOM TABS
// ───────────────
composable("home") {
MainContainer(navController) {
HomeScreen(navController , modifier = Modifier)
}
}
composable("search") {
MainContainer(navController) {
SearchScreen(navController,)
}
}
composable("lab") {
MainContainer(navController) {
LabScreen()
}
}
// ───────────────
// GLOBAL SCREEN
// ───────────────
composable(
route = Screen.DrugDetail.route,
arguments = listOf(
navArgument("detailUrl") {
type = NavType.StringType
}
)
) {
val detailUrl =
Uri.decode(it.arguments?.getString("detailUrl") ?: "")
DrugDetailScreen(
navController = navController,
detailUrl = detailUrl
)
}
}
}
@@ -1,29 +1,22 @@
package com.approagency.drug.navigation
package com.approagency.pharmacy.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.LocalPharmacy
import androidx.compose.material.icons.filled.Science
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -33,59 +26,51 @@ 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.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
@Composable
fun BottomBar(
navController: NavHostController
) {
fun BottomBar(navController: NavHostController) {
val items = listOf(
Triple(MainRoute.HomeGraph, "دارو", Icons.Default.Search),
Triple(MainRoute.SearchGraph, "سرچ", Icons.Rounded.Refresh),
Triple(MainRoute.LabGraph, "آزمایش", Icons.Default.Build)
Triple("home", "عمومی", Icons.Filled.LocalPharmacy),
Triple("search", "جستجو",Icons.Filled.Search),
Triple("lab", "آزمایش", Icons.Filled.Science)
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val currentRoute = navBackStackEntry?.destination?.route
Surface(
tonalElevation = 2.dp,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.navigationBarsPadding()
.height(50.dp)
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
.height(56.dp)
) {
Row(modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically) {
items.forEach { item ->
val selected = currentDestination
?.hierarchy
?.any { it.route == item.first.route } == true
val selected = currentRoute == item.first
val color = if (selected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
val background = if (selected)
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else
Color.Transparent
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp , vertical = 4.dp)
.weight(1f).padding(horizontal = 8.dp , vertical = 4.dp)
.clip(MaterialTheme.shapes.large)
.background(background)
.clickable {
navController.navigate(item.first.route) {
if (!selected) {
navController.navigate(item.first) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
@@ -93,7 +78,8 @@ fun BottomBar(
restoreState = true
}
}
.padding(horizontal = 10.dp, vertical = 10.dp),
}.padding(horizontal = 10.dp, vertical = 10.dp),
contentAlignment = Alignment.Center
) {
@@ -0,0 +1,116 @@
package com.approagency.pharmacy.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.presentation.account.AccountDrawer
import com.approagency.pharmacy.presentation.account.AccountSheet
import com.approagency.pharmacy.presentation.account.AccountSheetController
import com.approagency.pharmacy.presentation.common.ConfirmBottomSheet
import com.approagency.pharmacy.presentation.legal.TermsController
import com.approagency.pharmacy.presentation.legal.TermsDialog
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainContainer(
navController: NavHostController,
content: @Composable () -> Unit
) {
val session: SessionManager = koinInject()
val sheetController: AccountSheetController = koinInject()
val termsController: TermsController = koinInject()
val authRepository: AuthRepository = koinInject()
val account by session.account.collectAsState()
val sheetVisible by sheetController.visible.collectAsState()
val termsVisible by termsController.visible.collectAsState()
val themeMode by session.themeMode.collectAsState()
val fontScale by session.fontScale.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) }
val context = LocalContext.current
val appVersion = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull().orEmpty()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AccountDrawer(
account = account,
appVersion = appVersion,
themeMode = themeMode,
onThemeModeChange = { session.setThemeMode(it) },
fontScale = fontScale,
onFontScaleChange = { session.setFontScale(it) },
onShowTerms = {
scope.launch { drawerState.close() }
termsController.show()
},
onLogout = { showLogoutConfirm = true }
)
}
) {
Scaffold(
topBar = {
AccountAppBar(
account = account,
onMenuClick = { scope.launch { drawerState.open() } },
onAccountClick = { sheetController.show() }
)
},
bottomBar = { BottomBar(navController) }
) { padding ->
Box(modifier = Modifier.padding(padding)) {
content()
}
}
}
if (sheetVisible) {
AccountSheet(onDismiss = { sheetController.hide() })
}
if (termsVisible) {
TermsDialog(onDismiss = { termsController.hide() })
}
if (showLogoutConfirm) {
ConfirmBottomSheet(
title = "خروج از حساب",
message = "آیا می‌خواهید از حساب کاربری خود خارج شوید؟",
confirmText = "خروج",
onConfirm = {
showLogoutConfirm = false
scope.launch {
authRepository.logout()
drawerState.close()
}
},
onDismiss = { showLogoutConfirm = false }
)
}
}
@@ -1,4 +1,4 @@
package com.approagency.drug.navigation
package com.approagency.pharmacy.navigation
import android.net.Uri
@@ -0,0 +1,19 @@
//package com.approagency.drug.navigation.graphs
//
//import androidx.compose.ui.Modifier
//import androidx.navigation.NavGraphBuilder
//import androidx.navigation.NavHostController
//import androidx.navigation.compose.composable
//import androidx.navigation.navigation
//import com.approagency.drug.navigation.MainRoute
//import com.approagency.drug.navigation.Screen
//import com.approagency.drug.presentation.screens.HomeScreen
//
//fun NavGraphBuilder.homeGraph(navController: NavHostController) {
//
// composable(MainRoute.HomeGraph.route) {
// HomeScreen(
// navController = navController , modifier = Modifier
// )
// }
//}
@@ -0,0 +1,16 @@
//package com.approagency.drug.navigation.graphs
//
//import androidx.navigation.NavGraphBuilder
//import androidx.navigation.NavHostController
//import androidx.navigation.compose.composable
//import androidx.navigation.navigation
//import com.approagency.drug.navigation.MainRoute
//import com.approagency.drug.navigation.Screen
//import com.approagency.drug.presentation.screens.LabScreen
//
//fun NavGraphBuilder.labGraph(navController: NavHostController) {
//
// composable(MainRoute.LabGraph.route) {
// LabScreen()
// }
//}
@@ -0,0 +1,20 @@
//package com.approagency.drug.navigation.graphs
//
//import android.net.Uri
//import androidx.navigation.NavGraphBuilder
//import androidx.navigation.NavHostController
//import androidx.navigation.NavType
//import androidx.navigation.compose.composable
//import androidx.navigation.navArgument
//import androidx.navigation.navigation
//import com.approagency.drug.navigation.MainRoute
//import com.approagency.drug.navigation.Screen
//import com.approagency.drug.presentation.screens.DrugDetailScreen
//import com.approagency.drug.presentation.screens.SearchScreen
//
//fun NavGraphBuilder.searchGraph(navController: NavHostController) {
//
// composable(MainRoute.SearchGraph.route) {
// SearchScreen(navController)
// }
//}
@@ -0,0 +1,328 @@
package com.approagency.pharmacy.presentation.account
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.WorkspacePremium
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.approagency.pharmacy.data.local.AccountState
import com.approagency.pharmacy.domain.model.FontScale
import com.approagency.pharmacy.domain.model.ThemeMode
import com.vada.caller.ui.theme.dime
/**
* کشوی کناری حساب کاربری با طراحی بهبودیافته:
* - سربرگ با آواتار، نام/موبایل و نشانِ وضعیت اشتراک
* - کارت اطلاعات (موبایل، اشتراک، تاریخ انقضا، نسخه‌ی برنامه)
* - انتخاب حالت نمایش (سیستم/روشن/تاریک)
* - دکمه‌ی خروج
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountDrawer(
account: AccountState,
appVersion: String,
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
fontScale: FontScale,
onFontScaleChange: (FontScale) -> Unit,
onShowTerms: () -> Unit,
onLogout: () -> Unit
) {
ModalDrawerSheet(
modifier = Modifier.width(280.dp),
drawerContainerColor = MaterialTheme.colorScheme.surface
) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = MaterialTheme.dime.lg, vertical = MaterialTheme.dime.xl)
) {
Header(account)
Spacer(Modifier.height(MaterialTheme.dime.xl))
// کارت اطلاعات حساب
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(MaterialTheme.dime.md)) {
InfoRow(
icon = Icons.Filled.Phone,
label = "شماره موبایل",
value = account.mobile ?: "-"
)
InfoRow(
icon = Icons.Filled.WorkspacePremium,
label = "اشتراک",
value = account.subscriptionTitle ?: "بدون اشتراک",
highlight = account.isSubscribed
)
if (account.isSubscribed) {
InfoRow(
icon = Icons.Filled.CalendarMonth,
label = "تاریخ انقضا",
value = account.subscriptionExpireAt ?: "-"
)
}
InfoRow(
icon = Icons.Filled.Info,
label = "نسخه برنامه",
value = appVersion.ifBlank { "-" }
)
}
}
Spacer(Modifier.height(MaterialTheme.dime.xl))
// انتخاب حالت نمایش
Text(
text = "حالت نمایش",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(MaterialTheme.dime.sm))
ThemeModeSelector(selected = themeMode, onSelect = onThemeModeChange)
Spacer(Modifier.height(MaterialTheme.dime.lg))
// اندازهٔ متن (برای کاربرانِ مسن‌تر)
Text(
text = "اندازهٔ متن",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(MaterialTheme.dime.sm))
FontScaleSelector(selected = fontScale, onSelect = onFontScaleChange)
Spacer(Modifier.height(MaterialTheme.dime.lg))
TextButton(
onClick = onShowTerms,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Article,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(MaterialTheme.dime.sm))
Text(
text = "شرایط و قوانین",
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
if (account.isLoggedIn) {
OutlinedButton(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.size(MaterialTheme.dime.sm))
Text(
text = "خروج از حساب",
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
@Composable
private fun Header(account: AccountState) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(56.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(32.dp)
)
}
Spacer(Modifier.size(MaterialTheme.dime.md))
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.displayName?.takeIf { it.isNotBlank() }
?: account.mobile
?: "کاربر مهمان",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(MaterialTheme.dime.xs))
SubscriptionBadge(isSubscribed = account.isSubscribed)
}
}
}
@Composable
private fun SubscriptionBadge(isSubscribed: Boolean) {
val container = if (isSubscribed)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceContainerHighest
val content = if (isSubscribed)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
Surface(color = container, shape = CircleShape) {
Text(
text = if (isSubscribed) "اشتراک فعال" else "بدون اشتراک",
style = MaterialTheme.typography.labelSmall,
color = content,
modifier = Modifier.padding(
horizontal = MaterialTheme.dime.sm,
vertical = MaterialTheme.dime.xxs
)
)
}
}
@Composable
private fun InfoRow(
icon: ImageVector,
label: String,
value: String,
highlight: Boolean = false
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.dime.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(MaterialTheme.dime.sm))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (highlight)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThemeModeSelector(
selected: ThemeMode,
onSelect: (ThemeMode) -> Unit
) {
val options = listOf(
Triple(ThemeMode.SYSTEM, "سیستم", Icons.Filled.BrightnessAuto),
Triple(ThemeMode.LIGHT, "روشن", Icons.Filled.LightMode),
Triple(ThemeMode.DARK, "تاریک", Icons.Filled.DarkMode)
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (mode, label, icon) ->
SegmentedButton(
selected = selected == mode,
onClick = { onSelect(mode) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
icon = {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
label = { Text(label, style = MaterialTheme.typography.labelMedium) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FontScaleSelector(
selected: FontScale,
onSelect: (FontScale) -> Unit
) {
val options = FontScale.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, option ->
SegmentedButton(
selected = selected == option,
onClick = { onSelect(option) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
icon = {},
label = { Text(option.label, style = MaterialTheme.typography.labelSmall) }
)
}
}
}
@@ -0,0 +1,216 @@
package com.approagency.pharmacy.presentation.account
import android.app.Activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
import com.approagency.pharmacy.presentation.common.OtpTextField
import com.approagency.pharmacy.presentation.common.PrimaryButton
import com.approagency.pharmacy.presentation.legal.TermsController
import com.approagency.pharmacy.presentation.viewModel.AccountPhase
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
import com.vada.caller.ui.theme.dime
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSheet(
onDismiss: () -> Unit,
viewModel: AccountViewModel = koinViewModel()
) {
val ui by viewModel.ui.collectAsState()
val account by viewModel.account.collectAsState()
val activity = LocalContext.current as? Activity
val otpAutoFillBus: OtpAutoFillBus = koinInject()
val termsController: TermsController = koinInject()
LaunchedEffect(Unit) { viewModel.onSheetOpened() }
// پس از خرید موفق، شیت بسته می‌شود (نوار بالای اپ خودش به‌روز می‌شود).
LaunchedEffect(ui.purchaseSuccess) {
if (ui.purchaseSuccess) {
viewModel.consumePurchaseSuccess()
onDismiss()
}
}
// در مرحله‌ی کد، گوش‌دادن به پیامک را آغاز کن و با خروج متوقفش کن.
val isOtpStep = ui.phase == AccountPhase.EnterOtp
DisposableEffect(isOtpStep) {
val autofill = activity as? OtpAutofillController
if (isOtpStep) autofill?.startOtpAutofill()
onDispose { autofill?.stopOtpAutofill() }
}
// کدِ خوانده‌شده از پیامک را در فیلد بگذار و به‌صورت خودکار تأیید کن.
LaunchedEffect(Unit) {
otpAutoFillBus.codes.collect { code ->
viewModel.updateOtp(code)
viewModel.verifyOtp()
}
}
CustomModalBottomSheet(onDismiss = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 220.dp)
.padding(MaterialTheme.dime.lg)
) {
when (ui.phase) {
AccountPhase.EnterMobile -> {
SheetTitle("ورود به حساب")
Text(
"برای ادامه‌ی جستجو شماره موبایل خود را وارد کنید",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
OutlinedTextField(
value = viewModel.mobile,
shape = MaterialTheme.shapes.large,
onValueChange = { viewModel.updateMobile(it) },
label = { Text("شماره موبایل" , textAlign = TextAlign.Right ) },
placeholder = { Text("09xxxxxxxxx") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = { viewModel.sendOtp() })
TermsConsentNote(onShowTerms = { termsController.show() })
}
AccountPhase.EnterOtp -> {
SheetTitle("تأیید شماره")
Text(
"کد ارسال‌شده به ${viewModel.mobile} را وارد کنید",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
OtpTextField(
otpText = viewModel.otp,
otpCount = 5,
onOtpTextChange = { value, _ -> viewModel.updateOtp(value) },
onComplete = { viewModel.verifyOtp() },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(MaterialTheme.dime.md))
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = { viewModel.verifyOtp() })
TextButton(onClick = { viewModel.editMobile() }, enabled = !ui.busy) {
Text("ویرایش شماره موبایل")
}
}
AccountPhase.Products -> {
SheetTitle("اشتراک ویژه")
Text(
"سهمیه‌ی جستجوی رایگان شما به پایان رسیده است. برای جستجوی نامحدود اشتراک تهیه کنید.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
when {
ui.productsLoading -> Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
) {
repeat(2) { SubscriptionProductCardSkeleton() }
}
else -> Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
) {
ui.products.forEach { product ->
SubscriptionProductCard(
product = product,
isPurchasing = ui.purchasingProductId == product.id,
onBuy = { activity?.let { viewModel.purchase(it, product) } }
)
}
}
}
if (!viewModel.gatewayAvailable) {
Spacer(Modifier.height(MaterialTheme.dime.sm))
Text(
"درگاه پرداخت روی این نسخه فعال نیست.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
AccountPhase.Subscribed -> {
SheetTitle("اشتراک فعال")
Text(
account.subscriptionTitle?.let { "اشتراک شما فعال است: $it" }
?: "اشتراک شما فعال است.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
ui.error?.let { message ->
Spacer(Modifier.height(MaterialTheme.dime.sm))
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
}
}
}
}
@Composable
private fun SheetTitle(text: String) {
Text(text = text, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(MaterialTheme.dime.xs))
}
/** لینکِ «شرایط و قوانین» زیر فرمِ ورود (برای دسترسی و بازبینیِ فروشگاه). */
@Composable
private fun TermsConsentNote(onShowTerms: () -> Unit) {
TextButton(onClick = onShowTerms, modifier = Modifier.fillMaxWidth()) {
Text(
text = "با ادامه، شرایط و قوانین را می‌پذیرید",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
@@ -0,0 +1,19 @@
package com.approagency.pharmacy.presentation.account
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* کنترلر سراسریِ نمایش شیتِ حساب (ورود/اشتراک).
*
* تک‌نمونه در Koin؛ هم نوار بالای اپ و هم گیتِ جستجو می‌توانند با [show] آن را
* باز کنند و [MainContainer] با مشاهده‌ی [visible] شیت را نمایش می‌دهد.
*/
class AccountSheetController {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
fun show() { _visible.value = true }
fun hide() { _visible.value = false }
}
@@ -0,0 +1,19 @@
package com.approagency.pharmacy.presentation.account
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* پلِ تک‌نمونه برای رساندن کدِ خوانده‌شده از پیامک (SMS User Consent) از
* [MainActivity] به شیتِ ورود. اکتیویتی کد را [submit] می‌کند و شیت آن را
* از [codes] می‌خواند و در فیلد OTP قرار می‌دهد.
*/
class OtpAutoFillBus {
private val _codes = MutableSharedFlow<String>(extraBufferCapacity = 1)
val codes: SharedFlow<String> = _codes.asSharedFlow()
fun submit(code: String) {
_codes.tryEmit(code)
}
}
@@ -0,0 +1,12 @@
package com.approagency.pharmacy.presentation.account
/**
* قراردادِ شروع/توقفِ خودکارپُرکُنِ کدِ پیامک.
*
* اکتیویتی میزبان آن را پیاده‌سازی می‌کند؛ شیتِ ورود فقط به این اینترفیس وابسته
* است و از نوعِ مشخصِ اکتیویتی بی‌خبر می‌ماند.
*/
interface OtpAutofillController {
fun startOtpAutofill()
fun stopOtpAutofill()
}
@@ -0,0 +1,94 @@
package com.approagency.pharmacy.presentation.account
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
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.approagency.pharmacy.domain.model.SubscriptionProduct
import com.approagency.pharmacy.presentation.common.PrimaryButton
import com.approagency.pharmacy.presentation.common.shimmer
import com.vada.caller.ui.theme.dime
/** کارت یک محصول اشتراک به‌همراه دکمه‌ی خرید. */
@Composable
fun SubscriptionProductCard(
product: SubscriptionProduct,
isPurchasing: Boolean,
onBuy: () -> Unit
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(MaterialTheme.dime.lg)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(product.title, style = MaterialTheme.typography.titleMedium)
Text(
"${formatPrice(product.price)} تومان",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
}
if (!product.description.isNullOrBlank()) {
Spacer(Modifier.height(MaterialTheme.dime.xs))
Text(
product.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
PrimaryButton(text = "خرید", height = 44, isLoading = isPurchasing, onClick = onBuy)
}
}
}
/** جای‌گیرنده‌ی shimmer برای کارت محصول هنگام بارگذاری. */
@Composable
fun SubscriptionProductCardSkeleton() {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(MaterialTheme.dime.lg)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(
Modifier
.width(120.dp)
.height(18.dp)
.shimmer()
)
Box(
Modifier
.width(72.dp)
.height(18.dp)
.shimmer()
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
Box(
Modifier
.fillMaxWidth()
.height(44.dp)
.shimmer(shape = MaterialTheme.shapes.medium)
)
}
}
}
/** قالب‌بندی قیمت با جداکننده‌ی هزارگان. */
private fun formatPrice(price: Long): String =
price.toString().reversed().chunked(3).joinToString(",").reversed()

Some files were not shown because too many files have changed in this diff Show More