Compare commits
35
Commits
e02bc76ff8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2715bc720d | ||
|
|
f266f4d142 | ||
|
|
1971708b3f | ||
|
|
27dc08fa68 | ||
|
|
1ed75fb823 | ||
|
|
0a4b339481 | ||
|
|
5e7ce5bc6b | ||
|
|
fd8eb0a373 | ||
|
|
675123309a | ||
|
|
fdf15913a9 | ||
|
|
c3496e3de4 | ||
|
|
e73269889f | ||
|
|
34ae6a1b54 | ||
|
|
16128e5304 | ||
|
|
0ae8fa19bc | ||
|
|
d1718793d9 | ||
|
|
b46cd5b40c | ||
|
|
292b77f29b | ||
|
|
670dbf8ce2 | ||
|
|
cbe77fbb9e | ||
|
|
92b7ab49c4 | ||
|
|
098d5c8d5f | ||
|
|
885b960cc9 | ||
|
|
4480b26e07 | ||
|
|
43427ae420 | ||
|
|
1ba6062c86 | ||
|
|
8516405daf | ||
|
|
e0e4bb4eef | ||
|
|
3ea8bf5542 | ||
|
|
e427bde0d0 | ||
|
|
4a57954faf | ||
|
|
c8044bfe42 | ||
|
|
c09d79dbbc | ||
|
|
dd5cbaa3bc | ||
|
|
e65c699bcb |
@@ -9,6 +9,13 @@
|
|||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.dm
|
||||||
|
/app/release/
|
||||||
|
release/
|
||||||
|
/build/
|
||||||
|
/app/build/
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
|
|||||||
Generated
+157
@@ -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>
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="21" />
|
<bytecodeTargetLevel target="17" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Generated
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amirmahdi/.android/avd/Pixel_3a_API_33_arm64-v8a.avd" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amirmahdi/.android/avd/Pixel_3a_API_33_arm64-v8a.avd" />
|
||||||
|
|||||||
Generated
+13
@@ -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>
|
||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<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" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<component name="ProjectType">
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -6,13 +6,13 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.approagency.drug"
|
namespace = "com.approagency.pharmacy"
|
||||||
compileSdk {
|
compileSdk {
|
||||||
version = release(36)
|
version = release(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.approagency.drug"
|
applicationId = "com.approagency.pharmacy"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
@@ -23,7 +23,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -77,6 +77,9 @@ dependencies {
|
|||||||
// Location services
|
// Location services
|
||||||
implementation(libs.location.services)
|
implementation(libs.location.services)
|
||||||
|
|
||||||
|
// SMS User Consent API (OTP auto-fill)
|
||||||
|
implementation(libs.play.services.auth.api.phone)
|
||||||
|
|
||||||
// Koin for DI
|
// Koin for DI
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
implementation(libs.koin.androidx.compose.v410)
|
implementation(libs.koin.androidx.compose.v410)
|
||||||
@@ -93,4 +96,7 @@ dependencies {
|
|||||||
implementation(libs.jsoup)
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
implementation(libs.retrofit.scalars)
|
implementation(libs.retrofit.scalars)
|
||||||
|
|
||||||
|
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "com.approagency.pharmacy",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 1,
|
||||||
|
"versionName": "1.0",
|
||||||
|
"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
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug
|
package com.approagency.pharmacy
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -19,6 +19,6 @@ class ExampleInstrumentedTest {
|
|||||||
fun useAppContext() {
|
fun useAppContext() {
|
||||||
// Context of the app under test.
|
// Context of the app under test.
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
assertEquals("com.approagency.drug", appContext.packageName)
|
assertEquals("com.approagency.pharmacy", appContext.packageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-83
@@ -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,25 @@
|
|||||||
|
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("درگاه پرداخت روی این نسخه فعال نیست."))
|
||||||
|
}
|
||||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.data.dto
|
package com.approagency.pharmacy.data.dto
|
||||||
|
|
||||||
data class DarmanModel(
|
data class DarmanModel(
|
||||||
val success : String,
|
val success : String,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.data.dto
|
package com.approagency.pharmacy.data.dto
|
||||||
|
|
||||||
data class DrugModels(
|
data class DrugModels(
|
||||||
val success: Boolean,
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -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.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
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
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
+2
-2
@@ -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.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
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
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
package com.approagency.drug.data.local.database
|
package com.approagency.pharmacy.data.local.database
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import com.approagency.drug.data.local.dao.TestGroupDao
|
import com.approagency.pharmacy.data.local.dao.TestGroupDao
|
||||||
import com.approagency.drug.data.local.dao.TestItemDao
|
import com.approagency.pharmacy.data.local.dao.TestItemDao
|
||||||
import com.approagency.drug.data.local.entities.TestGroupEntity
|
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
|
||||||
import com.approagency.drug.data.local.entities.TestItemEntity
|
import com.approagency.pharmacy.data.local.entities.TestItemEntity
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
+1
-1
@@ -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.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
+1
-2
@@ -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.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.data.remote
|
package com.approagency.pharmacy.data.remote
|
||||||
|
|
||||||
import retrofit2.http.Field
|
import retrofit2.http.Field
|
||||||
import retrofit2.http.FormUrlEncoded
|
import retrofit2.http.FormUrlEncoded
|
||||||
@@ -6,7 +6,7 @@ import retrofit2.http.GET
|
|||||||
import retrofit2.http.Headers
|
import retrofit2.http.Headers
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Url
|
||||||
|
|
||||||
interface DarooyabApiService {
|
interface DarooyabApiService {
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@@ -32,4 +32,29 @@ interface DarooyabApiService {
|
|||||||
suspend fun getDrugDetail(
|
suspend fun getDrugDetail(
|
||||||
@Path(value = "detailUrl", encoded = true) detailUrl: String
|
@Path(value = "detailUrl", encoded = true) detailUrl: String
|
||||||
): 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
|
||||||
}
|
}
|
||||||
+88
-17
@@ -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.Jsoup
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import org.jsoup.select.Evaluator
|
|
||||||
|
|
||||||
class DrugDetailParser {
|
class DrugDetailParser {
|
||||||
|
|
||||||
@@ -87,8 +86,9 @@ class DrugDetailParser {
|
|||||||
private fun extractPersianName(document: Document): String {
|
private fun extractPersianName(document: Document): String {
|
||||||
val titleElement = document.select("h1.EnglishNumericFont").first()
|
val titleElement = document.select("h1.EnglishNumericFont").first()
|
||||||
val fullText = titleElement?.text() ?: ""
|
val fullText = titleElement?.text() ?: ""
|
||||||
// Remove the suffix "چیست و برای چه مواردی استفاده می شود؟"
|
val regexFullText = fullText.replace(Regex("\\s*چیست و برای چه مواردی استفاده می شود؟\\s*"), "").trim()
|
||||||
return fullText.replace(Regex("\\s*چیست و برای چه مواردی استفاده می شود\\?\\s*"), "").trim()
|
println("regexFullText: $regexFullText")
|
||||||
|
return regexFullText
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractEnglishName(document: Document): String {
|
private fun extractEnglishName(document: Document): String {
|
||||||
@@ -535,23 +535,94 @@ class DrugDetailParser {
|
|||||||
* Converts block elements to newlines for better readability.
|
* Converts block elements to newlines for better readability.
|
||||||
*/
|
*/
|
||||||
private fun cleanText(element: Element): String {
|
private fun cleanText(element: Element): String {
|
||||||
// Clone to avoid affecting the original
|
|
||||||
val clone = element.clone()
|
val clone = element.clone()
|
||||||
|
val output = StringBuilder()
|
||||||
|
|
||||||
// Replace block-level elements with newlines for structure
|
// Process all nodes in order (both elements and text nodes)
|
||||||
clone.select("p, div, h2, h3, h4, li, br").before("\n")
|
processNode(clone, output)
|
||||||
|
|
||||||
// Get the text and clean it up
|
return output.toString()
|
||||||
var text = clone.text()
|
.replace(Regex("\\n{3,}"), "\n\n") // Collapse 3+ newlines to 2
|
||||||
.replace(Regex("\\n\\s*\\n+"), "\n\n") // Collapse multiple newlines
|
|
||||||
.replace(Regex("[ \\t]+"), " ") // Collapse spaces/tabs
|
|
||||||
.trim()
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-3
@@ -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.pharmacy.domain.model.DaroYabSearchResult
|
||||||
import com.approagency.drug.domain.model.DrugSearchResult
|
import com.approagency.pharmacy.domain.model.DrugSearchResult
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Document
|
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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.data.remote
|
package com.approagency.pharmacy.data.remote
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.Converter
|
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("", "", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -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.pharmacy.data.local.dao.TestGroupDao
|
||||||
import com.approagency.drug.data.local.dao.TestItemDao
|
import com.approagency.pharmacy.data.local.dao.TestItemDao
|
||||||
import com.approagency.drug.data.local.entities.TestGroupEntity
|
import com.approagency.pharmacy.data.local.entities.TestGroupEntity
|
||||||
import com.approagency.drug.data.local.entities.TestItemEntity
|
import com.approagency.pharmacy.data.local.entities.TestItemEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.approagency.pharmacy.di
|
||||||
|
|
||||||
|
|
||||||
|
import com.approagency.pharmacy.data.billing.StubPurchaseGateway
|
||||||
|
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()) }
|
||||||
|
|
||||||
|
// درگاه پرداخت: روی main استاب؛ شاخههای myket/bazar جایگزین میکنند.
|
||||||
|
single<PurchaseGateway> { StubPurchaseGateway() }
|
||||||
|
|
||||||
|
|
||||||
|
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,35 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نتیجهی یک خرید موفق در فروشگاه. */
|
||||||
|
data class PurchaseResult(
|
||||||
|
val purchaseToken: String,
|
||||||
|
val orderId: String? = null
|
||||||
|
)
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.domain.model
|
package com.approagency.pharmacy.domain.model
|
||||||
|
|
||||||
data class DaroYabParams(
|
data class DaroYabParams(
|
||||||
val query: String? = null,
|
val query: String? = null,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.domain.model
|
package com.approagency.pharmacy.domain.model
|
||||||
|
|
||||||
data class DaroYabSearchResult(
|
data class DaroYabSearchResult(
|
||||||
val drugs: List<DrugSearchResult>,
|
val drugs: List<DrugSearchResult>,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.domain.model
|
package com.approagency.pharmacy.domain.model
|
||||||
|
|
||||||
data class DrugDetail(
|
data class DrugDetail(
|
||||||
val genericId: String,
|
val genericId: String,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.domain.model
|
package com.approagency.pharmacy.domain.model
|
||||||
|
|
||||||
data class DrugSearchParams(
|
data class DrugSearchParams(
|
||||||
val query: String? = null,
|
val query: String? = null,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.domain.model
|
package com.approagency.pharmacy.domain.model
|
||||||
|
|
||||||
data class DrugSearchResult(
|
data class DrugSearchResult(
|
||||||
val genericId: String, // e.g., "2556" from "/G-2556/Casanthranol"
|
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?
|
||||||
|
)
|
||||||
+2
-2
@@ -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(
|
data class TestGroup(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
+2
-2
@@ -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(
|
data class TestItem(
|
||||||
val id: Int,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -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.pharmacy.data.dto.DarmanModel
|
||||||
import com.approagency.drug.domain.repository.DrugRepository
|
import com.approagency.pharmacy.domain.repository.DrugRepository
|
||||||
|
|
||||||
class GetDarmanUseCase (
|
class GetDarmanUseCase (
|
||||||
private val repository: DrugRepository
|
private val repository: DrugRepository
|
||||||
+3
-3
@@ -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.pharmacy.data.dto.DrugModels
|
||||||
import com.approagency.drug.domain.repository.DrugRepository
|
import com.approagency.pharmacy.domain.repository.DrugRepository
|
||||||
|
|
||||||
class GetDrugDetailUseCase
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -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.pharmacy.data.repository.LabRepositoryImpl
|
||||||
import com.approagency.drug.domain.model.TestGroup
|
import com.approagency.pharmacy.domain.model.TestGroup
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
+3
-4
@@ -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.pharmacy.data.repository.LabRepositoryImpl
|
||||||
import com.approagency.drug.data.repository.LabRepositoryImpl
|
import com.approagency.pharmacy.domain.model.TestItem
|
||||||
import com.approagency.drug.domain.model.TestItem
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -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.pharmacy.data.repository.LabRepositoryImpl
|
||||||
import com.approagency.drug.domain.model.TestGroup
|
import com.approagency.pharmacy.domain.model.TestGroup
|
||||||
import com.approagency.drug.domain.model.TestItem
|
import com.approagency.pharmacy.domain.model.TestItem
|
||||||
import com.approagency.drug.domain.model.toTestGroup
|
import com.approagency.pharmacy.domain.model.toTestGroup
|
||||||
import com.approagency.drug.domain.model.toTestItem
|
import com.approagency.pharmacy.domain.model.toTestItem
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-33
@@ -1,29 +1,22 @@
|
|||||||
package com.approagency.drug.navigation
|
package com.approagency.pharmacy.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
import androidx.compose.material.icons.filled.LocalPharmacy
|
||||||
|
import androidx.compose.material.icons.filled.Science
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,59 +26,51 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomBar(
|
fun BottomBar(navController: NavHostController) {
|
||||||
navController: NavHostController
|
|
||||||
) {
|
|
||||||
val items = listOf(
|
val items = listOf(
|
||||||
Triple(MainRoute.HomeGraph, "دارو", Icons.Default.Search),
|
Triple("home", "عمومی", Icons.Filled.LocalPharmacy),
|
||||||
Triple(MainRoute.SearchGraph, "سرچ", Icons.Rounded.Refresh),
|
Triple("search", "جستجو",Icons.Filled.Search),
|
||||||
Triple(MainRoute.LabGraph, "آزمایش", Icons.Default.Build)
|
Triple("lab", "آزمایش", Icons.Filled.Science)
|
||||||
)
|
)
|
||||||
|
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentDestination = navBackStackEntry?.destination
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
tonalElevation = 2.dp,
|
tonalElevation = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.height(50.dp)
|
.height(56.dp)
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
|
||||||
items.forEach { item ->
|
items.forEach { item ->
|
||||||
|
|
||||||
val selected = currentDestination
|
val selected = currentRoute == item.first
|
||||||
?.hierarchy
|
|
||||||
?.any { it.route == item.first.route } == true
|
|
||||||
|
|
||||||
val color = if (selected)
|
val color = if (selected)
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
val background = if (selected)
|
val background = if (selected)
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||||
else
|
else
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f).padding(horizontal = 8.dp , vertical = 4.dp)
|
||||||
.padding(horizontal = 8.dp , vertical = 4.dp)
|
|
||||||
.clip(MaterialTheme.shapes.large)
|
.clip(MaterialTheme.shapes.large)
|
||||||
.background(background)
|
.background(background)
|
||||||
.clickable {
|
.clickable {
|
||||||
navController.navigate(item.first.route) {
|
if (!selected) {
|
||||||
|
navController.navigate(item.first) {
|
||||||
popUpTo(navController.graph.findStartDestination().id) {
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
saveState = true
|
saveState = true
|
||||||
}
|
}
|
||||||
@@ -93,7 +78,8 @@ fun BottomBar(
|
|||||||
restoreState = true
|
restoreState = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(horizontal = 10.dp, vertical = 10.dp),
|
}.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||||
|
|
||||||
contentAlignment = Alignment.Center
|
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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.approagency.drug.navigation
|
package com.approagency.pharmacy.navigation
|
||||||
|
|
||||||
import android.net.Uri
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.approagency.pharmacy.presentation.account
|
||||||
|
|
||||||
|
/**
|
||||||
|
* قراردادِ شروع/توقفِ خودکارپُرکُنِ کدِ پیامک.
|
||||||
|
*
|
||||||
|
* اکتیویتی میزبان آن را پیادهسازی میکند؛ شیتِ ورود فقط به این اینترفیس وابسته
|
||||||
|
* است و از نوعِ مشخصِ اکتیویتی بیخبر میماند.
|
||||||
|
*/
|
||||||
|
interface OtpAutofillController {
|
||||||
|
fun startOtpAutofill()
|
||||||
|
fun stopOtpAutofill()
|
||||||
|
}
|
||||||
+94
@@ -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()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.approagency.pharmacy.presentation.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.vada.caller.ui.theme.dime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* شیتِ تأییدِ عمومی برای کنشهای حساس (مثل خروج از حساب).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ConfirmBottomSheet(
|
||||||
|
title: String,
|
||||||
|
message: String,
|
||||||
|
confirmText: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
dismissText: String = "انصراف",
|
||||||
|
confirmColor: Color = MaterialTheme.colorScheme.error
|
||||||
|
) {
|
||||||
|
CustomModalBottomSheet(onDismiss = onDismiss) {
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(MaterialTheme.dime.lg)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.xl))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp)
|
||||||
|
) { Text(dismissText) }
|
||||||
|
Button(
|
||||||
|
onClick = onConfirm,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = confirmColor,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
) { Text(confirmText) }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -1,5 +1,6 @@
|
|||||||
package com.approagency.drug.presentation.common
|
package com.approagency.pharmacy.presentation.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -7,10 +8,8 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.vada.caller.ui.theme.dime
|
import com.vada.caller.ui.theme.dime
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,6 +26,7 @@ fun CustomModalBottomSheet(
|
|||||||
},
|
},
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = MaterialTheme.dime.lg, topEnd = MaterialTheme.dime.lg)
|
shape = RoundedCornerShape(topStart = MaterialTheme.dime.lg, topEnd = MaterialTheme.dime.lg)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user