Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a67bed0d3 | ||
|
|
cba6aa6153 | ||
|
|
f3b227f09a | ||
|
|
c76f8aa08f | ||
|
|
f266f4d142 | ||
|
|
1971708b3f | ||
|
|
27dc08fa68 | ||
|
|
1ed75fb823 | ||
|
|
0a4b339481 | ||
|
|
5e7ce5bc6b | ||
|
|
fd8eb0a373 | ||
|
|
675123309a | ||
|
|
fdf15913a9 | ||
|
|
c3496e3de4 | ||
|
|
e73269889f | ||
|
|
34ae6a1b54 | ||
|
|
16128e5304 |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -15,10 +15,22 @@ android {
|
||||
applicationId = "com.approagency.pharmacy"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 2
|
||||
versionName = "1.0.2+BZ"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// ---- Bazaar (Cafe Bazaar) in-app billing ----
|
||||
val marketApplicationId = "com.farsitel.bazaar"
|
||||
val marketBindAddress = "ir.cafebazaar.pardakht.InAppBillingService.BIND"
|
||||
manifestPlaceholders["marketApplicationId"] = marketApplicationId
|
||||
manifestPlaceholders["marketBindAddress"] = marketBindAddress
|
||||
manifestPlaceholders["marketPermission"] = "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR"
|
||||
buildConfigField(
|
||||
"String",
|
||||
"IAB_PUBLIC_KEY",
|
||||
"\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwDzw+tRS/C//9JebsgTQOPAJU3Qwg7aIOhO4wqPVMNCjkGQZH7MckLU8hC6Rrag8gakkGBROSN/J58W4q1wxFbaUgRiTvyM04ElE7tHHa8CKtNH3NSUFzCQkhqd+9mLGflmj7JEj6EaZLoTLfZYhQgGDpWNXwW9HmO2lygXkua6BUkelpafgAmLz/ojlBm+ByLdSVkXIaaGqs2GLLR4qZHiksljQskbv0T0Ccax8pcCAwEAAQ==\""
|
||||
)
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -39,6 +51,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +90,12 @@ dependencies {
|
||||
// Location services
|
||||
implementation(libs.location.services)
|
||||
|
||||
// SMS User Consent API (OTP auto-fill)
|
||||
implementation(libs.play.services.auth.api.phone)
|
||||
|
||||
// Myket in-app billing
|
||||
implementation("com.github.myketstore:myket-billing-client:1.6")
|
||||
|
||||
// Koin for DI
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.androidx.compose.v410)
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1,
|
||||
"versionName": "1.0",
|
||||
"versionCode": 2,
|
||||
"versionName": "1.0.2+MK",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".DrugApp"
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -3,12 +3,18 @@ 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.GlobalScope
|
||||
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(){
|
||||
class DrugApp : Application() {
|
||||
|
||||
// اسکوپ سطحاپ برای کارهای پسزمینهی طولعمرِ برنامه (بهجای GlobalScope).
|
||||
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startKoin {
|
||||
@@ -17,8 +23,8 @@ class DrugApp : Application(){
|
||||
printLogger()
|
||||
}
|
||||
|
||||
// Preload database (optional, for faster access)
|
||||
GlobalScope.launch {
|
||||
// پیشبارگذاری دیتابیس برای دسترسی سریعتر
|
||||
applicationScope.launch {
|
||||
LabDatabase.getInstance(this@DrugApp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +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) }
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
DrugTheme {
|
||||
|
||||
AppNavGraph(
|
||||
// navController = navController,
|
||||
// modifier = Modifier.padding(innerPadding)
|
||||
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
|
||||
)
|
||||
// Scaffold(modifier = Modifier.fillMaxSize(),
|
||||
// ) { innerPadding ->
|
||||
// AppNavGraph(
|
||||
//// navController = navController,
|
||||
//// modifier = Modifier.padding(innerPadding)
|
||||
// )
|
||||
// }
|
||||
) {
|
||||
AppNavGraph()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* هنگام شروع اپ و هر بار بازگشت به اپ (مثلاً پس از بازگشت از درگاه پرداختِ
|
||||
* مایکت/بازار) وضعیت اشتراک از سرور تازهسازی میشود تا کل اپ بهروز بماند.
|
||||
*/
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (session.isLoggedIn) {
|
||||
lifecycleScope.launch { authRepository.refreshStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- خودکارپُرکُنِ کد پیامک (SMS User Consent API) ----------
|
||||
|
||||
/** آغاز گوشدادن به پیامکِ کد؛ کدِ یافتشده از طریق [OtpAutoFillBus] تحویل میشود. */
|
||||
override fun startOtpAutofill() {
|
||||
SmsRetriever.getClient(this).startSmsUserConsent(null)
|
||||
if (otpSmsReceiver != null) return
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(c: Context?, received: Intent?) {
|
||||
if (received?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
|
||||
val extras = received.extras ?: return
|
||||
val status = extras.get(SmsRetriever.EXTRA_STATUS) as? Status ?: return
|
||||
if (status.statusCode != CommonStatusCodes.SUCCESS) return
|
||||
val consentIntent: Intent? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT)
|
||||
}
|
||||
consentIntent?.let { runCatching { smsConsentLauncher.launch(it) } }
|
||||
}
|
||||
}
|
||||
otpSmsReceiver = receiver
|
||||
// این برودکست را Google Play services با مجوز SEND میفرستد؛ پس گیرنده باید
|
||||
// همان مجوز را الزام کند تا پیامک به آن تحویل شود.
|
||||
ContextCompat.registerReceiver(
|
||||
this,
|
||||
receiver,
|
||||
IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION),
|
||||
SmsRetriever.SEND_PERMISSION,
|
||||
null,
|
||||
ContextCompat.RECEIVER_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
override fun stopOtpAutofill() {
|
||||
otpSmsReceiver?.let { runCatching { unregisterReceiver(it) } }
|
||||
otpSmsReceiver = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
stopOtpAutofill()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.approagency.pharmacy.data.billing
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import com.approagency.pharmacy.BuildConfig
|
||||
import com.approagency.pharmacy.data.local.SessionManager
|
||||
import com.approagency.pharmacy.domain.billing.PurchaseGateway
|
||||
import com.approagency.pharmacy.domain.billing.PurchaseResult
|
||||
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
||||
import ir.myket.billingclient.IabHelper
|
||||
import ir.myket.billingclient.util.Purchase
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
/**
|
||||
* درگاهِ پرداختِ کافهبازار بر پایهٔ همان کتابخانهٔ `myket-billing-client`
|
||||
* (IabHelper). فروشگاهِ مقصد با `manifestPlaceholders` در build.gradle مشخص
|
||||
* میشود (com.farsitel.bazaar)، بنابراین کدِ IabHelper یکسان است و فقط مقصدِ
|
||||
* اتصال و کلیدِ عمومی فرق میکند.
|
||||
*
|
||||
* جریان:
|
||||
* 1. اتصال به سرویس بازار ([ensureSetup]).
|
||||
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
|
||||
* 3. توکن به سرور فرستاده میشود (با gateway = "bazaar") و پس از تأیید، خرید
|
||||
* [consume] میشود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
|
||||
*
|
||||
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را میپذیرد، همهٔ فراخوانیها
|
||||
* با [mutex] ترتیبی میشوند.
|
||||
*/
|
||||
class BazaarPurchaseGateway(
|
||||
context: Context,
|
||||
private val session: SessionManager
|
||||
) : PurchaseGateway {
|
||||
|
||||
// مقدارِ معتبرِ درگاه در سمتِ سرورِ آپرواجنسی برای کافهبازار «cafe» است.
|
||||
override val name: String = "cafe"
|
||||
override val isAvailable: Boolean = true
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile private var helper: IabHelper? = null
|
||||
@Volatile private var setupDone = false
|
||||
|
||||
/** خریدهای منتظرِ مصرف، برحسبِ توکن. */
|
||||
private val pending = mutableMapOf<String, Purchase>()
|
||||
|
||||
override suspend fun purchase(
|
||||
activity: Activity,
|
||||
product: SubscriptionProduct
|
||||
): Result<PurchaseResult> = mutex.withLock {
|
||||
val sku = product.uuid?.takeIf { it.isNotBlank() }
|
||||
?: return Result.failure(IllegalArgumentException("شناسهٔ محصول (SKU) نامعتبر است."))
|
||||
|
||||
ensureSetup().onFailure { return Result.failure(it) }
|
||||
val helper = helper ?: return Result.failure(IllegalStateException("اتصال به بازار برقرار نشد."))
|
||||
|
||||
// developerPayload = شماره موبایلِ کاربر برای شناساییِ خریدار؛ در صورت نبود، توکن تصادفی.
|
||||
val payload = session.account.value.mobile?.takeIf { it.isNotBlank() }
|
||||
?: UUID.randomUUID().toString()
|
||||
|
||||
val outcome = runCatching {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
// محصولهای بازار از نوع «اشتراک» هستند.
|
||||
helper.launchSubscriptionPurchaseFlow(
|
||||
activity,
|
||||
sku,
|
||||
IabHelper.OnIabPurchaseFinishedListener { result, purchase ->
|
||||
when {
|
||||
result.isSuccess && purchase != null ->
|
||||
cont.resume(PurchaseOutcome.Success(purchase))
|
||||
result.response == IabHelper.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED ->
|
||||
cont.resume(PurchaseOutcome.AlreadyOwned)
|
||||
else ->
|
||||
cont.resume(PurchaseOutcome.Error(result.message ?: "خطای پرداخت بازار"))
|
||||
}
|
||||
},
|
||||
payload
|
||||
)
|
||||
}
|
||||
}.getOrElse { return Result.failure(it) }
|
||||
|
||||
val purchase = when (outcome) {
|
||||
is PurchaseOutcome.Success -> outcome.purchase
|
||||
PurchaseOutcome.AlreadyOwned ->
|
||||
return Result.failure(IllegalStateException("اشتراک شما از قبل فعال است."))
|
||||
is PurchaseOutcome.Error -> return Result.failure(IllegalStateException(outcome.message))
|
||||
}
|
||||
|
||||
val token = purchase.token
|
||||
if (token.isNullOrBlank()) {
|
||||
return Result.failure(IllegalStateException("توکن خرید دریافت نشد."))
|
||||
}
|
||||
pending[token] = purchase
|
||||
Result.success(PurchaseResult(purchaseToken = token, orderId = purchase.orderId))
|
||||
}
|
||||
|
||||
// اشتراکِ کافهبازار مصرف نمیشود؛ این متد بیاثر است.
|
||||
override suspend fun consume(purchaseToken: String): Result<Unit> {
|
||||
pending.remove(purchaseToken)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/** اتصال به سرویس بازار (یکبار). */
|
||||
private suspend fun ensureSetup(): Result<Unit> {
|
||||
if (setupDone && helper != null) return Result.success(Unit)
|
||||
val newHelper = IabHelper(appContext, BuildConfig.IAB_PUBLIC_KEY)
|
||||
.apply { enableDebugLogging(BuildConfig.DEBUG) }
|
||||
return runCatching {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
newHelper.startSetup(IabHelper.OnIabSetupFinishedListener { result ->
|
||||
if (result.isSuccess) cont.resume(Unit)
|
||||
else cont.resumeWithException(
|
||||
IllegalStateException(result.message ?: "اتصال به بازار ناموفق بود")
|
||||
)
|
||||
})
|
||||
}
|
||||
}.onSuccess {
|
||||
helper = newHelper
|
||||
setupDone = true
|
||||
}.onFailure {
|
||||
runCatching { newHelper.dispose() }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface PurchaseOutcome {
|
||||
data class Success(val purchase: Purchase) : PurchaseOutcome
|
||||
data object AlreadyOwned : PurchaseOutcome
|
||||
data class Error(val message: String) : PurchaseOutcome
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.approagency.pharmacy.data.billing
|
||||
|
||||
import android.app.Activity
|
||||
import com.approagency.pharmacy.domain.billing.PurchaseGateway
|
||||
import com.approagency.pharmacy.domain.billing.PurchaseResult
|
||||
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
||||
|
||||
/**
|
||||
* پیادهسازی پیشفرض درگاه پرداخت روی شاخهی `main`.
|
||||
*
|
||||
* هیچ درگاه واقعیای ندارد؛ شاخههای `myket` و `bazar` این را با پیادهسازی
|
||||
* فروشگاه خود جایگزین میکنند. تا آن زمان، خرید با پیام مناسب ناموفق میشود.
|
||||
*/
|
||||
class StubPurchaseGateway : PurchaseGateway {
|
||||
|
||||
override val name: String = "none"
|
||||
|
||||
override val isAvailable: Boolean = false
|
||||
|
||||
override suspend fun purchase(
|
||||
activity: Activity,
|
||||
product: SubscriptionProduct
|
||||
): Result<PurchaseResult> =
|
||||
Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست."))
|
||||
|
||||
override suspend fun consume(purchaseToken: String): Result<Unit> = Result.success(Unit)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.approagency.pharmacy.data.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/** پاسخ ارسال کد یکبارمصرف (login-otp). */
|
||||
data class LoginOtpResponse(
|
||||
@SerializedName("message") val message: String? = null,
|
||||
@SerializedName("status") val status: String? = null
|
||||
)
|
||||
|
||||
/** پاسخ تأیید کد (check-otp) که توکن نشست را برمیگرداند. */
|
||||
data class CheckOtpResponse(
|
||||
@SerializedName("token") val token: String? = null,
|
||||
@SerializedName("message") val message: String? = null,
|
||||
@SerializedName("user") val user: UserDto? = null
|
||||
)
|
||||
|
||||
data class UserDto(
|
||||
@SerializedName("id") val id: Int? = null,
|
||||
@SerializedName("first_name") val firstName: String? = null,
|
||||
@SerializedName("last_name") val lastName: String? = null,
|
||||
@SerializedName("mobile") val mobile: String? = null,
|
||||
@SerializedName("email") val email: String? = null
|
||||
)
|
||||
|
||||
/** یک محصول اشتراک از `package-names/{name}/products`. */
|
||||
data class ProductDto(
|
||||
@SerializedName("id") val id: Int,
|
||||
@SerializedName("package_name_id") val packageNameId: Int? = null,
|
||||
@SerializedName("title") val title: String? = null,
|
||||
@SerializedName("price") val price: Long? = null,
|
||||
@SerializedName("type") val type: Int? = null,
|
||||
@SerializedName("uuid") val uuid: String? = null,
|
||||
@SerializedName("descriptions") val descriptions: String? = null,
|
||||
@SerializedName("expires_at") val expiresAt: String? = null,
|
||||
@SerializedName("expire_at") val expireAt: String? = null,
|
||||
@SerializedName("pivot") val pivot: ProductPivot? = null
|
||||
) {
|
||||
/** تاریخ انقضای اشتراکِ این کاربر؛ از pivot یا فیلدهای مستقیمِ محصول. */
|
||||
val resolvedExpireAt: String?
|
||||
get() = pivot?.expiresAt ?: pivot?.expireAt ?: expiresAt ?: expireAt
|
||||
}
|
||||
|
||||
/** اطلاعات رابطهی کاربر-محصول (شامل تاریخ انقضای خرید). */
|
||||
data class ProductPivot(
|
||||
@SerializedName("expires_at") val expiresAt: String? = null,
|
||||
@SerializedName("expire_at") val expireAt: String? = null,
|
||||
@SerializedName("created_at") val createdAt: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* پاسخ `GET /status` که همان پروفایل کاربر بههمراه فهرست محصولاتِ خریداریشده است.
|
||||
* اشتراک فعال یعنی [products] خالی نباشد؛ عنوان اشتراک از اولین محصول خوانده میشود.
|
||||
*/
|
||||
data class StatusDto(
|
||||
@SerializedName("id") val id: Int? = null,
|
||||
@SerializedName("first_name") val firstName: String? = null,
|
||||
@SerializedName("last_name") val lastName: String? = null,
|
||||
@SerializedName("full_name") val fullName: String? = null,
|
||||
@SerializedName("mobile") val mobile: String? = null,
|
||||
@SerializedName("email") val email: String? = null,
|
||||
@SerializedName("avatar") val avatar: String? = null,
|
||||
@SerializedName("wallet") val wallet: Long? = null,
|
||||
@SerializedName("products") val products: List<ProductDto>? = null
|
||||
) {
|
||||
/** کاربر در صورت داشتن حداقل یک محصول، اشتراک فعال دارد. */
|
||||
val isSubscribed: Boolean get() = !products.isNullOrEmpty()
|
||||
|
||||
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
|
||||
val subscriptionTitle: String? get() = products?.firstOrNull()?.title
|
||||
|
||||
/** تاریخ انقضای اشتراک جاری. */
|
||||
val subscriptionExpireAt: String? get() = products?.firstOrNull()?.resolvedExpireAt
|
||||
|
||||
val displayName: String? get() = fullName?.takeIf { it.isNotBlank() }
|
||||
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/** بدنهی درخواست خرید اشتراک. */
|
||||
data class SubscribeRequest(
|
||||
@SerializedName("purchase_token") val purchaseToken: String,
|
||||
@SerializedName("gateway") val gateway: String
|
||||
)
|
||||
|
||||
/** پاسخ خرید اشتراک. موفقیت اصلی با کد ۲xx مشخص میشود. */
|
||||
data class SubscribeResponse(
|
||||
@SerializedName("message") val message: String? = null,
|
||||
@SerializedName("status") val status: String? = null
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.approagency.pharmacy.data.remote
|
||||
|
||||
import com.approagency.pharmacy.data.dto.CheckOtpResponse
|
||||
import com.approagency.pharmacy.data.dto.LoginOtpResponse
|
||||
import com.approagency.pharmacy.data.dto.ProductDto
|
||||
import com.approagency.pharmacy.data.dto.StatusDto
|
||||
import com.approagency.pharmacy.data.dto.SubscribeRequest
|
||||
import com.approagency.pharmacy.data.dto.SubscribeResponse
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Field
|
||||
import retrofit2.http.FormUrlEncoded
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/** بکاند اشتراک/احراز هویت آپرواجنسی ([Config.AUTH_BASE_URL]). */
|
||||
interface ApproApiService {
|
||||
|
||||
/** ارسال کد یکبارمصرف به موبایل. */
|
||||
@FormUrlEncoded
|
||||
@POST("auth/login-otp")
|
||||
suspend fun loginOtp(
|
||||
@Field("mobile") mobile: String,
|
||||
@Field("package_name") packageName: String,
|
||||
@Field("fcm_token") fcmToken: String? = null
|
||||
): LoginOtpResponse
|
||||
|
||||
/** تأیید کد و دریافت توکن نشست. */
|
||||
@FormUrlEncoded
|
||||
@POST("auth/check-otp")
|
||||
suspend fun checkOtp(
|
||||
@Field("mobile") mobile: String,
|
||||
@Field("token") token: String
|
||||
): CheckOtpResponse
|
||||
|
||||
/** خروج از حساب (توکن از طریق اینترسپتور ارسال میشود). */
|
||||
@PUT("auth/logout")
|
||||
suspend fun logout()
|
||||
|
||||
/** وضعیت اشتراک کاربر برای این پکیج. */
|
||||
@GET("status")
|
||||
suspend fun getStatus(
|
||||
@Query("package_name") packageName: String
|
||||
): StatusDto
|
||||
|
||||
/** فهرست محصولات اشتراک قابل خرید برای این پکیج. */
|
||||
@GET("package-names/{name}/products")
|
||||
suspend fun getProducts(
|
||||
@Path("name") packageName: String
|
||||
): List<ProductDto>
|
||||
|
||||
/** ثبت خرید اشتراک پس از پرداخت موفق در درگاه (مایکت/بازار). */
|
||||
@PUT("package-names/{name}/products/{product_id}/subscribe")
|
||||
suspend fun subscribe(
|
||||
@Path("name") packageName: String,
|
||||
@Path("product_id") productId: Int,
|
||||
@Body body: SubscribeRequest
|
||||
): SubscribeResponse
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.approagency.pharmacy.data.remote
|
||||
|
||||
import com.approagency.pharmacy.data.local.SessionManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* هدرهای لازم برای بکاند آپرواجنسی را اضافه میکند:
|
||||
* - `Accept: application/json`
|
||||
* - `Authorization: Bearer <token>` در صورت وجود توکن
|
||||
*
|
||||
* این اینترسپتور فقط روی کلاینت اختصاصیِ آپرواجنسی نصب میشود؛ بنابراین توکن
|
||||
* هرگز به دارویاب یا بکاند جستجوی دارو ارسال نمیشود.
|
||||
*/
|
||||
class AuthInterceptor(
|
||||
private val sessionManager: SessionManager
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val builder = chain.request().newBuilder()
|
||||
.header("Accept", "application/json")
|
||||
|
||||
sessionManager.getToken()?.takeIf { it.isNotBlank() }?.let { token ->
|
||||
builder.header("Authorization", "Bearer $token")
|
||||
}
|
||||
|
||||
return chain.proceed(builder.build())
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.approagency.pharmacy.data.remote
|
||||
|
||||
import com.approagency.pharmacy.data.dto.DarmanModel
|
||||
import com.approagency.pharmacy.data.dto.DrugListResponse
|
||||
import com.approagency.pharmacy.data.dto.DrugModels
|
||||
import retrofit2.http.GET
|
||||
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
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ 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.DrugApiService
|
||||
import com.approagency.pharmacy.data.remote.DrugDetailParser
|
||||
import com.approagency.pharmacy.data.remote.DrugHtmlParser
|
||||
import com.approagency.pharmacy.data.remote.PharmacyHtmlParser
|
||||
@@ -20,37 +20,39 @@ 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,
|
||||
private val localDrugDataSource: LocalDrugDataSource,
|
||||
): DrugRepository {
|
||||
// اطلاعات دارو همیشه از پایگاهدادهٔ محلیِ همراهِ اپ (assets/DrugStore.db) خوانده میشود.
|
||||
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.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 {
|
||||
val response= apiService.getDrugDetail(cod = cod)
|
||||
println(response.message)
|
||||
return Result.success(response)
|
||||
}catch (e: Exception){
|
||||
println(e.message)
|
||||
Result.success(localDrugDataSource.drugDetail(cod))
|
||||
} catch (e: Exception) {
|
||||
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.success(localDrugDataSource.darmanGroups())
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
@@ -63,19 +65,13 @@ class DrugRepositoryImpl(
|
||||
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))
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(Exception("An error occurred: ${e.message}", e))
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package com.approagency.pharmacy.di
|
||||
|
||||
|
||||
import com.approagency.pharmacy.data.billing.BazaarPurchaseGateway
|
||||
import com.approagency.pharmacy.data.local.LocalDrugDataSource
|
||||
import com.approagency.pharmacy.data.local.SessionManager
|
||||
import com.approagency.pharmacy.data.local.database.LabDatabase
|
||||
import com.approagency.pharmacy.data.remote.ApproApiService
|
||||
import com.approagency.pharmacy.data.remote.AuthInterceptor
|
||||
import com.approagency.pharmacy.data.remote.DarooyabApiService
|
||||
import com.approagency.pharmacy.data.remote.DrugApiService
|
||||
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
|
||||
@@ -19,12 +26,16 @@ 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.approgency.drug.presentation.viewModel.SearchViewModel
|
||||
import com.approagency.pharmacy.presentation.viewModel.SearchViewModel
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
@@ -37,12 +48,15 @@ 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 authRetrofitQualifier = qualifier("authRetrofit")
|
||||
val appModule= module {
|
||||
|
||||
single { LabDatabase.getInstance(androidContext()) }
|
||||
|
||||
// نشست کاربر روی دستگاه (توکن، موبایل، سهمیهی جستجوی رایگان، وضعیت اشتراک)
|
||||
single { SessionManager(androidContext()) }
|
||||
|
||||
single { get<LabDatabase>().testGroupDao() }
|
||||
single { get<LabDatabase>().testItemDao() }
|
||||
|
||||
@@ -62,19 +76,6 @@ val appModule= module {
|
||||
.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()
|
||||
@@ -89,12 +90,44 @@ val appModule= module {
|
||||
retrofit.create(DarooyabApiService::class.java)
|
||||
}
|
||||
|
||||
// ========== Retrofit for APPROAGENCY auth/subscription backend ==========
|
||||
// کلاینت مجزا با AuthInterceptor تا توکن فقط به این بکاند ارسال شود.
|
||||
single<Retrofit>(authRetrofitQualifier) {
|
||||
val authClient = OkHttpClient.Builder()
|
||||
.addInterceptor(AuthInterceptor(get()))
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl(Config.AUTH_BASE_URL)
|
||||
.client(authClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
single<ApproApiService> {
|
||||
val retrofit: Retrofit = get(authRetrofitQualifier)
|
||||
retrofit.create(ApproApiService::class.java)
|
||||
}
|
||||
|
||||
single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
|
||||
|
||||
// درگاه پرداخت: روی شاخهٔ bazar پیادهسازی کافهبازار.
|
||||
single<PurchaseGateway> { BazaarPurchaseGateway(androidContext(), get()) }
|
||||
|
||||
|
||||
factory { DrugHtmlParser() }
|
||||
factory { DrugDetailParser() }
|
||||
// منبع آفلاین دارو (assets/DrugStore.db) برای fallback هنگام خطای شبکه
|
||||
single { LocalDrugDataSource(androidContext()) }
|
||||
|
||||
//repo
|
||||
single<DrugRepository> {
|
||||
DrugRepositoryImpl(get<DrugApiService>() , get<DarooyabApiService>() , get() , get())
|
||||
DrugRepositoryImpl(get<DarooyabApiService>() , get() , get() , get())
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +183,20 @@ val appModule= module {
|
||||
}
|
||||
|
||||
viewModel {
|
||||
SearchViewModel(get())
|
||||
SearchViewModel(get(), get(), get())
|
||||
}
|
||||
|
||||
// کنترلر سراسری نمایش شیت حساب
|
||||
single { AccountSheetController() }
|
||||
|
||||
// پلِ تحویل کدِ خواندهشده از پیامک به شیت ورود
|
||||
single { OtpAutoFillBus() }
|
||||
|
||||
// کنترلر نمایش صفحهٔ شرایط و قوانین
|
||||
single { TermsController() }
|
||||
|
||||
viewModel {
|
||||
AccountViewModel(get(), get(), get())
|
||||
}
|
||||
viewModel {
|
||||
PharmacyViewModel(get() , get ())
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.approagency.pharmacy.domain.billing
|
||||
|
||||
import android.app.Activity
|
||||
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
||||
|
||||
/**
|
||||
* انتزاع درگاه پرداخت درونبرنامهای.
|
||||
*
|
||||
* این اینترفیس مرز میان منطقِ مشترکِ اشتراک و پیادهسازیِ مخصوصِ هر فروشگاه است:
|
||||
* - شاخهی `myket` → پیادهسازی مایکت (Myket IAB)
|
||||
* - شاخهی `bazar` → پیادهسازی کافهبازار (Poolakey)
|
||||
*
|
||||
* جریان: درگاه خرید را انجام میدهد و یک [PurchaseResult.purchaseToken] برمیگرداند؛
|
||||
* سپس لایهی اشتراک آن توکن را با `gateway = [name]` به سرور آپرواجنسی میفرستد.
|
||||
*/
|
||||
interface PurchaseGateway {
|
||||
|
||||
/** نام درگاه که به سرور ارسال میشود (مثلاً "myket" یا "bazaar"). */
|
||||
val name: String
|
||||
|
||||
/** آیا این درگاه روی این بیلد فعال/پیکربندی شده است؟ */
|
||||
val isAvailable: Boolean
|
||||
|
||||
/**
|
||||
* خرید [product] را در فروشگاه آغاز میکند.
|
||||
* نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد.
|
||||
*/
|
||||
suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result<PurchaseResult>
|
||||
|
||||
/**
|
||||
* مصرفِ خرید پس از تأییدِ سرور، تا کاربر بتواند دوباره (دورهٔ بعد) خرید کند.
|
||||
* فقط برای درگاههایی که مدلِ مصرفشدنی دارند (مثل مایکت) معنا دارد؛ برای
|
||||
* بقیه میتواند بیاثر باشد.
|
||||
*/
|
||||
suspend fun consume(purchaseToken: String): Result<Unit>
|
||||
}
|
||||
|
||||
/** نتیجهی یک خرید موفق در فروشگاه. */
|
||||
data class PurchaseResult(
|
||||
val purchaseToken: String,
|
||||
val orderId: String? = null
|
||||
)
|
||||
@@ -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,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?
|
||||
)
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ fun AppNavGraph() {
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "home"
|
||||
startDestination = "search"
|
||||
) {
|
||||
|
||||
// ───────────────
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
|
||||
@@ -70,7 +71,7 @@ fun BottomBar(navController: NavHostController) {
|
||||
.clickable {
|
||||
if (!selected) {
|
||||
navController.navigate(item.first) {
|
||||
popUpTo("home") {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
|
||||
@@ -2,25 +2,115 @@ 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()
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
BottomBar(navController)
|
||||
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()
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.WorkspacePremium
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.approagency.pharmacy.data.local.AccountState
|
||||
import com.approagency.pharmacy.domain.model.FontScale
|
||||
import com.approagency.pharmacy.domain.model.ThemeMode
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
/**
|
||||
* کشوی کناری حساب کاربری با طراحی بهبودیافته:
|
||||
* - سربرگ با آواتار، نام/موبایل و نشانِ وضعیت اشتراک
|
||||
* - کارت اطلاعات (موبایل، اشتراک، تاریخ انقضا، نسخهی برنامه)
|
||||
* - انتخاب حالت نمایش (سیستم/روشن/تاریک)
|
||||
* - دکمهی خروج
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountDrawer(
|
||||
account: AccountState,
|
||||
appVersion: String,
|
||||
themeMode: ThemeMode,
|
||||
onThemeModeChange: (ThemeMode) -> Unit,
|
||||
fontScale: FontScale,
|
||||
onFontScaleChange: (FontScale) -> Unit,
|
||||
onShowTerms: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.width(280.dp),
|
||||
drawerContainerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = MaterialTheme.dime.lg, vertical = MaterialTheme.dime.xl)
|
||||
) {
|
||||
Header(account)
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.xl))
|
||||
|
||||
// کارت اطلاعات حساب
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(MaterialTheme.dime.md)) {
|
||||
InfoRow(
|
||||
icon = Icons.Filled.Phone,
|
||||
label = "شماره موبایل",
|
||||
value = account.mobile ?: "-"
|
||||
)
|
||||
InfoRow(
|
||||
icon = Icons.Filled.WorkspacePremium,
|
||||
label = "اشتراک",
|
||||
value = account.subscriptionTitle ?: "بدون اشتراک",
|
||||
highlight = account.isSubscribed
|
||||
)
|
||||
if (account.isSubscribed) {
|
||||
InfoRow(
|
||||
icon = Icons.Filled.CalendarMonth,
|
||||
label = "تاریخ انقضا",
|
||||
value = account.subscriptionExpireAt ?: "-"
|
||||
)
|
||||
}
|
||||
InfoRow(
|
||||
icon = Icons.Filled.Info,
|
||||
label = "نسخه برنامه",
|
||||
value = appVersion.ifBlank { "-" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.xl))
|
||||
|
||||
// انتخاب حالت نمایش
|
||||
Text(
|
||||
text = "حالت نمایش",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||
ThemeModeSelector(selected = themeMode, onSelect = onThemeModeChange)
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||
|
||||
// اندازهٔ متن (برای کاربرانِ مسنتر)
|
||||
Text(
|
||||
text = "اندازهٔ متن",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||
FontScaleSelector(selected = fontScale, onSelect = onFontScaleChange)
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||
|
||||
TextButton(
|
||||
onClick = onShowTerms,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Article,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.width(MaterialTheme.dime.sm))
|
||||
Text(
|
||||
text = "شرایط و قوانین",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
|
||||
if (account.isLoggedIn) {
|
||||
OutlinedButton(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(Modifier.size(MaterialTheme.dime.sm))
|
||||
Text(
|
||||
text = "خروج از حساب",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(account: AccountState) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(MaterialTheme.dime.md))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = account.displayName?.takeIf { it.isNotBlank() }
|
||||
?: account.mobile
|
||||
?: "کاربر مهمان",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.xs))
|
||||
SubscriptionBadge(isSubscribed = account.isSubscribed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionBadge(isSubscribed: Boolean) {
|
||||
val container = if (isSubscribed)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val content = if (isSubscribed)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Surface(color = container, shape = CircleShape) {
|
||||
Text(
|
||||
text = if (isSubscribed) "اشتراک فعال" else "بدون اشتراک",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = content,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = MaterialTheme.dime.sm,
|
||||
vertical = MaterialTheme.dime.xxs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
highlight: Boolean = false
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = MaterialTheme.dime.sm),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(Modifier.size(MaterialTheme.dime.sm))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (highlight)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ThemeModeSelector(
|
||||
selected: ThemeMode,
|
||||
onSelect: (ThemeMode) -> Unit
|
||||
) {
|
||||
val options = listOf(
|
||||
Triple(ThemeMode.SYSTEM, "سیستم", Icons.Filled.BrightnessAuto),
|
||||
Triple(ThemeMode.LIGHT, "روشن", Icons.Filled.LightMode),
|
||||
Triple(ThemeMode.DARK, "تاریک", Icons.Filled.DarkMode)
|
||||
)
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
options.forEachIndexed { index, (mode, label, icon) ->
|
||||
SegmentedButton(
|
||||
selected = selected == mode,
|
||||
onClick = { onSelect(mode) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
},
|
||||
label = { Text(label, style = MaterialTheme.typography.labelMedium) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FontScaleSelector(
|
||||
selected: FontScale,
|
||||
onSelect: (FontScale) -> Unit
|
||||
) {
|
||||
val options = FontScale.entries
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
options.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
selected = selected == option,
|
||||
onClick = { onSelect(option) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
||||
icon = {},
|
||||
label = { Text(option.label, style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
|
||||
import com.approagency.pharmacy.presentation.common.OtpTextField
|
||||
import com.approagency.pharmacy.presentation.common.PrimaryButton
|
||||
import com.approagency.pharmacy.presentation.legal.TermsController
|
||||
import com.approagency.pharmacy.presentation.viewModel.AccountPhase
|
||||
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
|
||||
import com.vada.caller.ui.theme.dime
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountSheet(
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: AccountViewModel = koinViewModel()
|
||||
) {
|
||||
val ui by viewModel.ui.collectAsState()
|
||||
val account by viewModel.account.collectAsState()
|
||||
val activity = LocalContext.current as? Activity
|
||||
val otpAutoFillBus: OtpAutoFillBus = koinInject()
|
||||
val termsController: TermsController = koinInject()
|
||||
|
||||
LaunchedEffect(Unit) { viewModel.onSheetOpened() }
|
||||
|
||||
// پس از خرید موفق، شیت بسته میشود (نوار بالای اپ خودش بهروز میشود).
|
||||
LaunchedEffect(ui.purchaseSuccess) {
|
||||
if (ui.purchaseSuccess) {
|
||||
viewModel.consumePurchaseSuccess()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// در مرحلهی کد، گوشدادن به پیامک را آغاز کن و با خروج متوقفش کن.
|
||||
val isOtpStep = ui.phase == AccountPhase.EnterOtp
|
||||
DisposableEffect(isOtpStep) {
|
||||
val autofill = activity as? OtpAutofillController
|
||||
if (isOtpStep) autofill?.startOtpAutofill()
|
||||
onDispose { autofill?.stopOtpAutofill() }
|
||||
}
|
||||
|
||||
// کدِ خواندهشده از پیامک را در فیلد بگذار و بهصورت خودکار تأیید کن.
|
||||
LaunchedEffect(Unit) {
|
||||
otpAutoFillBus.codes.collect { code ->
|
||||
viewModel.updateOtp(code)
|
||||
viewModel.verifyOtp()
|
||||
}
|
||||
}
|
||||
|
||||
CustomModalBottomSheet(onDismiss = onDismiss) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 220.dp)
|
||||
.padding(MaterialTheme.dime.lg)
|
||||
) {
|
||||
when (ui.phase) {
|
||||
AccountPhase.EnterMobile -> {
|
||||
SheetTitle("ورود به حساب")
|
||||
Text(
|
||||
"برای ادامهی جستجو شماره موبایل خود را وارد کنید",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
OutlinedTextField(
|
||||
value = viewModel.mobile,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
onValueChange = { viewModel.updateMobile(it) },
|
||||
label = { Text("شماره موبایل" , textAlign = TextAlign.Right ) },
|
||||
placeholder = { Text("09xxxxxxxxx") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = { viewModel.sendOtp() })
|
||||
TermsConsentNote(onShowTerms = { termsController.show() })
|
||||
}
|
||||
|
||||
AccountPhase.EnterOtp -> {
|
||||
SheetTitle("تأیید شماره")
|
||||
Text(
|
||||
"کد ارسالشده به ${viewModel.mobile} را وارد کنید",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||
OtpTextField(
|
||||
otpText = viewModel.otp,
|
||||
otpCount = 5,
|
||||
onOtpTextChange = { value, _ -> viewModel.updateOtp(value) },
|
||||
onComplete = { viewModel.verifyOtp() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = { viewModel.verifyOtp() })
|
||||
TextButton(onClick = { viewModel.editMobile() }, enabled = !ui.busy) {
|
||||
Text("ویرایش شماره موبایل")
|
||||
}
|
||||
}
|
||||
|
||||
AccountPhase.Products -> {
|
||||
SheetTitle("اشتراک ویژه")
|
||||
Text(
|
||||
"سهمیهی جستجوی رایگان شما به پایان رسیده است. برای جستجوی نامحدود اشتراک تهیه کنید.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||
when {
|
||||
ui.productsLoading -> Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
|
||||
) {
|
||||
repeat(2) { SubscriptionProductCardSkeleton() }
|
||||
}
|
||||
else -> Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
|
||||
) {
|
||||
ui.products.forEach { product ->
|
||||
SubscriptionProductCard(
|
||||
product = product,
|
||||
isPurchasing = ui.purchasingProductId == product.id,
|
||||
onBuy = { activity?.let { viewModel.purchase(it, product) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!viewModel.gatewayAvailable) {
|
||||
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||
Text(
|
||||
"درگاه پرداخت روی این نسخه فعال نیست.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AccountPhase.Subscribed -> {
|
||||
SheetTitle("اشتراک فعال")
|
||||
Text(
|
||||
account.subscriptionTitle?.let { "اشتراک شما فعال است: $it" }
|
||||
?: "اشتراک شما فعال است.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ui.error?.let { message ->
|
||||
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetTitle(text: String) {
|
||||
Text(text = text, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(MaterialTheme.dime.xs))
|
||||
}
|
||||
|
||||
/** لینکِ «شرایط و قوانین» زیر فرمِ ورود (برای دسترسی و بازبینیِ فروشگاه). */
|
||||
@Composable
|
||||
private fun TermsConsentNote(onShowTerms: () -> Unit) {
|
||||
TextButton(onClick = onShowTerms, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "با ادامه، شرایط و قوانین را میپذیرید",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* کنترلر سراسریِ نمایش شیتِ حساب (ورود/اشتراک).
|
||||
*
|
||||
* تکنمونه در Koin؛ هم نوار بالای اپ و هم گیتِ جستجو میتوانند با [show] آن را
|
||||
* باز کنند و [MainContainer] با مشاهدهی [visible] شیت را نمایش میدهد.
|
||||
*/
|
||||
class AccountSheetController {
|
||||
private val _visible = MutableStateFlow(false)
|
||||
val visible: StateFlow<Boolean> = _visible.asStateFlow()
|
||||
|
||||
fun show() { _visible.value = true }
|
||||
fun hide() { _visible.value = false }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
/**
|
||||
* پلِ تکنمونه برای رساندن کدِ خواندهشده از پیامک (SMS User Consent) از
|
||||
* [MainActivity] به شیتِ ورود. اکتیویتی کد را [submit] میکند و شیت آن را
|
||||
* از [codes] میخواند و در فیلد OTP قرار میدهد.
|
||||
*/
|
||||
class OtpAutoFillBus {
|
||||
private val _codes = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val codes: SharedFlow<String> = _codes.asSharedFlow()
|
||||
|
||||
fun submit(code: String) {
|
||||
_codes.tryEmit(code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
/**
|
||||
* قراردادِ شروع/توقفِ خودکارپُرکُنِ کدِ پیامک.
|
||||
*
|
||||
* اکتیویتی میزبان آن را پیادهسازی میکند؛ شیتِ ورود فقط به این اینترفیس وابسته
|
||||
* است و از نوعِ مشخصِ اکتیویتی بیخبر میماند.
|
||||
*/
|
||||
interface OtpAutofillController {
|
||||
fun startOtpAutofill()
|
||||
fun stopOtpAutofill()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
||||
import com.approagency.pharmacy.presentation.common.PrimaryButton
|
||||
import com.approagency.pharmacy.presentation.common.shimmer
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
/** کارت یک محصول اشتراک بههمراه دکمهی خرید. */
|
||||
@Composable
|
||||
fun SubscriptionProductCard(
|
||||
product: SubscriptionProduct,
|
||||
isPurchasing: Boolean,
|
||||
onBuy: () -> Unit
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(MaterialTheme.dime.lg)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(product.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"${formatPrice(product.price)} تومان",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (!product.description.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(MaterialTheme.dime.xs))
|
||||
Text(
|
||||
product.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
PrimaryButton(text = "خرید", height = 44, isLoading = isPurchasing, onClick = onBuy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** جایگیرندهی shimmer برای کارت محصول هنگام بارگذاری. */
|
||||
@Composable
|
||||
fun SubscriptionProductCardSkeleton() {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(MaterialTheme.dime.lg)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(120.dp)
|
||||
.height(18.dp)
|
||||
.shimmer()
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.width(72.dp)
|
||||
.height(18.dp)
|
||||
.shimmer()
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.shimmer(shape = MaterialTheme.shapes.medium)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** قالببندی قیمت با جداکنندهی هزارگان. */
|
||||
private fun formatPrice(price: Long): String =
|
||||
price.toString().reversed().chunked(3).joinToString(",").reversed()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.approagency.pharmacy.presentation.common
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* فیلد ورود کدِ یکبارمصرف بهصورت خانههای مجزا با انیمیشن خانهی فعال.
|
||||
* همیشه LTR است و رنگها از تم گرفته میشوند تا در حالت تاریک/روشن هماهنگ باشد.
|
||||
*
|
||||
* @param onOtpTextChange (متن، آیا کامل شد) — با هر تغییر صدا زده میشود.
|
||||
* @param onComplete وقتی همهی خانهها پر شد یکبار صدا زده میشود.
|
||||
*/
|
||||
@Composable
|
||||
fun OtpTextField(
|
||||
otpText: String,
|
||||
onOtpTextChange: (String, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
otpCount: Int = 5,
|
||||
size: Dp = 48.dp,
|
||||
focusedSize: Dp = 56.dp,
|
||||
onComplete: () -> Unit = {}
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var isCompleted by remember { mutableStateOf(false) }
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
BasicTextField(
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
value = TextFieldValue(otpText, selection = TextRange(otpText.length)),
|
||||
onValueChange = { newValue ->
|
||||
if (newValue.text.length <= otpCount) {
|
||||
onOtpTextChange(newValue.text, newValue.text.length == otpCount)
|
||||
if (newValue.text.length == otpCount && !isCompleted) {
|
||||
isCompleted = true
|
||||
focusManager.clearFocus()
|
||||
onComplete()
|
||||
isCompleted = false
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.NumberPassword,
|
||||
imeAction = if (otpText.length == otpCount) ImeAction.Done else ImeAction.Next
|
||||
),
|
||||
decorationBox = {
|
||||
Row(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
repeat(otpCount) { index ->
|
||||
val isFocused = otpText.length == index
|
||||
val animatedSize by animateDpAsState(
|
||||
targetValue = if (isFocused) focusedSize else size,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "otpCellSize"
|
||||
)
|
||||
val char = when {
|
||||
index == otpText.length -> "_"
|
||||
index > otpText.length -> ""
|
||||
else -> otpText[index].toString()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = if (isFocused)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
.size(animatedSize),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = char,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
if (index < otpCount - 1) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.approagency.pharmacy.presentation.common
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
/**
|
||||
* نوار تبهای سگمنتی: تبِ انتخابشده بهصورت یک «قرص» پررنگ نمایش داده میشود
|
||||
* تا کاملاً قابل لمس و قابلتشخیص باشد. عرض همهی تبها برابر است.
|
||||
*/
|
||||
@Composable
|
||||
fun SegmentedTabBar(
|
||||
tabs: List<String>,
|
||||
selectedIndex: Int,
|
||||
onSelected: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = MaterialTheme.dime
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(dime.xxs),
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.xxs)
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
val selected = index == selectedIndex
|
||||
|
||||
val background by animateColorAsState(
|
||||
targetValue = if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "segmentBackground"
|
||||
)
|
||||
val foreground by animateColorAsState(
|
||||
targetValue = if (selected)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = tween(durationMillis = 200),
|
||||
label = "segmentForeground"
|
||||
)
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.background(background)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) { onSelected(index) }
|
||||
.padding(vertical = dime.sm, horizontal = dime.xs),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||
color = foreground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.approagency.pharmacy.presentation.common
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
||||
/**
|
||||
* افکت درخششِ متحرک (shimmer) برای جایگیرندههای لودینگ.
|
||||
* رنگها از تم گرفته میشوند تا در حالت تاریک/روشن هماهنگ باشد.
|
||||
*/
|
||||
fun Modifier.shimmer(shape: Shape = RoundedCornerShape(8.dp)): Modifier = composed {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val transition = rememberInfiniteTransition(label = "shimmer")
|
||||
val startX by transition.animateFloat(
|
||||
initialValue = -2f * size.width.coerceAtLeast(1),
|
||||
targetValue = 2f * size.width.coerceAtLeast(1),
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "shimmerX"
|
||||
)
|
||||
val base = MaterialTheme.colorScheme.surfaceVariant
|
||||
val highlight = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
|
||||
clip(shape)
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(base, highlight, base),
|
||||
start = Offset(startX, 0f),
|
||||
end = Offset(startX + size.width.toFloat(), size.height.toFloat())
|
||||
)
|
||||
)
|
||||
.onGloballyPositioned { size = it.size }
|
||||
}
|
||||
@@ -1,74 +1,97 @@
|
||||
package com.approagency.pharmacy.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.Spacer
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocalPharmacy
|
||||
import androidx.compose.material.icons.filled.Medication
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.domain.model.DrugSearchResult
|
||||
import com.approagency.pharmacy.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:(DrugSearchResult) ->Unit,
|
||||
onClickDrugStore: (DrugSearchResult) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
|
||||
CustomBox (
|
||||
CustomBox(
|
||||
modifier = modifier
|
||||
.padding(bottom = MaterialTheme.dime.sm)
|
||||
.fillMaxWidth()
|
||||
.clickable { onClickDetail(drug) },
|
||||
|
||||
) {
|
||||
Row (
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Column(
|
||||
modifier = Modifier.padding(dime.lg),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.End
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(MaterialTheme.dime.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.xs)
|
||||
) {
|
||||
Text(
|
||||
text = drug.persianName,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
drug.englishName?.let {
|
||||
drug.englishName?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "کد: ${drug.genericId}",
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dime.sm)
|
||||
) {
|
||||
TextButton(onClick = {onClickDetail(drug)} , content = {
|
||||
Text("اطلاعات دارویی")
|
||||
})
|
||||
TextButton(onClick = {onClickDrugStore(drug)} , content = {
|
||||
Text("موجودی داروخانه")
|
||||
})
|
||||
Button(
|
||||
onClick = { onClickDrugStore(drug) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.LocalPharmacy,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.size(MaterialTheme.dime.xs))
|
||||
Text("موجودی داروخانه", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onClickDetail(drug) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(44.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Medication,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.size(MaterialTheme.dime.xs))
|
||||
Text("اطلاعات دارویی", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,22 @@ package com.approagency.pharmacy.presentation.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import com.approagency.pharmacy.domain.model.PharmacyItem
|
||||
import com.approagency.pharmacy.presentation.common.CustomBox
|
||||
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
|
||||
import com.approagency.pharmacy.presentation.common.Loading
|
||||
import com.approagency.pharmacy.presentation.viewModel.PharmacyDetailState
|
||||
import com.approagency.pharmacy.presentation.viewModel.PharmacyState
|
||||
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
|
||||
import com.approagency.pharmacy.utils.provinces
|
||||
import com.vada.caller.ui.theme.LocalDime
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
@SuppressLint("ConfigurationScreenWidthHeight")
|
||||
@@ -41,155 +33,99 @@ fun PharmacyBottomSheet(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = false, confirmValueChange = { true })
|
||||
|
||||
skipPartiallyExpanded = true, confirmValueChange = { true }
|
||||
)
|
||||
|
||||
var selectedProvinceId by remember { mutableStateOf("40") } // پیشفرض تهران
|
||||
var showProvinceDropdown by remember { mutableStateOf(false) }
|
||||
|
||||
val pharmacyState by viewModel.state.collectAsState()
|
||||
|
||||
// جستجوی اولیه با استان تهران — با تغییر دارو دوباره اجرا میشود
|
||||
// (در صورت خالی بودن شناسه، ViewModel وضعیت قبلی را پاک و خطا نمایش میدهد)
|
||||
// شیت بلند ثابت میشود تا لیست داخلی بهدرستی اسکرول شود (نه اینکه درگِ شیت
|
||||
// جای اسکرولِ لیست را بگیرد) و حالتهای لودینگ/خالی/خطا وسط دیده شوند.
|
||||
val sheetHeight = (LocalConfiguration.current.screenHeightDp * 0.85f).dp
|
||||
|
||||
LaunchedEffect(genericDrugId, brandIrc) {
|
||||
viewModel.search(genericDrugId, brandIrc, "40")
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
CustomModalBottomSheet(
|
||||
sheetState = sheetState, onDismiss = onDismiss, scope = scope
|
||||
) {
|
||||
CustomModalBottomSheet(sheetState = sheetState, onDismiss = onDismiss, scope = scope) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
LazyColumn(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 400.dp)
|
||||
.padding(MaterialTheme.dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.xl)
|
||||
.height(sheetHeight)
|
||||
.padding(MaterialTheme.dime.lg)
|
||||
) {
|
||||
item {
|
||||
// سربرگ ثابت
|
||||
Text(
|
||||
text = "جستجوی دارو در داروخانهها",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = MaterialTheme.typography.titleMedium.fontSize
|
||||
)
|
||||
}
|
||||
// عنوان
|
||||
|
||||
item {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showProvinceDropdown,
|
||||
onExpandedChange = { showProvinceDropdown = it }) {
|
||||
OutlinedTextField(
|
||||
value = provinces.find { it.id == selectedProvinceId }?.name
|
||||
?: "انتخاب استان",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showProvinceDropdown) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
shape = MaterialTheme.shapes.large
|
||||
text = "موجودی دارو در داروخانهها",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
|
||||
ProvinceSelector(
|
||||
selectedProvinceId = selectedProvinceId,
|
||||
expanded = showProvinceDropdown,
|
||||
onDismissRequest = { showProvinceDropdown = false }) {
|
||||
provinces.forEach { province ->
|
||||
DropdownMenuItem(text = { Text(province.name) }, onClick = {
|
||||
selectedProvinceId = province.id
|
||||
onExpandedChange = { showProvinceDropdown = it },
|
||||
onProvinceSelected = { id ->
|
||||
selectedProvinceId = id
|
||||
showProvinceDropdown = false
|
||||
// جستجو با استان جدید
|
||||
viewModel.search(genericDrugId, brandIrc, province.id)
|
||||
})
|
||||
viewModel.search(genericDrugId, brandIrc, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// انتخاب استان
|
||||
|
||||
|
||||
// نمایش نتایج
|
||||
when (val state = pharmacyState) {
|
||||
PharmacyState.Loading -> {
|
||||
item {
|
||||
Loading(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp),
|
||||
message = "در حال جستجو..."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyState.Success -> {
|
||||
if (state.items.isEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||
|
||||
// ناحیهی محتوا فضای باقیمانده را پر میکند؛ سربرگ بالا ثابت میماند.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp),
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "هیچ داروخانهای یافت نشد",
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
when (val state = pharmacyState) {
|
||||
PharmacyState.Loading -> Loading(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
message = "در حال جستجو..."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyState.Success -> {
|
||||
if (state.items.isEmpty()) {
|
||||
EmptyMessage("هیچ داروخانهای یافت نشد")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.sm)
|
||||
) {
|
||||
items(state.items) { pharmacy ->
|
||||
PharmacyResultItem(pharmacy = pharmacy, viewModel)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
|
||||
Text(
|
||||
text = "تعداد کل: ${state.items.size} داروخانه",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(bottom = MaterialTheme.dime.xs)
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = MaterialTheme.dime.sm)
|
||||
)
|
||||
}
|
||||
// LazyColumn(
|
||||
// verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
// modifier = Modifier,
|
||||
// ) {
|
||||
// items(state.items) { pharmacy ->
|
||||
// PharmacyResultItem(pharmacy = pharmacy)
|
||||
// }
|
||||
//
|
||||
//
|
||||
// }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyState.Error -> {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
is PharmacyState.Error -> Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = state.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.dime.sm))
|
||||
TextButton(
|
||||
onClick = { viewModel.retry() }) {
|
||||
Text("تلاش مجدد")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||
TextButton(onClick = { viewModel.retry() }) { Text("تلاش مجدد") }
|
||||
}
|
||||
|
||||
PharmacyState.Idle -> {}
|
||||
@@ -198,146 +134,51 @@ fun PharmacyBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProvinceSelector(
|
||||
selectedProvinceId: String,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onProvinceSelected: (String) -> Unit
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = onExpandedChange
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = provinces.find { it.id == selectedProvinceId }?.name ?: "انتخاب استان",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("استان") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
shape = MaterialTheme.shapes.large
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { onExpandedChange(false) }
|
||||
) {
|
||||
provinces.forEach { province ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(province.name) },
|
||||
onClick = { onProvinceSelected(province.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PharmacyResultItem(
|
||||
pharmacy: PharmacyItem, viewModel: PharmacyViewModel, modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
var showDetails by remember { mutableStateOf(false) }
|
||||
// گرفتن State مخصوص این داروخانه از Map
|
||||
val detailStates by viewModel.pharmacyDetailStates.collectAsState()
|
||||
val detailState = detailStates[pharmacy.pharmacyUrl] ?: PharmacyDetailState.Idle
|
||||
|
||||
// Reset detail state when item changes
|
||||
LaunchedEffect(pharmacy.pharmacyUrl) {
|
||||
println(pharmacy.pharmacyUrl)
|
||||
showDetails = false
|
||||
}
|
||||
|
||||
// Load detail when expanded
|
||||
LaunchedEffect(showDetails) {
|
||||
if (showDetails) {
|
||||
// فقط اگر Idle هست (هنوز لود نشده) درخواست بده
|
||||
if (detailState is PharmacyDetailState.Idle) {
|
||||
println(pharmacy.pharmacyUrl)
|
||||
viewModel.loadPharmacyDetail(pharmacy.pharmacyUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
CustomBox(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(dime.xs)
|
||||
) {
|
||||
// نام برند
|
||||
private fun EmptyMessage(message: String) {
|
||||
Text(
|
||||
text = pharmacy.brandName,
|
||||
fontSize = MaterialTheme.typography.labelSmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// نام داروخانه
|
||||
Text(
|
||||
text = pharmacy.pharmacyName,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
|
||||
// موقعیت
|
||||
// موقعیت و دکمه اطلاعات تماس
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${pharmacy.province} - ${pharmacy.city}",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = { showDetails = !showDetails }) {
|
||||
Text(
|
||||
text = if (showDetails) "بستن" else "اطلاعات تماس",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showDetails) {
|
||||
|
||||
when (detailState) {
|
||||
is PharmacyDetailState.Loading -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Loading(size = 24.dp)
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyDetailState.Success -> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = MaterialTheme.dime.xs))
|
||||
// آدرس
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = detailState.detail.address,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
// تلفن
|
||||
if (detailState.detail.phone.isNotBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Phone,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = detailState.detail.phone,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyDetailState.Error -> {
|
||||
Text(
|
||||
text = "خطا در دریافت اطلاعات تماس",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.approagency.pharmacy.presentation.components
|
||||
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.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.pharmacy.domain.model.PharmacyItem
|
||||
import com.approagency.pharmacy.presentation.common.CustomBox
|
||||
import com.approagency.pharmacy.presentation.common.Loading
|
||||
import com.approagency.pharmacy.presentation.viewModel.PharmacyDetailState
|
||||
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
|
||||
import com.vada.caller.ui.theme.LocalDime
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
@Composable
|
||||
fun PharmacyResultItem(
|
||||
pharmacy: PharmacyItem, viewModel: PharmacyViewModel, modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
var showDetails by remember { mutableStateOf(false) }
|
||||
// گرفتن State مخصوص این داروخانه از Map
|
||||
val detailStates by viewModel.pharmacyDetailStates.collectAsState()
|
||||
val detailState = detailStates[pharmacy.pharmacyUrl] ?: PharmacyDetailState.Idle
|
||||
|
||||
// Reset detail state when item changes
|
||||
LaunchedEffect(pharmacy.pharmacyUrl) {
|
||||
showDetails = false
|
||||
}
|
||||
|
||||
// Load detail when expanded
|
||||
LaunchedEffect(showDetails) {
|
||||
if (showDetails) {
|
||||
// فقط اگر Idle هست (هنوز لود نشده) درخواست بده
|
||||
if (detailState is PharmacyDetailState.Idle) {
|
||||
viewModel.loadPharmacyDetail(pharmacy.pharmacyUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
CustomBox(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(dime.xs)
|
||||
) {
|
||||
// نام برند
|
||||
Text(
|
||||
text = pharmacy.brandName,
|
||||
fontSize = MaterialTheme.typography.labelSmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// نام داروخانه
|
||||
Text(
|
||||
text = pharmacy.pharmacyName,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
|
||||
// موقعیت و دکمه اطلاعات تماس
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${pharmacy.province} - ${pharmacy.city}",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = { showDetails = !showDetails }) {
|
||||
Text(
|
||||
text = if (showDetails) "بستن" else "اطلاعات تماس",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showDetails) {
|
||||
|
||||
when (detailState) {
|
||||
is PharmacyDetailState.Loading -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Loading(size = 24.dp)
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyDetailState.Success -> {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = MaterialTheme.dime.xs))
|
||||
// آدرس
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = detailState.detail.address,
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
|
||||
// تلفن
|
||||
if (detailState.detail.phone.isNotBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Phone,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = detailState.detail.phone,
|
||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PharmacyDetailState.Error -> {
|
||||
Text(
|
||||
text = "خطا در دریافت اطلاعات تماس",
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.approagency.pharmacy.presentation.components.drugdetail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
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.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.Icon
|
||||
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.draw.clip
|
||||
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 com.vada.caller.ui.theme.LocalDime
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
/**
|
||||
* بلوکهای مشترکِ ظاهری صفحهی جزئیات دارو.
|
||||
* این کامپوزبلها در فایلهای تب (DrugDetailTabs) استفاده میشوند.
|
||||
*/
|
||||
|
||||
/** کارت متنی با عنوانِ سربرگدار و بدنهی توضیحات. */
|
||||
@Composable
|
||||
internal fun InfoSectionCard(
|
||||
title: String,
|
||||
content: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
|
||||
DetailCard(modifier = modifier) {
|
||||
SectionHeader(title = title)
|
||||
Spacer(modifier = Modifier.height(dime.sm))
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
lineHeight = 22.sp,
|
||||
textAlign = TextAlign.Justify,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* کارت پایهی صفحهی جزئیات — ظاهر یکدست با بقیهی برنامه:
|
||||
* سطح روشن + قاب نازک همرنگ تم، بدون سایهی سنگین.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DetailCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.large
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = shape
|
||||
)
|
||||
.padding(MaterialTheme.dime.md),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/** سربرگ بخش با نوار تأکید کوچک کنار عنوان. */
|
||||
@Composable
|
||||
internal fun SectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 4.dp, height = 18.dp)
|
||||
.clip(RoundedCornerShape(dime.xxs))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** حالت «اطلاعاتی ثبت نشده» با آیکن، یکدست در همهی تبها. */
|
||||
@Composable
|
||||
internal fun DetailEmptyState(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dime.xxl, horizontal = dime.lg),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** ردیف «برچسب: مقدار» با تایپوگرافی یکدست. */
|
||||
@Composable
|
||||
internal fun LabeledRow(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** برچسب کوچک رنگی (مثل «پرخطر» / «حیاتی»). */
|
||||
@Composable
|
||||
internal fun TagChip(
|
||||
text: String,
|
||||
container: Color,
|
||||
onContainer: Color
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = onContainer,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(MaterialTheme.dime.xs))
|
||||
.background(container)
|
||||
.padding(horizontal = MaterialTheme.dime.sm, vertical = MaterialTheme.dime.xxs)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package com.approagency.pharmacy.presentation.components.drugdetail
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.Icon
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.approagency.pharmacy.domain.model.DrugDetail
|
||||
import com.approagency.pharmacy.domain.model.GenericInfo
|
||||
import com.vada.caller.ui.theme.LocalDime
|
||||
|
||||
/** کارت اطلاعات سازنده (برای صفحات برند). */
|
||||
@Composable
|
||||
internal fun ManufacturerInfoCard(
|
||||
manufacturer: String,
|
||||
genericInfo: GenericInfo?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
val onContainer = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.padding(dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = onContainer
|
||||
)
|
||||
Text(
|
||||
text = "سازنده:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = onContainer
|
||||
)
|
||||
Text(
|
||||
text = manufacturer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = onContainer
|
||||
)
|
||||
}
|
||||
|
||||
if (genericInfo != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = onContainer
|
||||
)
|
||||
Text(
|
||||
text = "ماده موثره:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = onContainer
|
||||
)
|
||||
Text(
|
||||
text = genericInfo.persianName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = onContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** تب «عمومی». */
|
||||
@Composable
|
||||
internal 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) {
|
||||
DetailCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(dime.xs)) {
|
||||
drugDetail.drugClass?.let {
|
||||
LabeledRow(label = "طبقه بندی مارتیندل", value = it)
|
||||
}
|
||||
drugDetail.therapeuticClass?.let {
|
||||
LabeledRow(label = "طبقه درمانی", value = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pregnancy Card
|
||||
if (drugDetail.pregnancyCategory != null || drugDetail.pregnancyDescription != null) {
|
||||
val onContainer = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||
.padding(dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(dime.xs)
|
||||
) {
|
||||
Text(
|
||||
text = "مصرف در بارداری",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = onContainer
|
||||
)
|
||||
drugDetail.pregnancyCategory?.let {
|
||||
Text(
|
||||
text = "گروه: $it",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = onContainer
|
||||
)
|
||||
}
|
||||
drugDetail.pregnancyDescription?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
lineHeight = 18.sp,
|
||||
color = onContainer.copy(alpha = 0.9f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// موارد مصرف
|
||||
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()
|
||||
) {
|
||||
DetailEmptyState(text = "اطلاعات عمومی برای این دارو ثبت نشده است")
|
||||
}
|
||||
} 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
|
||||
) {
|
||||
DetailEmptyState(
|
||||
text = "اطلاعات عمومی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** تب «تخصصی». */
|
||||
@Composable
|
||||
internal 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
|
||||
) {
|
||||
DetailEmptyState(
|
||||
text = if (drugDetail.isGeneric)
|
||||
"اطلاعات تخصصی برای این دارو ثبت نشده است"
|
||||
else
|
||||
"اطلاعات تخصصی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** تب «اشکال دارویی». */
|
||||
@Composable
|
||||
internal fun DosageFormsTabContent(drugDetail: DrugDetail) {
|
||||
val dime = LocalDime.current
|
||||
val dosageForms = drugDetail.dosageForms
|
||||
|
||||
if (dosageForms.isEmpty()) {
|
||||
DetailEmptyState(
|
||||
text = if (drugDetail.isGeneric)
|
||||
"اشکال دارویی برای این دارو ثبت نشده است"
|
||||
else
|
||||
"سایر اشکال دارویی این برند ثبت نشده است"
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
|
||||
// اگر صفحه برند است، عنوان متفاوت نشان بده
|
||||
if (!drugDetail.isGeneric) {
|
||||
SectionHeader(
|
||||
title = "سایر محصولات این برند",
|
||||
modifier = Modifier.padding(bottom = dime.xs)
|
||||
)
|
||||
}
|
||||
|
||||
dosageForms.forEach { form ->
|
||||
DetailCard {
|
||||
Text(
|
||||
text = form.persianName,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
if (form.englishName.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(dime.xxs))
|
||||
Text(
|
||||
text = form.englishName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// نشانگرهای خطر
|
||||
if (form.isHighRisk || form.isVital) {
|
||||
Spacer(modifier = Modifier.height(dime.sm))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(dime.xs)) {
|
||||
if (form.isHighRisk) {
|
||||
TagChip(
|
||||
text = "پرخطر",
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
if (form.isVital) {
|
||||
TagChip(
|
||||
text = "حیاتی",
|
||||
container = MaterialTheme.colorScheme.primaryContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** تب «اسامی تجاری». */
|
||||
@Composable
|
||||
internal fun BrandNamesTabContent(drugDetail: DrugDetail) {
|
||||
val dime = LocalDime.current
|
||||
|
||||
if (drugDetail.isGeneric && drugDetail.brandNames.isEmpty()) {
|
||||
DetailEmptyState(text = "اسامی تجاری برای این دارو ثبت نشده است")
|
||||
} else if (drugDetail.isGeneric && drugDetail.brandNames.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
|
||||
drugDetail.brandNames.forEach { brand ->
|
||||
DetailCard {
|
||||
Text(
|
||||
text = brand.persianName,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
brand.manufacturer?.let {
|
||||
Spacer(modifier = Modifier.height(dime.xs))
|
||||
LabeledRow(label = "تولید کننده", value = it)
|
||||
}
|
||||
brand.importer?.let {
|
||||
Spacer(modifier = Modifier.height(dime.xxs))
|
||||
LabeledRow(label = "وارد کننده", value = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// صفحه برند است - اسامی تجاری برای برند معنی ندارد
|
||||
DetailEmptyState(
|
||||
text = "این صفحه مربوط به یک محصول تجاری است. برای مشاهده اسامی تجاری سایر برندها، به صفحه داروی ژنریک مراجعه کنید."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.approagency.pharmacy.presentation.legal
|
||||
|
||||
/** یک بخش از سند شرایط و قوانین. */
|
||||
data class LegalSection(
|
||||
val title: String,
|
||||
val items: List<String>
|
||||
)
|
||||
|
||||
/** محتوای شرایط و قوانینِ اپلیکیشن داروچی (برای نمایش درونبرنامهای). */
|
||||
object AppLegal {
|
||||
|
||||
const val LAST_UPDATED = "۱۴۰۵/۰۳/۲۴"
|
||||
const val SUPPORT_EMAIL = "approagency@gmail.com"
|
||||
|
||||
const val INTRO =
|
||||
"استفاده از اپلیکیشن «داروچی» (که از این پس «برنامه» نامیده میشود) بهمنزلهٔ " +
|
||||
"مطالعه و پذیرشِ کاملِ شرایط و قوانین زیر است. در صورتی که با هر بخشی از این " +
|
||||
"قوانین موافق نیستید، لطفاً از برنامه استفاده نکنید."
|
||||
|
||||
val sections: List<LegalSection> = listOf(
|
||||
LegalSection(
|
||||
title = "۱. معرفی خدمات",
|
||||
items = listOf(
|
||||
"برنامه ابزاری اطلاعرسانی است که امکاناتِ جستوجوی دارو و مشاهدهٔ اطلاعات دارویی، مشاهدهٔ موجودی دارو در داروخانهها و مشاهدهٔ اطلاعات آزمایشهای پزشکی را فراهم میکند.",
|
||||
"این خدمات صرفاً جنبهٔ آموزشی و اطلاعرسانی دارند."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۲. سلب مسئولیت پزشکی (بسیار مهم)",
|
||||
items = listOf(
|
||||
"اطلاعاتِ ارائهشده در برنامه جایگزینِ مشاورهٔ پزشک، داروساز یا کادر درمان نیست و نباید مبنای تشخیص، تجویز یا قطع/شروع مصرف دارو قرار گیرد.",
|
||||
"مصرفِ هر دارو باید زیر نظر پزشک یا داروساز و مطابق نسخه و دستور ایشان انجام شود.",
|
||||
"اطلاعاتِ دارویی ممکن است کامل، بهروز یا بدون خطا نباشد و مسئولیتِ استفاده از آن بر عهدهٔ کاربر است.",
|
||||
"موجودیِ نمایشدادهشدهٔ داروها بر اساس دادههای منابعِ ثالث است و پیش از مراجعه باید از داروخانه تأیید شود.",
|
||||
"در شرایطِ اورژانسی یا بروزِ عوارض، فوراً به پزشک یا اورژانس مراجعه کنید."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۳. حساب کاربری و ورود",
|
||||
items = listOf(
|
||||
"ورود به برنامه از طریق شماره موبایل و کدِ یکبارمصرف (OTP) انجام میشود.",
|
||||
"مسئولیتِ حفظِ شماره موبایل، دستگاه و کدِ تأیید بر عهدهٔ کاربر است.",
|
||||
"کاربر موظف است اطلاعاتِ صحیح ارائه دهد و حساب را در اختیار دیگران قرار ندهد."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۴. جستوجوی رایگان، اشتراک و پرداخت",
|
||||
items = listOf(
|
||||
"برنامه تعدادی جستوجوی رایگان در اختیار کاربر قرار میدهد؛ پس از پایانِ سهمیهٔ رایگان، برای استفادهٔ نامحدود تهیهٔ اشتراک لازم است.",
|
||||
"خرید و پرداختِ اشتراک از طریق درگاهِ پرداختِ درونبرنامهایِ فروشگاه (کافهبازار) انجام میشود و تابعِ قوانینِ همان فروشگاه است.",
|
||||
"قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده میشود.",
|
||||
"بازگشتِ وجه تابعِ سیاستهای فروشگاهِ کافهبازار است و از طریق همان فروشگاه پیگیری میشود.",
|
||||
"در صورتی که پس از پرداختِ موفق اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۵. حریم خصوصی و دادهها",
|
||||
items = listOf(
|
||||
"برای ارائهٔ خدمات، حداقلِ اطلاعاتِ لازم (از جمله شماره موبایل و وضعیتِ اشتراک) دریافت و نگهداری میشود.",
|
||||
"اطلاعاتِ خرید (توکنِ خرید) صرفاً برای فعالسازیِ اشتراک به سرور ارسال میشود.",
|
||||
"اطلاعاتِ کاربران بدون رضایتِ ایشان در اختیارِ اشخاصِ ثالثِ غیرمرتبط قرار نمیگیرد، مگر در مواردی که قانون الزام کند."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۶. تعهدات کاربر",
|
||||
items = listOf(
|
||||
"استفاده از برنامه صرفاً برای اهدافِ قانونی و شخصی.",
|
||||
"ایجادنکردنِ اخلال در عملکردِ برنامه و خودداری از مهندسیِ معکوس یا دورزدنِ محدودیتها.",
|
||||
"بازنشر یا تجاریسازیِ محتوای برنامه بدون اجازه ممنوع است."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۷. مالکیت فکری",
|
||||
items = listOf(
|
||||
"تمامِ حقوقِ مربوط به برنامه، نام، نشان، رابط کاربری و محتوای آن متعلق به مالکِ برنامه است و هرگونه استفادهٔ غیرمجاز پیگردِ قانونی دارد."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۸. محدودیت مسئولیت",
|
||||
items = listOf(
|
||||
"برنامه «همانگونه که هست» ارائه میشود و تضمینی نسبت به بینقص بودن، دردسترس بودنِ دائمی یا صحتِ کاملِ اطلاعات داده نمیشود.",
|
||||
"مالکِ برنامه در قبالِ خساراتِ مستقیم یا غیرمستقیمِ ناشی از استفاده یا اتکا به اطلاعاتِ برنامه مسئولیتی نمیپذیرد."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۹. تعلیق، تغییرات و قانونِ حاکم",
|
||||
items = listOf(
|
||||
"در صورتِ نقضِ این قوانین، دسترسیِ کاربر ممکن است محدود یا مسدود شود.",
|
||||
"این شرایط ممکن است بهروزرسانی شود و ادامهٔ استفاده بهمنزلهٔ پذیرشِ تغییرات است.",
|
||||
"این شرایط تابعِ قوانینِ جمهوریِ اسلامیِ ایران است."
|
||||
)
|
||||
),
|
||||
LegalSection(
|
||||
title = "۱۰. ارتباط با ما",
|
||||
items = listOf(
|
||||
"در صورتِ هرگونه پرسش یا مشکل از طریقِ ایمیلِ $SUPPORT_EMAIL با ما در تماس باشید."
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.approagency.pharmacy.presentation.legal
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* کنترلر سراسریِ نمایشِ صفحهٔ «شرایط و قوانین».
|
||||
* هم کشوی کناری و هم شیتِ ورود میتوانند آن را با [show] باز کنند.
|
||||
*/
|
||||
class TermsController {
|
||||
private val _visible = MutableStateFlow(false)
|
||||
val visible: StateFlow<Boolean> = _visible.asStateFlow()
|
||||
|
||||
fun show() { _visible.value = true }
|
||||
fun hide() { _visible.value = false }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.approagency.pharmacy.presentation.legal
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vada.caller.ui.theme.dime
|
||||
|
||||
/** صفحهٔ تمامصفحهٔ «شرایط و قوانین». */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TermsDialog(onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"شرایط و قوانین",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "بستن")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(MaterialTheme.dime.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
|
||||
) {
|
||||
Text(
|
||||
text = "آخرین بهروزرسانی: ${AppLegal.LAST_UPDATED}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = AppLegal.INTRO,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
AppLegal.sections.forEach { section ->
|
||||
Spacer(Modifier.height(MaterialTheme.dime.xs))
|
||||
Text(
|
||||
text = section.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
section.items.forEach { item ->
|
||||
Text(
|
||||
text = "• $item",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,19 @@
|
||||
package com.approagency.pharmacy.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.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
|
||||
@@ -38,24 +26,22 @@ 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.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.approagency.pharmacy.domain.model.DrugDetail
|
||||
import com.approagency.pharmacy.presentation.common.ErrorState
|
||||
import com.approagency.pharmacy.presentation.common.Loading
|
||||
import com.approagency.pharmacy.presentation.common.SegmentedTabBar
|
||||
import com.approagency.pharmacy.presentation.components.drugdetail.BrandNamesTabContent
|
||||
import com.approagency.pharmacy.presentation.components.drugdetail.DosageFormsTabContent
|
||||
import com.approagency.pharmacy.presentation.components.drugdetail.GeneralInfoTabContent
|
||||
import com.approagency.pharmacy.presentation.components.drugdetail.ManufacturerInfoCard
|
||||
import com.approagency.pharmacy.presentation.components.drugdetail.SpecializedInfoTabContent
|
||||
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
|
||||
import com.approagency.pharmacy.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)
|
||||
@@ -140,7 +126,7 @@ fun DrugDetailScreen(
|
||||
val drugDetail = state.drugDetail
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Manufacturer Info Card (برای صفحات برند)
|
||||
// کارت سازنده (برای صفحات برند)
|
||||
if (!drugDetail.isGeneric && drugDetail.manufacturer != null) {
|
||||
ManufacturerInfoCard(
|
||||
manufacturer = drugDetail.manufacturer!!,
|
||||
@@ -152,29 +138,16 @@ fun DrugDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1
|
||||
SegmentedTabBar(
|
||||
tabs = tabs,
|
||||
selectedIndex = selectedTab,
|
||||
onSelected = { selectedTab = it },
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dime.md,
|
||||
vertical = dime.sm
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tab Content
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -183,21 +156,10 @@ fun DrugDetailScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(dime.md)
|
||||
) {
|
||||
when (selectedTab) {
|
||||
0 -> {
|
||||
item { GeneralInfoTabContent(drugDetail) }
|
||||
}
|
||||
|
||||
1 -> {
|
||||
item { SpecializedInfoTabContent(drugDetail) }
|
||||
}
|
||||
|
||||
2 -> {
|
||||
item { DosageFormsTabContent(drugDetail) }
|
||||
}
|
||||
|
||||
3 -> {
|
||||
item { BrandNamesTabContent(drugDetail) }
|
||||
}
|
||||
0 -> item { GeneralInfoTabContent(drugDetail) }
|
||||
1 -> item { SpecializedInfoTabContent(drugDetail) }
|
||||
2 -> item { DosageFormsTabContent(drugDetail) }
|
||||
3 -> item { BrandNamesTabContent(drugDetail) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,505 +181,3 @@ fun DrugDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ManufacturerInfoCard(
|
||||
manufacturer: String,
|
||||
genericInfo: com.approagency.pharmacy.domain.model.GenericInfo?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
val onContainer = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.padding(dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = onContainer
|
||||
)
|
||||
Text(
|
||||
text = "سازنده:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = onContainer
|
||||
)
|
||||
Text(
|
||||
text = manufacturer,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = onContainer
|
||||
)
|
||||
}
|
||||
|
||||
if (genericInfo != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = onContainer
|
||||
)
|
||||
Text(
|
||||
text = "ماده موثره:",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = onContainer
|
||||
)
|
||||
Text(
|
||||
text = genericInfo.persianName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = onContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// آپدیت 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) {
|
||||
DetailCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(dime.xs)) {
|
||||
drugDetail.drugClass?.let {
|
||||
LabeledRow(label = "طبقه بندی مارتیندل", value = it)
|
||||
}
|
||||
drugDetail.therapeuticClass?.let {
|
||||
LabeledRow(label = "طبقه درمانی", value = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pregnancy Card
|
||||
if (drugDetail.pregnancyCategory != null || drugDetail.pregnancyDescription != null) {
|
||||
val onContainer = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.tertiaryContainer)
|
||||
.padding(dime.md),
|
||||
verticalArrangement = Arrangement.spacedBy(dime.xs)
|
||||
) {
|
||||
Text(
|
||||
text = "مصرف در بارداری",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = onContainer
|
||||
)
|
||||
drugDetail.pregnancyCategory?.let {
|
||||
Text(
|
||||
text = "گروه: $it",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = onContainer
|
||||
)
|
||||
}
|
||||
drugDetail.pregnancyDescription?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
lineHeight = 18.sp,
|
||||
color = onContainer.copy(alpha = 0.9f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// موارد مصرف
|
||||
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()
|
||||
) {
|
||||
DetailEmptyState(text = "اطلاعات عمومی برای این دارو ثبت نشده است")
|
||||
}
|
||||
} 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
|
||||
) {
|
||||
DetailEmptyState(
|
||||
text = "اطلاعات عمومی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
) {
|
||||
DetailEmptyState(
|
||||
text = if (drugDetail.isGeneric)
|
||||
"اطلاعات تخصصی برای این دارو ثبت نشده است"
|
||||
else
|
||||
"اطلاعات تخصصی برای این برند ثبت نشده است.\nبرای مشاهده اطلاعات کامل، به صفحه داروی ژنریک مراجعه کنید."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DosageFormsTabContent(drugDetail: DrugDetail) {
|
||||
val dime = LocalDime.current
|
||||
val dosageForms = drugDetail.dosageForms
|
||||
|
||||
if (dosageForms.isEmpty()) {
|
||||
DetailEmptyState(
|
||||
text = if (drugDetail.isGeneric)
|
||||
"اشکال دارویی برای این دارو ثبت نشده است"
|
||||
else
|
||||
"سایر اشکال دارویی این برند ثبت نشده است"
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
|
||||
// اگر صفحه برند است، عنوان متفاوت نشان بده
|
||||
if (!drugDetail.isGeneric) {
|
||||
SectionHeader(
|
||||
title = "سایر محصولات این برند",
|
||||
modifier = Modifier.padding(bottom = dime.xs)
|
||||
)
|
||||
}
|
||||
|
||||
dosageForms.forEach { form ->
|
||||
DetailCard {
|
||||
Text(
|
||||
text = form.persianName,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
if (form.englishName.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(dime.xxs))
|
||||
Text(
|
||||
text = form.englishName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// نشانگرهای خطر
|
||||
if (form.isHighRisk || form.isVital) {
|
||||
Spacer(modifier = Modifier.height(dime.sm))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(dime.xs)) {
|
||||
if (form.isHighRisk) {
|
||||
TagChip(
|
||||
text = "پرخطر",
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
if (form.isVital) {
|
||||
TagChip(
|
||||
text = "حیاتی",
|
||||
container = MaterialTheme.colorScheme.primaryContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BrandNamesTabContent(drugDetail: DrugDetail) {
|
||||
val dime = LocalDime.current
|
||||
|
||||
if (drugDetail.isGeneric && drugDetail.brandNames.isEmpty()) {
|
||||
DetailEmptyState(text = "اسامی تجاری برای این دارو ثبت نشده است")
|
||||
} else if (drugDetail.isGeneric && drugDetail.brandNames.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(dime.sm)) {
|
||||
drugDetail.brandNames.forEach { brand ->
|
||||
DetailCard {
|
||||
Text(
|
||||
text = brand.persianName,
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
brand.manufacturer?.let {
|
||||
Spacer(modifier = Modifier.height(dime.xs))
|
||||
LabeledRow(label = "تولید کننده", value = it)
|
||||
}
|
||||
brand.importer?.let {
|
||||
Spacer(modifier = Modifier.height(dime.xxs))
|
||||
LabeledRow(label = "وارد کننده", value = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// صفحه برند است - اسامی تجاری برای برند معنی ندارد
|
||||
DetailEmptyState(
|
||||
text = "این صفحه مربوط به یک محصول تجاری است. برای مشاهده اسامی تجاری سایر برندها، به صفحه داروی ژنریک مراجعه کنید."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoSectionCard(
|
||||
title: String,
|
||||
content: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
|
||||
DetailCard(modifier = modifier) {
|
||||
SectionHeader(title = title)
|
||||
Spacer(modifier = Modifier.height(dime.sm))
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
lineHeight = 22.sp,
|
||||
textAlign = TextAlign.Justify,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* کارت پایهی صفحهی جزئیات — ظاهر یکدست با بقیهی برنامه:
|
||||
* سطح روشن + قاب نازک همرنگ تم، بدون سایهی سنگین.
|
||||
*/
|
||||
@Composable
|
||||
private fun DetailCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val shape = MaterialTheme.shapes.large
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = shape
|
||||
)
|
||||
.padding(MaterialTheme.dime.md),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/** سربرگ بخش با نوار تأکید کوچک کنار عنوان. */
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 4.dp, height = 18.dp)
|
||||
.clip(RoundedCornerShape(dime.xxs))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** حالت «اطلاعاتی ثبت نشده» با آیکن، یکدست در همهی تبها. */
|
||||
@Composable
|
||||
private fun DetailEmptyState(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = dime.xxl, horizontal = dime.lg),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(dime.sm)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** ردیف «برچسب: مقدار» با تایپوگرافی یکدست. */
|
||||
@Composable
|
||||
private fun LabeledRow(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(modifier = modifier) {
|
||||
Text(
|
||||
text = "$label: ",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** برچسب کوچک رنگی (مثل «پرخطر» / «حیاتی»). */
|
||||
@Composable
|
||||
private fun TagChip(
|
||||
text: String,
|
||||
container: Color,
|
||||
onContainer: Color
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = onContainer,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(MaterialTheme.dime.xs))
|
||||
.background(container)
|
||||
.padding(horizontal = MaterialTheme.dime.sm, vertical = MaterialTheme.dime.xxs)
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.approagency.pharmacy.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -22,9 +24,12 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.navigation.NavController
|
||||
import com.approagency.pharmacy.data.local.SessionManager
|
||||
import com.approagency.pharmacy.domain.model.DrugSearchResult
|
||||
import com.approagency.pharmacy.navigation.Screen
|
||||
import com.approagency.pharmacy.presentation.account.AccountSheetController
|
||||
import com.approagency.pharmacy.presentation.common.CustomTextFilled
|
||||
import com.approagency.pharmacy.presentation.common.EmptySearchState
|
||||
import com.approagency.pharmacy.presentation.common.EndOfListIndicator
|
||||
@@ -37,11 +42,12 @@ import com.approagency.pharmacy.presentation.components.DrugDetailBottomSheet
|
||||
import com.approagency.pharmacy.presentation.components.PharmacyBottomSheet
|
||||
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
|
||||
import com.approagency.pharmacy.presentation.viewModel.PharmacyViewModel
|
||||
import com.approgency.drug.presentation.viewModel.SearchState
|
||||
import com.approgency.drug.presentation.viewModel.SearchViewModel
|
||||
import com.approagency.pharmacy.presentation.viewModel.SearchState
|
||||
import com.approagency.pharmacy.presentation.viewModel.SearchViewModel
|
||||
import com.vada.caller.ui.theme.LocalDime
|
||||
import com.vada.caller.ui.theme.dime
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -54,6 +60,10 @@ fun SearchScreen(
|
||||
) {
|
||||
val dime = LocalDime.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val sheetController: AccountSheetController = koinInject()
|
||||
val session: SessionManager = koinInject()
|
||||
val account by session.account.collectAsState()
|
||||
val remainingFree by viewModel.remainingFreeSearches.collectAsState()
|
||||
val searchText = viewModel.searchText
|
||||
val state by viewModel.searchState.collectAsState()
|
||||
val lazyListState = rememberLazyListState()
|
||||
@@ -125,8 +135,28 @@ fun SearchScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// شمارش جستجوهای رایگانِ باقیمانده برای کاربرانِ بدون اشتراک
|
||||
if (!account.isSubscribed) {
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
|
||||
Text(
|
||||
text = "جستجوی رایگان باقیمانده: $remainingFree",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
|
||||
|
||||
// PrimaryButton(
|
||||
// onClick = {
|
||||
// session.testForIncrease()
|
||||
// } ,
|
||||
// text = "",
|
||||
// isLoading = false
|
||||
// )
|
||||
|
||||
if (showPharmacyBottomSheet && selectedDrugForPharmacy != null) {
|
||||
// درخواست بر اساس نوع صفحه فرق میکند:
|
||||
// - صفحات برند (/B-...): شناسه باید بهعنوان brandIrc ارسال شود
|
||||
@@ -228,9 +258,55 @@ fun SearchScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SearchState.RequireLogin -> {
|
||||
SearchGatePrompt(
|
||||
message = "سهمیهی جستجوی رایگان شما به پایان رسیده است. برای ادامه وارد شوید.",
|
||||
buttonText = "ورود",
|
||||
onClick = { sheetController.show() }
|
||||
)
|
||||
}
|
||||
|
||||
SearchState.RequireSubscription -> {
|
||||
SearchGatePrompt(
|
||||
message = "برای جستجوی نامحدودِ دارو، اشتراک ویژه تهیه کنید.",
|
||||
buttonText = "تهیه اشتراک",
|
||||
onClick = { sheetController.show() }
|
||||
)
|
||||
}
|
||||
|
||||
SearchState.Idle -> {
|
||||
EmptySearchState(onRetry = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchGatePrompt(
|
||||
message: String,
|
||||
buttonText: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(MaterialTheme.dime.lg),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(MaterialTheme.dime.lg))
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
PrimaryButton(
|
||||
text = buttonText,
|
||||
isLoading = false,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.approagency.pharmacy.presentation.viewModel
|
||||
|
||||
import android.app.Activity
|
||||
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.pharmacy.data.local.AccountState
|
||||
import com.approagency.pharmacy.data.local.SessionManager
|
||||
import com.approagency.pharmacy.domain.billing.PurchaseGateway
|
||||
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
||||
import com.approagency.pharmacy.domain.repository.AuthRepository
|
||||
import com.approagency.pharmacy.utils.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* مغزِ جریانِ حساب: ورود با کد یکبارمصرف، نمایش/خرید محصولات و بهروزرسانی وضعیت.
|
||||
*
|
||||
* منبعِ حقیقتِ وضعیت حساب [SessionManager.account] است؛ این ViewModel فقط آن را
|
||||
* میخواند و با فراخوانی سرور بهروزرسانی میکند تا کل اپ واکنشی بماند.
|
||||
*/
|
||||
class AccountViewModel(
|
||||
private val repository: AuthRepository,
|
||||
private val gateway: PurchaseGateway,
|
||||
private val session: SessionManager
|
||||
) : ViewModel() {
|
||||
|
||||
val account: StateFlow<AccountState> = session.account
|
||||
|
||||
val gatewayAvailable: Boolean get() = gateway.isAvailable
|
||||
|
||||
var mobile by mutableStateOf(session.account.value.mobile.orEmpty())
|
||||
private set
|
||||
var otp by mutableStateOf("")
|
||||
private set
|
||||
|
||||
private val _ui = MutableStateFlow(AccountUiState())
|
||||
val ui: StateFlow<AccountUiState> = _ui.asStateFlow()
|
||||
|
||||
fun updateMobile(value: String) {
|
||||
mobile = value.filter { it.isDigit() }.take(11)
|
||||
}
|
||||
|
||||
fun updateOtp(value: String) {
|
||||
otp = value.filter { it.isDigit() }.take(OTP_LENGTH)
|
||||
}
|
||||
|
||||
/** هنگام باز شدن شیت: فاز مناسب را بر اساس وضعیت حساب تعیین کن. */
|
||||
fun onSheetOpened() {
|
||||
val state = account.value
|
||||
when {
|
||||
!state.isLoggedIn -> _ui.update { it.copy(phase = AccountPhase.EnterMobile, error = null) }
|
||||
!state.isSubscribed -> {
|
||||
_ui.update { it.copy(phase = AccountPhase.Products, error = null) }
|
||||
loadProducts()
|
||||
}
|
||||
else -> _ui.update { it.copy(phase = AccountPhase.Subscribed, error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendOtp() {
|
||||
val normalized = mobile.trim()
|
||||
if (normalized.length != 11 || !normalized.startsWith("09")) {
|
||||
_ui.update { it.copy(error = "شماره موبایل معتبر نیست.") }
|
||||
return
|
||||
}
|
||||
_ui.update { it.copy(busy = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
repository.loginOtp(normalized).fold(
|
||||
onSuccess = { _ui.update { it.copy(busy = false, phase = AccountPhase.EnterOtp) } },
|
||||
onFailure = { e -> _ui.update { it.copy(busy = false, error = e.toUserMessage()) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyOtp() {
|
||||
val code = otp.trim()
|
||||
if (code.isBlank()) {
|
||||
_ui.update { it.copy(error = "کد را وارد کنید.") }
|
||||
return
|
||||
}
|
||||
_ui.update { it.copy(busy = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
repository.checkOtp(mobile.trim(), code).fold(
|
||||
onSuccess = {
|
||||
otp = ""
|
||||
// پس از ورود، وضعیت حساب از سرور آمده است؛ فاز مناسب را تعیین کن.
|
||||
if (account.value.isSubscribed) {
|
||||
_ui.update { it.copy(busy = false, phase = AccountPhase.Subscribed) }
|
||||
} else {
|
||||
_ui.update { it.copy(busy = false, phase = AccountPhase.Products) }
|
||||
loadProducts()
|
||||
}
|
||||
},
|
||||
onFailure = { e -> _ui.update { it.copy(busy = false, error = e.toUserMessage()) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun editMobile() {
|
||||
otp = ""
|
||||
_ui.update { it.copy(phase = AccountPhase.EnterMobile, error = null) }
|
||||
}
|
||||
|
||||
fun loadProducts() {
|
||||
_ui.update { it.copy(productsLoading = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
repository.getProducts().fold(
|
||||
onSuccess = { list -> _ui.update { it.copy(productsLoading = false, products = list) } },
|
||||
onFailure = { e -> _ui.update { it.copy(productsLoading = false, error = e.toUserMessage()) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** خرید [product]: ابتدا درگاه فروشگاه، سپس ثبت روی سرور آپرواجنسی. */
|
||||
fun purchase(activity: Activity, product: SubscriptionProduct) {
|
||||
if (!gateway.isAvailable) {
|
||||
_ui.update { it.copy(error = "درگاه پرداخت روی این نسخه فعال نیست.") }
|
||||
return
|
||||
}
|
||||
_ui.update { it.copy(purchasingProductId = product.id, error = null) }
|
||||
viewModelScope.launch {
|
||||
val purchase = gateway.purchase(activity, product)
|
||||
val result = purchase.getOrNull()
|
||||
if (result == null) {
|
||||
_ui.update {
|
||||
it.copy(purchasingProductId = null, error = purchase.exceptionOrNull().toUserMessage())
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
repository.subscribe(product.id, result.purchaseToken, gateway.name).fold(
|
||||
onSuccess = {
|
||||
// پس از تأییدِ سرور، خرید را مصرف کن تا دورهٔ بعد قابل خرید باشد.
|
||||
gateway.consume(result.purchaseToken)
|
||||
_ui.update {
|
||||
it.copy(
|
||||
purchasingProductId = null,
|
||||
phase = if (account.value.isSubscribed) AccountPhase.Subscribed else it.phase,
|
||||
purchaseSuccess = true
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_ui.update { it.copy(purchasingProductId = null, error = e.toUserMessage()) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** بهروزرسانی وضعیت اشتراک از سرور (شروع اپ / بازگشت از درگاه). */
|
||||
fun refreshStatus() {
|
||||
if (!session.isLoggedIn) return
|
||||
viewModelScope.launch { repository.refreshStatus() }
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
repository.logout()
|
||||
_ui.update { it.copy(phase = AccountPhase.EnterMobile, products = emptyList()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePurchaseSuccess() {
|
||||
_ui.update { it.copy(purchaseSuccess = false) }
|
||||
}
|
||||
|
||||
fun dismissError() {
|
||||
_ui.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** طول کد یکبارمصرفِ سامانهی آپرواجنسی. */
|
||||
const val OTP_LENGTH = 5
|
||||
}
|
||||
}
|
||||
|
||||
enum class AccountPhase { EnterMobile, EnterOtp, Products, Subscribed }
|
||||
|
||||
data class AccountUiState(
|
||||
val phase: AccountPhase = AccountPhase.EnterMobile,
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
val productsLoading: Boolean = false,
|
||||
val products: List<SubscriptionProduct> = emptyList(),
|
||||
val purchasingProductId: Int? = null,
|
||||
val purchaseSuccess: Boolean = false
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.approagency.pharmacy.domain.model.DrugDetail
|
||||
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
|
||||
import com.approagency.pharmacy.utils.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -22,7 +23,7 @@ class DrugDetailViewModel(
|
||||
val result = drugDetailUseCase(detailUrl)
|
||||
_detailState.value = when {
|
||||
result.isSuccess -> DrugDetailYabState.Success(result.getOrNull()!!)
|
||||
else -> DrugDetailYabState.Error(result.exceptionOrNull()?.message ?: "خطا در دریافت اطلاعات")
|
||||
else -> DrugDetailYabState.Error(result.exceptionOrNull().toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.approagency.pharmacy.domain.model.DrugSearchParams
|
||||
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.utils.ErrorMessages
|
||||
import com.approagency.pharmacy.utils.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -49,9 +51,9 @@ class HomeViewModel (
|
||||
)
|
||||
}
|
||||
}catch (e: Exception) {
|
||||
handleError(e.message)
|
||||
handleError(e.toUserMessage())
|
||||
}catch (e: Exception) {
|
||||
handleError(e.localizedMessage ?: "Unknown error")
|
||||
handleError(e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +65,6 @@ class HomeViewModel (
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = getDrugSearchUseCase.invoke(drugSearchParams)
|
||||
println(result)
|
||||
_uiState.update { it.copy(
|
||||
drugSearchState = it.drugSearchState.copy(
|
||||
isLoading = false,
|
||||
@@ -72,11 +73,9 @@ class HomeViewModel (
|
||||
|
||||
) }
|
||||
} catch (e: Exception) {
|
||||
println(e.message)
|
||||
handleError(e.message)
|
||||
handleError(e.toUserMessage())
|
||||
}catch (e: Exception) {
|
||||
println(e.message)
|
||||
handleError(e.localizedMessage ?: "Unknown error")
|
||||
handleError(e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,11 +100,10 @@ class HomeViewModel (
|
||||
)
|
||||
)
|
||||
}
|
||||
println(result)
|
||||
} catch (e: Exception) {
|
||||
handleError(e.message)
|
||||
handleError(e.toUserMessage())
|
||||
}catch (e: Exception) {
|
||||
handleError(e.localizedMessage ?: "Unknown error")
|
||||
handleError(e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,7 +124,7 @@ class HomeViewModel (
|
||||
) , drugDetailState = it.drugDetailState.copy(
|
||||
isLoading = false
|
||||
)) }
|
||||
_event.emit(HomeEvent.ShowError(message ?: "Error occurred"))
|
||||
_event.emit(HomeEvent.ShowError(message ?: ErrorMessages.GENERIC))
|
||||
}
|
||||
|
||||
// Clear error when consumed
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.approagency.pharmacy.domain.model.TestItem
|
||||
import com.approagency.pharmacy.domain.usecase.GetTestGroupUseCase
|
||||
import com.approagency.pharmacy.domain.usecase.GetTestItemByGroupId
|
||||
import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase
|
||||
import com.approagency.pharmacy.utils.toUserMessage
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -87,7 +88,7 @@ class LabViewModel (
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
searchResults = null,
|
||||
error = e.message ?: "خطا در جستجو"
|
||||
error = e.toUserMessage()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +110,7 @@ class LabViewModel (
|
||||
)
|
||||
}
|
||||
} catch (e:HttpException){
|
||||
handleError(e.message)
|
||||
handleError(e.toUserMessage())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -132,7 +133,7 @@ class LabViewModel (
|
||||
)
|
||||
}
|
||||
} catch (e:HttpException){
|
||||
handleError(e.message)
|
||||
handleError(e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.approagency.pharmacy.domain.model.PharmacyDetail
|
||||
import com.approagency.pharmacy.domain.model.PharmacyItem
|
||||
import com.approagency.pharmacy.domain.usecase.GetPharmaciesUseCase
|
||||
import com.approagency.pharmacy.domain.usecase.GetPharmacyDetailUseCase
|
||||
import com.approagency.pharmacy.utils.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -61,7 +62,7 @@ class PharmacyViewModel(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_pharmacyDetailStates.value = _pharmacyDetailStates.value.toMutableMap().apply {
|
||||
put(pharmacyUrl, PharmacyDetailState.Error(e.message ?: "خطا در دریافت اطلاعات"))
|
||||
put(pharmacyUrl, PharmacyDetailState.Error(e.toUserMessage()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +91,7 @@ class PharmacyViewModel(
|
||||
val result = getPharmaciesUseCase(genericDrugId, brandIrc, provinceId)
|
||||
_state.value = PharmacyState.Success(result)
|
||||
} catch (e: Exception) {
|
||||
_state.value = PharmacyState.Error(e.message ?: "خطا در دریافت اطلاعات")
|
||||
_state.value = PharmacyState.Error(e.toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
package com.approgency.drug.presentation.viewModel
|
||||
package com.approagency.pharmacy.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.pharmacy.data.local.SessionManager
|
||||
import com.approagency.pharmacy.domain.model.DrugSearchResult
|
||||
import com.approagency.pharmacy.domain.repository.AuthRepository
|
||||
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
|
||||
import com.approagency.pharmacy.utils.Config
|
||||
import com.approagency.pharmacy.utils.toUserMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SearchViewModel(
|
||||
private val searchDrugsUseCase: SearchDrugsYabUseCase
|
||||
private val searchDrugsUseCase: SearchDrugsYabUseCase,
|
||||
private val session: SessionManager,
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchState = MutableStateFlow<SearchState>(SearchState.Idle)
|
||||
val searchState: StateFlow<SearchState> = _searchState.asStateFlow()
|
||||
|
||||
/** تعداد جستجوی رایگانِ باقیمانده برای نمایش به کاربر. */
|
||||
val remainingFreeSearches: StateFlow<Int> = session.freeSearchCount
|
||||
.map { remaining(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = remaining(session.freeSearchCount.value)
|
||||
)
|
||||
|
||||
var searchText by mutableStateOf("")
|
||||
private set
|
||||
|
||||
fun updateSearchText(value: String) {
|
||||
searchText = value
|
||||
}
|
||||
|
||||
private var currentQuery = ""
|
||||
private var currentPage = 1
|
||||
private var totalPages = 1
|
||||
@@ -37,25 +57,32 @@ class SearchViewModel(
|
||||
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 (!isNewSearch) {
|
||||
// صفحهبندیِ همان جستجو نیازی به بررسی مجدد سهمیه ندارد.
|
||||
if (isLoadingMore || !hasMorePages) return
|
||||
isLoadingMore = true
|
||||
_searchState.value = SearchState.LoadingMore(
|
||||
currentItems = allDrugs.toList(),
|
||||
currentPage = currentPage
|
||||
)
|
||||
viewModelScope.launch { fetchPage(isNewSearch = false) }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
if (!ensureCanSearch()) return@launch
|
||||
|
||||
currentQuery = query
|
||||
currentPage = 1
|
||||
allDrugs.clear()
|
||||
isLoadingMore = false
|
||||
hasMorePages = true
|
||||
_searchState.value = SearchState.Loading(isNew = true)
|
||||
fetchPage(isNewSearch = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchPage(isNewSearch: Boolean) {
|
||||
val result = searchDrugsUseCase(currentQuery, currentPage)
|
||||
|
||||
_searchState.value = when {
|
||||
@@ -67,6 +94,12 @@ class SearchViewModel(
|
||||
allDrugs.addAll(searchResult.drugs)
|
||||
isLoadingMore = false
|
||||
|
||||
// فقط وقتی جستجوی جدید نتیجه داشته باشد از سهمیهی رایگان کم میکند؛
|
||||
// اگر دارویی یافت نشود سهمیه دستنخورده میماند.
|
||||
if (isNewSearch && !session.isSubscribed && searchResult.drugs.isNotEmpty()) {
|
||||
session.incrementFreeSearchCount()
|
||||
}
|
||||
|
||||
SearchState.Success(
|
||||
drugs = allDrugs.toList(),
|
||||
currentPage = searchResult.currentPage,
|
||||
@@ -77,10 +110,28 @@ class SearchViewModel(
|
||||
}
|
||||
else -> {
|
||||
isLoadingMore = false
|
||||
SearchState.Error(result.exceptionOrNull()?.message ?: "خطا در جستجو")
|
||||
SearchState.Error(result.exceptionOrNull().toUserMessage())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* سهمیهی جستجو را بررسی میکند. در صورت اتمام سهمیهی رایگان، وضعیت مناسب
|
||||
* (نیاز به ورود / نیاز به اشتراک) منتشر شده و false برمیگردد.
|
||||
*/
|
||||
private suspend fun ensureCanSearch(): Boolean {
|
||||
if (session.isSubscribed) return true
|
||||
if (session.freeSearchCount.value < Config.FREE_SEARCH_LIMIT) return true
|
||||
|
||||
if (!session.isLoggedIn) {
|
||||
_searchState.value = SearchState.RequireLogin
|
||||
return false
|
||||
}
|
||||
// کاربر واردشده ولی کش اشتراک ندارد → بررسی مجدد با سرور.
|
||||
if (authRepository.refreshStatus().getOrDefault(false)) return true
|
||||
|
||||
_searchState.value = SearchState.RequireSubscription
|
||||
return false
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
@@ -105,15 +156,26 @@ class SearchViewModel(
|
||||
searchDrugs(currentQuery, isNewSearch = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun remaining(count: Int): Int =
|
||||
(Config.FREE_SEARCH_LIMIT - count).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
sealed class SearchState {
|
||||
object Idle : SearchState()
|
||||
|
||||
/** سهمیهی رایگان تمام شده و کاربر باید وارد شود. */
|
||||
object RequireLogin : SearchState()
|
||||
|
||||
/** کاربر واردشده ولی اشتراک فعال ندارد. */
|
||||
object RequireSubscription : 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,
|
||||
@@ -121,5 +183,6 @@ sealed class SearchState {
|
||||
val isLoadingMore: Boolean = false,
|
||||
val hasMorePages: Boolean = true
|
||||
) : SearchState()
|
||||
|
||||
data class Error(val message: String) : SearchState()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.approagency.pharmacy.domain.model.ThemeMode
|
||||
import com.fabirt.podcastapp.ui.theme.Shapes
|
||||
import com.vada.caller.ui.theme.Dime
|
||||
import com.vada.caller.ui.theme.LocalDime
|
||||
@@ -108,11 +109,16 @@ private val DarkColorScheme = darkColorScheme(
|
||||
|
||||
@Composable
|
||||
fun DrugTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val darkTheme = when (themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.DARK -> true
|
||||
ThemeMode.LIGHT -> false
|
||||
}
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package com.approagency.pharmacy.utils
|
||||
|
||||
object Config {
|
||||
const val BASE_URL = "https://drug.approagency.ir/api/"
|
||||
const val Darro_Url = "https://www.darooyab.ir/"
|
||||
|
||||
/** بکاند اشتراک/احراز هویت آپرواجنسی (لاگین، وضعیت، محصولات، خرید). */
|
||||
const val AUTH_BASE_URL = "https://api.approagency.ir/api/"
|
||||
|
||||
/** نام پکیج این اپ در سامانهی آپرواجنسی (برای status / products / subscribe). */
|
||||
const val PACKAGE_NAME = "com.approagency.pharmacy"
|
||||
|
||||
/** تعداد جستجوی رایگان دارویاب پیش از نیاز به ورود و اشتراک. */
|
||||
const val FREE_SEARCH_LIMIT = 2
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.approagency.pharmacy.utils
|
||||
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
/** پیامهای خطای فارسیِ امن — بدون افشای منبع داده یا متن خام استثناء. */
|
||||
object ErrorMessages {
|
||||
const val NETWORK = "اتصال به اینترنت برقرار نیست. لطفاً اتصال خود را بررسی کرده و دوباره تلاش کنید."
|
||||
const val TIMEOUT = "زمان پاسخگویی به پایان رسید. لطفاً دوباره تلاش کنید."
|
||||
const val SERVER = "در حال حاضر امکان دریافت اطلاعات وجود ندارد. لطفاً کمی بعد دوباره تلاش کنید."
|
||||
const val NOT_FOUND = "موردی یافت نشد."
|
||||
const val GENERIC = "خطایی رخ داد. لطفاً دوباره تلاش کنید."
|
||||
}
|
||||
|
||||
/**
|
||||
* تبدیل استثناء به پیام فارسیِ قابلنمایش به کاربر.
|
||||
* هرگز متن خام استثناء، آدرس سرور یا نام منبع داده را برنمیگرداند.
|
||||
*/
|
||||
fun Throwable?.toUserMessage(): String = when (this) {
|
||||
null -> ErrorMessages.GENERIC
|
||||
is UnknownHostException -> ErrorMessages.NETWORK
|
||||
is SocketTimeoutException -> ErrorMessages.TIMEOUT
|
||||
is HttpException -> ErrorMessages.SERVER
|
||||
is IOException -> ErrorMessages.NETWORK
|
||||
else -> when (cause) {
|
||||
is UnknownHostException -> ErrorMessages.NETWORK
|
||||
is SocketTimeoutException -> ErrorMessages.TIMEOUT
|
||||
is HttpException -> ErrorMessages.SERVER
|
||||
is IOException -> ErrorMessages.NETWORK
|
||||
else -> ErrorMessages.GENERIC
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_bg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_image" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_bg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_image" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -7,4 +7,6 @@
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<!-- پسزمینهی آیکن تطبیقی (همرنگِ پسزمینهی تصویر آیکن) -->
|
||||
<color name="ic_launcher_bg">#016E70</color>
|
||||
</resources>
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">داروخانه همراه</string>
|
||||
<string name="app_name">داروچی</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,99 @@
|
||||
# شرایط و قوانین استفاده از اپلیکیشن داروچی
|
||||
|
||||
آخرین بهروزرسانی: ۱۴۰۵/۰۳/۲۴
|
||||
|
||||
استفاده از اپلیکیشن «داروچی» (که از این پس «برنامه» نامیده میشود) بهمنزلهٔ
|
||||
مطالعه و پذیرشِ کاملِ شرایط و قوانین زیر است. در صورتی که با هر بخشی از این
|
||||
قوانین موافق نیستید، لطفاً از برنامه استفاده نکنید.
|
||||
|
||||
## ۱. معرفی خدمات
|
||||
|
||||
برنامه ابزاری اطلاعرسانی است که امکانات زیر را در اختیار کاربر قرار میدهد:
|
||||
|
||||
- جستوجوی دارو و مشاهدهٔ اطلاعات دارویی (موارد مصرف، مقدار مصرف، عوارض،
|
||||
تداخلات، هشدارها و موارد مشابه).
|
||||
- مشاهدهٔ موجودی دارو در داروخانهها.
|
||||
- مشاهدهٔ اطلاعات آزمایشها و تستهای پزشکی.
|
||||
|
||||
این خدمات صرفاً جنبهٔ آموزشی و اطلاعرسانی دارند.
|
||||
|
||||
## ۲. سلب مسئولیت پزشکی (بسیار مهم)
|
||||
|
||||
- اطلاعاتِ ارائهشده در برنامه **جایگزینِ مشاورهٔ پزشک، داروساز یا کادر درمان
|
||||
نیست** و نباید مبنای تشخیص، تجویز یا قطع/شروع مصرف دارو قرار گیرد.
|
||||
- مصرفِ هر دارو باید **زیر نظر پزشک یا داروساز** و مطابق نسخه و دستور ایشان
|
||||
انجام شود.
|
||||
- اطلاعاتِ دارویی ممکن است کامل، بهروز یا بدون خطا نباشد. مسئولیتِ هرگونه
|
||||
استفاده از این اطلاعات بر عهدهٔ کاربر است.
|
||||
- موجودیِ نمایشدادهشدهٔ داروها در داروخانهها بر اساس دادههای منابعِ ثالث
|
||||
است و ممکن است با وضعیتِ واقعی متفاوت باشد؛ پیش از مراجعه، موجودی را از
|
||||
داروخانه تأیید کنید.
|
||||
- در شرایطِ اورژانسی یا بروزِ عوارض، فوراً به پزشک یا اورژانس مراجعه کنید.
|
||||
|
||||
## ۳. حساب کاربری و ورود
|
||||
|
||||
- ورود به برنامه از طریق شماره موبایل و کدِ یکبارمصرف (OTP) انجام میشود.
|
||||
- مسئولیتِ حفظِ شماره موبایل، دسترسی به دستگاه و کدِ تأیید بر عهدهٔ کاربر است.
|
||||
- کاربر موظف است اطلاعاتِ صحیح ارائه دهد و حساب را در اختیار دیگران قرار ندهد.
|
||||
|
||||
## ۴. جستوجوی رایگان، اشتراک و پرداخت
|
||||
|
||||
- برنامه تعدادی **جستوجوی رایگان** در اختیار کاربر قرار میدهد. پس از پایانِ
|
||||
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
|
||||
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درونبرنامهایِ فروشگاه
|
||||
(کافهبازار)** انجام میشود و تابعِ قوانین و شرایطِ همان فروشگاه است.
|
||||
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده میشود.
|
||||
- **بازگشتِ وجه** تابعِ سیاستهای فروشگاهِ کافهبازار است. درخواستهای مربوط به
|
||||
عودتِ وجهِ خریدهای درونبرنامهای از طریق همان فروشگاه پیگیری میشود.
|
||||
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.
|
||||
|
||||
## ۵. حریم خصوصی و دادهها
|
||||
|
||||
- برای ارائهٔ خدمات، حداقلِ اطلاعاتِ لازم (از جمله **شماره موبایل** و وضعیتِ
|
||||
اشتراک) دریافت و نگهداری میشود.
|
||||
- اطلاعاتِ خرید (توکنِ خرید) صرفاً برای فعالسازیِ اشتراک به سرور ارسال میشود.
|
||||
- اطلاعاتِ کاربران بدون رضایتِ ایشان در اختیارِ اشخاصِ ثالثِ غیرمرتبط قرار
|
||||
نمیگیرد، مگر در مواردی که قانون الزام کند.
|
||||
- برای آگاهیِ بیشتر، به سند **حریم خصوصی** برنامه مراجعه کنید.
|
||||
|
||||
## ۶. تعهدات کاربر
|
||||
|
||||
کاربر متعهد میشود که:
|
||||
|
||||
- از برنامه صرفاً برای اهدافِ قانونی و شخصی استفاده کند.
|
||||
- در عملکردِ برنامه اخلال ایجاد نکند و از روشهای نفوذ، مهندسیِ معکوس یا دورزدنِ
|
||||
محدودیتها استفاده نکند.
|
||||
- محتوای برنامه را بدون اجازه بازنشر یا تجاریسازی نکند.
|
||||
|
||||
## ۷. مالکیت فکری
|
||||
|
||||
تمامِ حقوقِ مربوط به برنامه، نام، نشان (لوگو)، رابط کاربری و محتوای آن متعلق به
|
||||
مالکِ برنامه است و هرگونه استفادهٔ غیرمجاز پیگردِ قانونی دارد.
|
||||
|
||||
## ۸. محدودیت مسئولیت
|
||||
|
||||
- برنامه «همانگونه که هست» ارائه میشود و هیچ تضمینی نسبت به بینقص بودن،
|
||||
دردسترس بودنِ دائمی یا صحتِ کاملِ اطلاعات داده نمیشود.
|
||||
- مالکِ برنامه در قبالِ خساراتِ مستقیم یا غیرمستقیمِ ناشی از استفاده یا عدمِ
|
||||
امکانِ استفاده از برنامه، یا اتکا به اطلاعاتِ آن، مسئولیتی نمیپذیرد.
|
||||
|
||||
## ۹. تعلیق و خاتمه
|
||||
|
||||
در صورتِ نقضِ این قوانین، دسترسیِ کاربر به برنامه ممکن است بدونِ اطلاعِ قبلی
|
||||
محدود یا مسدود شود.
|
||||
|
||||
## ۱۰. تغییر قوانین
|
||||
|
||||
این شرایط ممکن است در آینده بهروزرسانی شود. ادامهٔ استفاده از برنامه پس از
|
||||
انتشارِ نسخهٔ جدید، بهمنزلهٔ پذیرشِ تغییرات است.
|
||||
|
||||
## ۱۱. قانونِ حاکم
|
||||
|
||||
این شرایط تابعِ قوانینِ جمهوریِ اسلامیِ ایران است و اختلافاتِ احتمالی مطابقِ همین
|
||||
قوانین حلوفصل میشود.
|
||||
|
||||
## ۱۲. ارتباط با ما
|
||||
|
||||
در صورتِ هرگونه پرسش یا مشکل، از طریقِ راههای زیر با ما در تماس باشید:
|
||||
|
||||
- ایمیل پشتیبانی: approagency@gmail.com
|
||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
||||
gson = "2.13.1"
|
||||
coil = "2.7.0"
|
||||
location = "21.3.0"
|
||||
playServicesAuthApiPhone = "18.1.0"
|
||||
koinAndroid = "4.1.0"
|
||||
koinAndroidxCompose = "4.1.0"
|
||||
lifecycleViewmodelKtx = "2.9.4"
|
||||
@@ -51,6 +52,7 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
|
||||
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location" }
|
||||
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencyResolutionManagement {
|
||||
//google()
|
||||
//mavenCentral()
|
||||
maven { url = uri("https://maven.myket.ir") }
|
||||
maven { url = uri("https://jitpack.io") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||