Compare commits

12 Commits
Author SHA1 Message Date
AmirmahdiandClaude Opus 4.8 79dfcc6916 feat: in-app terms & conditions (Myket) + app name داروچی
- Add شرایط و قوانین page (full-screen dialog) reachable from drawer and login sheet
- TermsController + LegalContent + TermsDialog
- App bar title uses app_name (داروچی)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:57:27 +03:30
Amirmahdi f3b227f09a feat:vr2 2026-06-14 20:05:29 +03:30
Amirmahdi c76f8aa08f feat: add myket iap 2026-06-14 18:31:15 +03:30
Amirmahdi f266f4d142 feat: font scaler added 2026-06-14 15:59:48 +03:30
Amirmahdi 1971708b3f feat: remove code 2026-06-14 15:31:13 +03:30
Amirmahdi 27dc08fa68 feat: remove drug.approagncy api 2026-06-14 08:18:01 +03:30
Amirmahdi 1ed75fb823 feat: clean code 2026-06-12 23:39:17 +03:30
Amirmahdi 0a4b339481 feat: update free usage 2026-06-12 22:30:43 +03:30
Amirmahdi 5e7ce5bc6b feat: change ic launcher 2026-06-12 22:25:23 +03:30
Amirmahdi fd8eb0a373 feat: change drawer 2026-06-12 21:16:51 +03:30
Amirmahdi 675123309a feat: add otp reciver 2026-06-12 20:02:21 +03:30
Amirmahdi fdf15913a9 feat: theme manage and auth 2026-06-12 15:42:09 +03:30
78 changed files with 2038 additions and 395 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
<bytecodeTargetLevel target="17" />
</component>
</project>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+21 -2
View File
@@ -15,10 +15,22 @@ android {
applicationId = "com.approagency.pharmacy"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.0.2+MK"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// ---- Myket in-app billing ----
val marketApplicationId = "ir.mservices.market"
val marketBindAddress = "ir.mservices.market.InAppBillingService.BIND"
manifestPlaceholders["marketApplicationId"] = marketApplicationId
manifestPlaceholders["marketBindAddress"] = marketBindAddress
manifestPlaceholders["marketPermission"] = "$marketApplicationId.BILLING"
buildConfigField(
"String",
"IAB_PUBLIC_KEY",
"\"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCDSaqJLDPxQZBZD/LGRsZOJ8m1hLVFedg/tnqebQusikVTNYSEjmP86i1TtXu/2b4E8hpkrqyNlNVvEg1lrHCpnxCh0tOubYiG6X/GEQtgOtIRuBzO3HM/r9w1HQm4gZOJc6DJO46Sxp3YbryJujp+pG8p7bU6zHp4zcqydCJ14QIDAQAB\""
)
}
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)
+2 -2
View File
@@ -11,8 +11,8 @@
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"versionCode": 2,
"versionName": "1.0.2+MK",
"outputFile": "app-release.apk"
}
],
+6
View File
@@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- دیدن و اتصال به سرویس پرداختِ مایکت روی اندروید ۱۱+ -->
<queries>
<package android:name="ir.mservices.market" />
</queries>
<application
android:name=".DrugApp"
android:allowBackup="true"
Binary file not shown.
@@ -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,76 @@
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() {
class MainActivity : ComponentActivity(), OtpAutofillController {
private val session: SessionManager by inject()
private val authRepository: AuthRepository by inject()
private val otpAutoFillBus: OtpAutoFillBus by inject()
private var otpSmsReceiver: BroadcastReceiver? = null
// نتیجه‌ی دیالوگ رضایتِ خواندن پیامک: کد ۵ رقمی استخراج و به شیت ورود تحویل می‌شود.
private val smsConsentLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE)
val code = Regex("\\b\\d{5}\\b").find(message ?: "")?.value
code?.let { otpAutoFillBus.submit(it) }
}
}
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DrugTheme {
val themeMode by session.themeMode.collectAsState()
val fontScale by session.fontScale.collectAsState()
DrugTheme(themeMode = themeMode) {
// مقیاسِ متنِ انتخابیِ کاربر روی fontScaleِ سیستم اعمال می‌شود
// تا کلِ متن‌های اپ یک‌جا بزرگ/کوچک شوند.
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(
density = density.density,
fontScale = density.fontScale * fontScale.scale
)
) {
AppNavGraph()
}
}
}
}
/**
* هنگام شروع اپ و هر بار بازگشت به اپ (مثلاً پس از بازگشت از درگاه پرداختِ
@@ -39,4 +82,49 @@ class MainActivity : ComponentActivity() {
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,153 @@
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).
*
* جریان (مطابق همان مدلی که در اپ فلاترِ آپرواجنسی استفاده شده):
* 1. اتصال به سرویس مایکت ([ensureSetup]).
* 2. شروع خرید با SKU = [SubscriptionProduct.uuid] و دریافت توکنِ خرید.
* 3. توکن به سرور فرستاده می‌شود (در لایهٔ اشتراک) و پس از تأیید، خرید [consume]
* می‌شود تا کاربر بتواند دورهٔ بعد دوباره خرید کند.
*
* چون IabHelper در هر لحظه فقط یک عملیاتِ ناهمگام را می‌پذیرد، همهٔ فراخوانی‌ها
* با [mutex] ترتیبی می‌شوند.
*/
class MyketPurchaseGateway(
context: Context,
private val session: SessionManager
) : PurchaseGateway {
override val name: String = "myket"
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.launchPurchaseFlow(
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 -> recoverOwned(sku)
?: 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> = mutex.withLock {
val purchase = pending[purchaseToken] ?: return Result.success(Unit)
val helper = helper ?: return Result.success(Unit)
runCatching {
suspendCancellableCoroutine { cont ->
helper.consumeAsync(purchase, IabHelper.OnConsumeFinishedListener { _, result ->
if (result.isSuccess) cont.resume(Unit)
else cont.resumeWithException(IllegalStateException(result.message ?: "خطای مصرفِ خرید"))
})
}
}.onSuccess { pending.remove(purchaseToken) }
}
/** اتصال به سرویس مایکت (یک‌بار). */
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 suspend fun recoverOwned(sku: String): Purchase? {
val helper = helper ?: return null
return runCatching {
suspendCancellableCoroutine<Purchase?> { cont ->
helper.queryInventoryAsync(
false,
listOf(sku),
IabHelper.QueryInventoryFinishedListener { result, inventory ->
cont.resume(if (result.isSuccess) inventory.getPurchase(sku) else null)
}
)
}
}.getOrNull()
}
private sealed interface PurchaseOutcome {
data class Success(val purchase: Purchase) : PurchaseOutcome
data object AlreadyOwned : PurchaseOutcome
data class Error(val message: String) : PurchaseOutcome
}
}
@@ -22,4 +22,6 @@ class StubPurchaseGateway : PurchaseGateway {
product: SubscriptionProduct
): Result<PurchaseResult> =
Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست."))
override suspend fun consume(purchaseToken: String): Result<Unit> = Result.success(Unit)
}
@@ -31,7 +31,21 @@ data class ProductDto(
@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("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
)
/**
@@ -55,6 +69,9 @@ data class StatusDto(
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
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() }
}
@@ -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
"""
}
}
@@ -2,6 +2,8 @@ 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
@@ -12,7 +14,8 @@ data class AccountState(
val mobile: String? = null,
val displayName: String? = null,
val isSubscribed: Boolean = false,
val subscriptionTitle: String? = null
val subscriptionTitle: String? = null,
val subscriptionExpireAt: String? = null
)
/**
@@ -75,13 +78,15 @@ class SessionManager(context: Context) {
mobile: String?,
displayName: String?,
isSubscribed: Boolean,
subscriptionTitle: String?
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()
}
@@ -101,6 +106,7 @@ class SessionManager(context: Context) {
.remove(KEY_DISPLAY_NAME)
.remove(KEY_IS_SUBSCRIBED)
.remove(KEY_SUB_TITLE)
.remove(KEY_SUB_EXPIRE)
.apply()
publishAccount()
}
@@ -109,12 +115,41 @@ class SessionManager(context: Context) {
_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)
subscriptionTitle = prefs.getString(KEY_SUB_TITLE, null),
subscriptionExpireAt = prefs.getString(KEY_SUB_EXPIRE, null)
)
private companion object {
@@ -125,5 +160,8 @@ class SessionManager(context: Context) {
const val KEY_FREE_SEARCH_COUNT = "free_search_count"
const val KEY_IS_SUBSCRIBED = "is_subscribed"
const val KEY_SUB_TITLE = "subscription_title"
const val KEY_SUB_EXPIRE = "subscription_expire_at"
const val KEY_THEME_MODE = "theme_mode"
const val KEY_FONT_SCALE = "font_scale"
}
}
@@ -1,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
}
@@ -69,7 +69,8 @@ class AuthRepositoryImpl(
mobile = dto.mobile ?: session.account.value.mobile,
displayName = dto.displayName,
isSubscribed = dto.isSubscribed,
subscriptionTitle = dto.subscriptionTitle
subscriptionTitle = dto.subscriptionTitle,
subscriptionExpireAt = dto.subscriptionExpireAt
)
return dto.isSubscribed
}
@@ -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,8 +65,6 @@ class DrugRepositoryImpl(
pageNumber = params.pageNumber
)
println("HTML Response length: ${htmlResponse.length}")
val searchResult = parser.parseSearchResultsWithPagination(htmlResponse)
Result.success(searchResult)
@@ -1,13 +1,13 @@
package com.approagency.pharmacy.di
import com.approagency.pharmacy.data.billing.StubPurchaseGateway
import com.approagency.pharmacy.data.billing.MyketPurchaseGateway
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
@@ -27,6 +27,8 @@ 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
@@ -46,7 +48,6 @@ 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 {
@@ -75,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()
@@ -128,15 +116,18 @@ val appModule= module {
single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
// درگاه پرداخت: روی main استاب؛ شاخه‌های myket/bazar جایگزین می‌کنند.
single<PurchaseGateway> { StubPurchaseGateway() }
// درگاه پرداخت: روی شاخهٔ myket پیاده‌سازی مایکت.
single<PurchaseGateway> { MyketPurchaseGateway(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())
}
@@ -198,6 +189,12 @@ val appModule= module {
// کنترلر سراسری نمایش شیت حساب
single { AccountSheetController() }
// پلِ تحویل کدِ خوانده‌شده از پیامک به شیت ورود
single { OtpAutoFillBus() }
// کنترلر نمایش صفحهٔ شرایط و قوانین
single { TermsController() }
viewModel {
AccountViewModel(get(), get(), get())
}
@@ -26,6 +26,13 @@ interface PurchaseGateway {
* نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد.
*/
suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result<PurchaseResult>
/**
* مصرفِ خرید پس از تأییدِ سرور، تا کاربر بتواند دوباره (دورهٔ بعد) خرید کند.
* فقط برای درگاه‌هایی که مدلِ مصرف‌شدنی دارند (مثل مایکت) معنا دارد؛ برای
* بقیه می‌تواند بی‌اثر باشد.
*/
suspend fun consume(purchaseToken: String): Result<Unit>
}
/** نتیجه‌ی یک خرید موفق در فروشگاه. */
@@ -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,4 @@
package com.approagency.pharmacy.domain.model
/** حالت نمایش انتخابی کاربر؛ پیش‌فرض پیروی از سیستم است. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }
@@ -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
@@ -1,28 +1,31 @@
package com.approagency.pharmacy.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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.text.style.TextAlign
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import com.approagency.pharmacy.data.local.AccountState
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)
@@ -33,14 +36,49 @@ fun MainContainer(
) {
val session: SessionManager = koinInject()
val sheetController: AccountSheetController = koinInject()
val termsController: TermsController = koinInject()
val authRepository: AuthRepository = koinInject()
val account by session.account.collectAsState()
val sheetVisible by sheetController.visible.collectAsState()
val termsVisible by termsController.visible.collectAsState()
val themeMode by session.themeMode.collectAsState()
val fontScale by session.fontScale.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
var showLogoutConfirm by remember { mutableStateOf(false) }
val context = LocalContext.current
val appVersion = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull().orEmpty()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AccountDrawer(
account = account,
appVersion = appVersion,
themeMode = themeMode,
onThemeModeChange = { session.setThemeMode(it) },
fontScale = fontScale,
onFontScaleChange = { session.setFontScale(it) },
onShowTerms = {
scope.launch { drawerState.close() }
termsController.show()
},
onLogout = { showLogoutConfirm = true }
)
}
) {
Scaffold(
topBar = {
AccountAppBar(
account = account,
onMenuClick = { scope.launch { drawerState.open() } },
onAccountClick = { sheetController.show() }
)
},
@@ -50,53 +88,29 @@ fun MainContainer(
content()
}
}
}
if (sheetVisible) {
AccountSheet(onDismiss = { sheetController.hide() })
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AccountAppBar(
account: AccountState,
onAccountClick: () -> Unit
) {
TopAppBar(
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
)
if (termsVisible) {
TermsDialog(onDismiss = { termsController.hide() })
}
} else {
Text(text = "دارویاب", style = MaterialTheme.typography.titleMedium)
if (showLogoutConfirm) {
ConfirmBottomSheet(
title = "خروج از حساب",
message = "آیا می‌خواهید از حساب کاربری خود خارج شوید؟",
confirmText = "خروج",
onConfirm = {
showLogoutConfirm = false
scope.launch {
authRepository.logout()
drawerState.close()
}
},
actions = {
if (account.isLoggedIn) {
IconButton(onClick = onAccountClick) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = "حساب کاربری",
tint = MaterialTheme.colorScheme.primary
onDismiss = { showLogoutConfirm = false }
)
}
} else {
TextButton(onClick = onAccountClick) {
Text("ورود", textAlign = TextAlign.Center)
}
}
}
)
}
@@ -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) }
)
}
}
}
@@ -2,6 +2,7 @@ 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
@@ -12,7 +13,6 @@ 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.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -20,25 +20,27 @@ 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.Alignment
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.domain.model.SubscriptionProduct
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
import com.approagency.pharmacy.presentation.common.Loading
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
@@ -49,6 +51,8 @@ fun AccountSheet(
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() }
@@ -60,6 +64,22 @@ fun AccountSheet(
}
}
// در مرحله‌ی کد، گوش‌دادن به پیامک را آغاز کن و با خروج متوقفش کن.
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(
@@ -77,17 +97,21 @@ fun AccountSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
OutlinedTextField(
value = viewModel.mobile,
onValueChange = viewModel::updateMobile,
label = { Text("شماره موبایل") },
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)
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = { viewModel.sendOtp() })
TermsConsentNote(onShowTerms = { termsController.show() })
}
AccountPhase.EnterOtp -> {
@@ -98,17 +122,16 @@ fun AccountSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
OutlinedTextField(
value = viewModel.otp,
onValueChange = viewModel::updateOtp,
label = { Text("کد تأیید") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
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) {
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = { viewModel.verifyOtp() })
TextButton(onClick = { viewModel.editMobile() }, enabled = !ui.busy) {
Text("ویرایش شماره موبایل")
}
}
@@ -122,14 +145,16 @@ fun AccountSheet(
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
when {
ui.productsLoading -> Loading(
modifier = Modifier.fillMaxWidth().height(160.dp)
)
ui.productsLoading -> Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
) {
repeat(2) { SubscriptionProductCardSkeleton() }
}
else -> Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
) {
ui.products.forEach { product ->
ProductCard(
SubscriptionProductCard(
product = product,
isPurchasing = ui.purchasingProductId == product.id,
onBuy = { activity?.let { viewModel.purchase(it, product) } }
@@ -155,8 +180,6 @@ fun AccountSheet(
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
TextButton(onClick = viewModel::logout) { Text("خروج از حساب") }
}
}
@@ -180,40 +203,14 @@ private fun SheetTitle(text: String) {
Spacer(Modifier.height(MaterialTheme.dime.xs))
}
/** لینکِ «شرایط و قوانین» زیر فرمِ ورود (برای دسترسی و بازبینیِ فروشگاه). */
@Composable
private fun ProductCard(
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)
private fun TermsConsentNote(onShowTerms: () -> Unit) {
TextButton(onClick = onShowTerms, modifier = Modifier.fillMaxWidth()) {
Text(
"${formatPrice(product.price)} تومان",
style = MaterialTheme.typography.titleSmall,
text = "با ادامه، شرایط و قوانین را می‌پذیرید",
style = MaterialTheme.typography.labelMedium,
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)
}
}
}
/** قالب‌بندی قیمت با جداکننده‌ی هزارگان. */
private fun formatPrice(price: Long): String =
price.toString().reversed().chunked(3).joinToString(",").reversed()
@@ -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,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,13 +3,13 @@ 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.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
@@ -33,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 -> {}
@@ -190,4 +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
private fun EmptyMessage(message: String) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -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))
}
}
}
}
}
}
@@ -47,7 +47,7 @@ class AccountViewModel(
}
fun updateOtp(value: String) {
otp = value.filter { it.isDigit() }.take(6)
otp = value.filter { it.isDigit() }.take(OTP_LENGTH)
}
/** هنگام باز شدن شیت: فاز مناسب را بر اساس وضعیت حساب تعیین کن. */
@@ -135,6 +135,8 @@ class AccountViewModel(
}
repository.subscribe(product.id, result.purchaseToken, gateway.name).fold(
onSuccess = {
// پس از تأییدِ سرور، خرید را مصرف کن تا دورهٔ بعد قابل خرید باشد.
gateway.consume(result.purchaseToken)
_ui.update {
it.copy(
purchasingProductId = null,
@@ -170,6 +172,11 @@ class AccountViewModel(
fun dismissError() {
_ui.update { it.copy(error = null) }
}
private companion object {
/** طول کد یک‌بارمصرفِ سامانه‌ی آپرواجنسی. */
const val OTP_LENGTH = 5
}
}
enum class AccountPhase { EnterMobile, EnterOtp, Products, Subscribed }
@@ -65,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,
@@ -74,10 +73,8 @@ class HomeViewModel (
) }
} catch (e: Exception) {
println(e.message)
handleError(e.toUserMessage())
}catch (e: Exception) {
println(e.message)
handleError(e.toUserMessage())
}
}
@@ -103,7 +100,6 @@ class HomeViewModel (
)
)
}
println(result)
} catch (e: Exception) {
handleError(e.toUserMessage())
}catch (e: Exception) {
@@ -94,8 +94,9 @@ class SearchViewModel(
allDrugs.addAll(searchResult.drugs)
isLoadingMore = false
// فقط یک «جستجوی جدیدِ» موفق از سهمیه‌ی رایگان کم می‌کند.
if (isNewSearch && !session.isSubscribed) {
// فقط وقتی جستجوی جدید نتیجه داشته باشد از سهمیه‌ی رایگان کم می‌کند؛
// اگر دارویی یافت نشود سهمیه دست‌نخورده می‌ماند.
if (isNewSearch && !session.isSubscribed && searchResult.drugs.isNotEmpty()) {
session.incrementFreeSearchCount()
}
@@ -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,7 +1,6 @@
package com.approagency.pharmacy.utils
object Config {
const val BASE_URL = "https://drug.approagency.ir/api/"
const val Darro_Url = "https://www.darooyab.ir/"
/** بک‌اند اشتراک/احراز هویت آپرواجنسی (لاگین، وضعیت، محصولات، خرید). */
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+2
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">داروخانه همراه</string>
<string name="app_name">داروچی</string>
</resources>
+99
View File
@@ -0,0 +1,99 @@
# شرایط و قوانین استفاده از اپلیکیشن داروچی
آخرین به‌روزرسانی: ۱۴۰۵/۰۳/۲۴
استفاده از اپلیکیشن «داروچی» (که از این پس «برنامه» نامیده می‌شود) به‌منزلهٔ
مطالعه و پذیرشِ کاملِ شرایط و قوانین زیر است. در صورتی که با هر بخشی از این
قوانین موافق نیستید، لطفاً از برنامه استفاده نکنید.
## ۱. معرفی خدمات
برنامه ابزاری اطلاع‌رسانی است که امکانات زیر را در اختیار کاربر قرار می‌دهد:
- جست‌وجوی دارو و مشاهدهٔ اطلاعات دارویی (موارد مصرف، مقدار مصرف، عوارض،
تداخلات، هشدارها و موارد مشابه).
- مشاهدهٔ موجودی دارو در داروخانه‌ها.
- مشاهدهٔ اطلاعات آزمایش‌ها و تست‌های پزشکی.
این خدمات صرفاً جنبهٔ آموزشی و اطلاع‌رسانی دارند.
## ۲. سلب مسئولیت پزشکی (بسیار مهم)
- اطلاعاتِ ارائه‌شده در برنامه **جایگزینِ مشاورهٔ پزشک، داروساز یا کادر درمان
نیست** و نباید مبنای تشخیص، تجویز یا قطع/شروع مصرف دارو قرار گیرد.
- مصرفِ هر دارو باید **زیر نظر پزشک یا داروساز** و مطابق نسخه و دستور ایشان
انجام شود.
- اطلاعاتِ دارویی ممکن است کامل، به‌روز یا بدون خطا نباشد. مسئولیتِ هرگونه
استفاده از این اطلاعات بر عهدهٔ کاربر است.
- موجودیِ نمایش‌داده‌شدهٔ داروها در داروخانه‌ها بر اساس داده‌های منابعِ ثالث
است و ممکن است با وضعیتِ واقعی متفاوت باشد؛ پیش از مراجعه، موجودی را از
داروخانه تأیید کنید.
- در شرایطِ اورژانسی یا بروزِ عوارض، فوراً به پزشک یا اورژانس مراجعه کنید.
## ۳. حساب کاربری و ورود
- ورود به برنامه از طریق شماره موبایل و کدِ یک‌بارمصرف (OTP) انجام می‌شود.
- مسئولیتِ حفظِ شماره موبایل، دسترسی به دستگاه و کدِ تأیید بر عهدهٔ کاربر است.
- کاربر موظف است اطلاعاتِ صحیح ارائه دهد و حساب را در اختیار دیگران قرار ندهد.
## ۴. جست‌وجوی رایگان، اشتراک و پرداخت
- برنامه تعدادی **جست‌وجوی رایگان** در اختیار کاربر قرار می‌دهد. پس از پایانِ
سهمیهٔ رایگان، برای ادامهٔ استفادهٔ نامحدود، تهیهٔ **اشتراک** لازم است.
- خرید و پرداختِ اشتراک از طریق **درگاهِ پرداختِ درون‌برنامه‌ایِ فروشگاه
(مایکت)** انجام می‌شود و تابعِ قوانین و شرایطِ همان فروشگاه است.
- قیمت و مدتِ اشتراک پیش از خرید به کاربر نمایش داده می‌شود.
- **بازگشتِ وجه** تابعِ سیاست‌های فروشگاهِ مایکت است. درخواست‌های مربوط به
عودتِ وجهِ خریدهای درون‌برنامه‌ای از طریق همان فروشگاه پیگیری می‌شود.
- در صورتی که پس از پرداختِ موفق، اشتراک فعال نشد، با پشتیبانیِ ما تماس بگیرید.
## ۵. حریم خصوصی و داده‌ها
- برای ارائهٔ خدمات، حداقلِ اطلاعاتِ لازم (از جمله **شماره موبایل** و وضعیتِ
اشتراک) دریافت و نگه‌داری می‌شود.
- اطلاعاتِ خرید (توکنِ خرید) صرفاً برای فعال‌سازیِ اشتراک به سرور ارسال می‌شود.
- اطلاعاتِ کاربران بدون رضایتِ ایشان در اختیارِ اشخاصِ ثالثِ غیرمرتبط قرار
نمی‌گیرد، مگر در مواردی که قانون الزام کند.
- برای آگاهیِ بیشتر، به سند **حریم خصوصی** برنامه مراجعه کنید.
## ۶. تعهدات کاربر
کاربر متعهد می‌شود که:
- از برنامه صرفاً برای اهدافِ قانونی و شخصی استفاده کند.
- در عملکردِ برنامه اخلال ایجاد نکند و از روش‌های نفوذ، مهندسیِ معکوس یا دور‌زدنِ
محدودیت‌ها استفاده نکند.
- محتوای برنامه را بدون اجازه بازنشر یا تجاری‌سازی نکند.
## ۷. مالکیت فکری
تمامِ حقوقِ مربوط به برنامه، نام، نشان (لوگو)، رابط کاربری و محتوای آن متعلق به
مالکِ برنامه است و هرگونه استفادهٔ غیرمجاز پیگردِ قانونی دارد.
## ۸. محدودیت مسئولیت
- برنامه «همان‌گونه که هست» ارائه می‌شود و هیچ تضمینی نسبت به بی‌نقص بودن،
در‌دسترس بودنِ دائمی یا صحتِ کاملِ اطلاعات داده نمی‌شود.
- مالکِ برنامه در قبالِ خساراتِ مستقیم یا غیرمستقیمِ ناشی از استفاده یا عدمِ
امکانِ استفاده از برنامه، یا اتکا به اطلاعاتِ آن، مسئولیتی نمی‌پذیرد.
## ۹. تعلیق و خاتمه
در صورتِ نقضِ این قوانین، دسترسیِ کاربر به برنامه ممکن است بدونِ اطلاعِ قبلی
محدود یا مسدود شود.
## ۱۰. تغییر قوانین
این شرایط ممکن است در آینده به‌روزرسانی شود. ادامهٔ استفاده از برنامه پس از
انتشارِ نسخهٔ جدید، به‌منزلهٔ پذیرشِ تغییرات است.
## ۱۱. قانونِ حاکم
این شرایط تابعِ قوانینِ جمهوریِ اسلامیِ ایران است و اختلافاتِ احتمالی مطابقِ همین
قوانین حل‌وفصل می‌شود.
## ۱۲. ارتباط با ما
در صورتِ هرگونه پرسش یا مشکل، از طریقِ راه‌های زیر با ما در تماس باشید:
- ایمیل پشتیبانی: approagency@gmail.com
+2
View File
@@ -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" }
+1
View File
@@ -18,6 +18,7 @@ dependencyResolutionManagement {
//google()
//mavenCentral()
maven { url = uri("https://maven.myket.ir") }
maven { url = uri("https://jitpack.io") }
}
}