feat: login and otp added

This commit is contained in:
2026-06-12 13:24:39 +03:30
parent e73269889f
commit c3496e3de4
18 changed files with 1252 additions and 49 deletions
@@ -5,29 +5,38 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ExperimentalMaterial3Api
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.ui.theme.DrugTheme
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
class MainActivity : ComponentActivity() {
private val session: SessionManager by inject()
private val authRepository: AuthRepository by inject()
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DrugTheme {
AppNavGraph(
// navController = navController,
// modifier = Modifier.padding(innerPadding)
)
// Scaffold(modifier = Modifier.fillMaxSize(),
// ) { innerPadding ->
// AppNavGraph(
//// navController = navController,
//// modifier = Modifier.padding(innerPadding)
// )
// }
AppNavGraph()
}
}
}
/**
* هنگام شروع اپ و هر بار بازگشت به اپ (مثلاً پس از بازگشت از درگاه پرداختِ
* مایکت/بازار) وضعیت اشتراک از سرور تازه‌سازی می‌شود تا کل اپ به‌روز بماند.
*/
override fun onResume() {
super.onResume()
if (session.isLoggedIn) {
lifecycleScope.launch { authRepository.refreshStatus() }
}
}
}
@@ -0,0 +1,25 @@
package com.approagency.pharmacy.data.billing
import android.app.Activity
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.billing.PurchaseResult
import com.approagency.pharmacy.domain.model.SubscriptionProduct
/**
* پیاده‌سازی پیش‌فرض درگاه پرداخت روی شاخه‌ی `main`.
*
* هیچ درگاه واقعی‌ای ندارد؛ شاخه‌های `myket` و `bazar` این را با پیاده‌سازی
* فروشگاه خود جایگزین می‌کنند. تا آن زمان، خرید با پیام مناسب ناموفق می‌شود.
*/
class StubPurchaseGateway : PurchaseGateway {
override val name: String = "none"
override val isAvailable: Boolean = false
override suspend fun purchase(
activity: Activity,
product: SubscriptionProduct
): Result<PurchaseResult> =
Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست."))
}
@@ -0,0 +1,72 @@
package com.approagency.pharmacy.data.dto
import com.google.gson.annotations.SerializedName
/** پاسخ ارسال کد یک‌بارمصرف (login-otp). */
data class LoginOtpResponse(
@SerializedName("message") val message: String? = null,
@SerializedName("status") val status: String? = null
)
/** پاسخ تأیید کد (check-otp) که توکن نشست را برمی‌گرداند. */
data class CheckOtpResponse(
@SerializedName("token") val token: String? = null,
@SerializedName("message") val message: String? = null,
@SerializedName("user") val user: UserDto? = null
)
data class UserDto(
@SerializedName("id") val id: Int? = null,
@SerializedName("first_name") val firstName: String? = null,
@SerializedName("last_name") val lastName: String? = null,
@SerializedName("mobile") val mobile: String? = null,
@SerializedName("email") val email: String? = null
)
/** یک محصول اشتراک از `package-names/{name}/products`. */
data class ProductDto(
@SerializedName("id") val id: Int,
@SerializedName("package_name_id") val packageNameId: Int? = null,
@SerializedName("title") val title: String? = null,
@SerializedName("price") val price: Long? = null,
@SerializedName("type") val type: Int? = null,
@SerializedName("uuid") val uuid: String? = null,
@SerializedName("descriptions") val descriptions: String? = null
)
/**
* پاسخ `GET /status` که همان پروفایل کاربر به‌همراه فهرست محصولاتِ خریداری‌شده است.
* اشتراک فعال یعنی [products] خالی نباشد؛ عنوان اشتراک از اولین محصول خوانده می‌شود.
*/
data class StatusDto(
@SerializedName("id") val id: Int? = null,
@SerializedName("first_name") val firstName: String? = null,
@SerializedName("last_name") val lastName: String? = null,
@SerializedName("full_name") val fullName: String? = null,
@SerializedName("mobile") val mobile: String? = null,
@SerializedName("email") val email: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("wallet") val wallet: Long? = null,
@SerializedName("products") val products: List<ProductDto>? = null
) {
/** کاربر در صورت داشتن حداقل یک محصول، اشتراک فعال دارد. */
val isSubscribed: Boolean get() = !products.isNullOrEmpty()
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
val subscriptionTitle: String? get() = products?.firstOrNull()?.title
val displayName: String? get() = fullName?.takeIf { it.isNotBlank() }
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
}
/** بدنه‌ی درخواست خرید اشتراک. */
data class SubscribeRequest(
@SerializedName("purchase_token") val purchaseToken: String,
@SerializedName("gateway") val gateway: String
)
/** پاسخ خرید اشتراک. موفقیت اصلی با کد ۲xx مشخص می‌شود. */
data class SubscribeResponse(
@SerializedName("message") val message: String? = null,
@SerializedName("status") val status: String? = null
)
@@ -0,0 +1,129 @@
package com.approagency.pharmacy.data.local
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** وضعیت حساب کاربر که در کل اپ مشاهده می‌شود. */
data class AccountState(
val isLoggedIn: Boolean = false,
val mobile: String? = null,
val displayName: String? = null,
val isSubscribed: Boolean = false,
val subscriptionTitle: String? = null
)
/**
* تنها منبعِ حقیقتِ نشست کاربر روی دستگاه (SharedPreferences).
*
* مسئولیت‌ها:
* - توکن احراز هویت آپرواجنسی
* - شمارنده‌ی جستجوهای رایگان دارویاب
* - کشِ پایدارِ وضعیت حساب/اشتراک ([AccountState]) که در کل اپ به‌صورت واکنشی
* مشاهده می‌شود و هنگام شروع اپ یا بازگشت از درگاه پرداخت به‌روزرسانی می‌گردد
*/
class SessionManager(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// ---------- توکن احراز هویت ----------
private var token: String? = prefs.getString(KEY_TOKEN, null)
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
fun saveToken(token: String) {
this.token = token
prefs.edit().putString(KEY_TOKEN, token).apply()
publishAccount()
}
fun getToken(): String? = token
// ---------- شمارنده‌ی جستجوی رایگان ----------
private val _freeSearchCount = MutableStateFlow(prefs.getInt(KEY_FREE_SEARCH_COUNT, 0))
val freeSearchCount: StateFlow<Int> = _freeSearchCount.asStateFlow()
fun incrementFreeSearchCount() {
val next = _freeSearchCount.value + 1
prefs.edit().putInt(KEY_FREE_SEARCH_COUNT, next).apply()
_freeSearchCount.value = next
}
fun resetFreeSearchCount() {
prefs.edit().putInt(KEY_FREE_SEARCH_COUNT, 0).apply()
_freeSearchCount.value = 0
}
fun testForIncrease (){
prefs.edit().putInt(KEY_FREE_SEARCH_COUNT, -1).apply()
_freeSearchCount.value = -1
}
// ---------- وضعیت حساب/اشتراک (پایدار و واکنشی) ----------
private val _account = MutableStateFlow(readAccount())
val account: StateFlow<AccountState> = _account.asStateFlow()
val isSubscribed: Boolean get() = _account.value.isSubscribed
/** به‌روزرسانی وضعیت حساب از روی پاسخ `/status` و ذخیره‌ی پایدار آن. */
fun updateAccount(
mobile: String?,
displayName: String?,
isSubscribed: Boolean,
subscriptionTitle: String?
) {
prefs.edit()
.putString(KEY_MOBILE, mobile)
.putString(KEY_DISPLAY_NAME, displayName)
.putBoolean(KEY_IS_SUBSCRIBED, isSubscribed)
.putString(KEY_SUB_TITLE, subscriptionTitle)
.apply()
publishAccount()
}
/** ذخیره‌ی شماره‌ی موبایل واردشده (پیش از تکمیل ورود). */
fun saveMobile(mobile: String) {
prefs.edit().putString(KEY_MOBILE, mobile).apply()
publishAccount()
}
/** پاک‌سازی کامل نشست هنگام خروج از حساب. */
fun clear() {
token = null
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_MOBILE)
.remove(KEY_DISPLAY_NAME)
.remove(KEY_IS_SUBSCRIBED)
.remove(KEY_SUB_TITLE)
.apply()
publishAccount()
}
private fun publishAccount() {
_account.value = readAccount()
}
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)
)
private companion object {
const val PREFS_NAME = "approagency_session"
const val KEY_TOKEN = "auth_token"
const val KEY_MOBILE = "mobile"
const val KEY_DISPLAY_NAME = "display_name"
const val KEY_FREE_SEARCH_COUNT = "free_search_count"
const val KEY_IS_SUBSCRIBED = "is_subscribed"
const val KEY_SUB_TITLE = "subscription_title"
}
}
@@ -0,0 +1,61 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.data.dto.CheckOtpResponse
import com.approagency.pharmacy.data.dto.LoginOtpResponse
import com.approagency.pharmacy.data.dto.ProductDto
import com.approagency.pharmacy.data.dto.StatusDto
import com.approagency.pharmacy.data.dto.SubscribeRequest
import com.approagency.pharmacy.data.dto.SubscribeResponse
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/** بک‌اند اشتراک/احراز هویت آپرواجنسی ([Config.AUTH_BASE_URL]). */
interface ApproApiService {
/** ارسال کد یک‌بارمصرف به موبایل. */
@FormUrlEncoded
@POST("auth/login-otp")
suspend fun loginOtp(
@Field("mobile") mobile: String,
@Field("package_name") packageName: String,
@Field("fcm_token") fcmToken: String? = null
): LoginOtpResponse
/** تأیید کد و دریافت توکن نشست. */
@FormUrlEncoded
@POST("auth/check-otp")
suspend fun checkOtp(
@Field("mobile") mobile: String,
@Field("token") token: String
): CheckOtpResponse
/** خروج از حساب (توکن از طریق اینترسپتور ارسال می‌شود). */
@PUT("auth/logout")
suspend fun logout()
/** وضعیت اشتراک کاربر برای این پکیج. */
@GET("status")
suspend fun getStatus(
@Query("package_name") packageName: String
): StatusDto
/** فهرست محصولات اشتراک قابل خرید برای این پکیج. */
@GET("package-names/{name}/products")
suspend fun getProducts(
@Path("name") packageName: String
): List<ProductDto>
/** ثبت خرید اشتراک پس از پرداخت موفق در درگاه (مایکت/بازار). */
@PUT("package-names/{name}/products/{product_id}/subscribe")
suspend fun subscribe(
@Path("name") packageName: String,
@Path("product_id") productId: Int,
@Body body: SubscribeRequest
): SubscribeResponse
}
@@ -0,0 +1,28 @@
package com.approagency.pharmacy.data.remote
import com.approagency.pharmacy.data.local.SessionManager
import okhttp3.Interceptor
import okhttp3.Response
/**
* هدرهای لازم برای بک‌اند آپرواجنسی را اضافه می‌کند:
* - `Accept: application/json`
* - `Authorization: Bearer <token>` در صورت وجود توکن
*
* این اینترسپتور فقط روی کلاینت اختصاصیِ آپرواجنسی نصب می‌شود؛ بنابراین توکن
* هرگز به دارویاب یا بک‌اند جستجوی دارو ارسال نمی‌شود.
*/
class AuthInterceptor(
private val sessionManager: SessionManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().newBuilder()
.header("Accept", "application/json")
sessionManager.getToken()?.takeIf { it.isNotBlank() }?.let { token ->
builder.header("Authorization", "Bearer $token")
}
return chain.proceed(builder.build())
}
}
@@ -0,0 +1,113 @@
package com.approagency.pharmacy.data.repository
import com.approagency.pharmacy.data.dto.SubscribeRequest
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.data.remote.ApproApiService
import com.approagency.pharmacy.domain.model.SubscriptionProduct
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.utils.Config
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AuthRepositoryImpl(
private val api: ApproApiService,
private val session: SessionManager
) : AuthRepository {
override suspend fun loginOtp(mobile: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
api.loginOtp(mobile = mobile, packageName = Config.PACKAGE_NAME)
session.saveMobile(mobile)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun checkOtp(mobile: String, code: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val response = api.checkOtp(mobile = mobile, token = code)
val token = response.token
if (token.isNullOrBlank()) {
Result.failure(IllegalStateException("توکن دریافت نشد."))
} else {
session.saveToken(token)
session.saveMobile(mobile)
// بلافاصله وضعیت اشتراک را بخوان تا حساب به‌روز شود.
runCatching { fetchAndStoreStatus() }
Result.success(Unit)
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout(): Result<Unit> = withContext(Dispatchers.IO) {
try {
runCatching { api.logout() } // حتی اگر سرور خطا داد، نشست محلی پاک شود
session.clear()
Result.success(Unit)
} catch (e: Exception) {
session.clear()
Result.failure(e)
}
}
override suspend fun refreshStatus(): Result<Boolean> = withContext(Dispatchers.IO) {
try {
Result.success(fetchAndStoreStatus())
} catch (e: Exception) {
Result.failure(e)
}
}
/** `/status` را می‌خواند، حساب را ذخیره می‌کند و وضعیت اشتراک را برمی‌گرداند. */
private suspend fun fetchAndStoreStatus(): Boolean {
val dto = api.getStatus(packageName = Config.PACKAGE_NAME)
session.updateAccount(
mobile = dto.mobile ?: session.account.value.mobile,
displayName = dto.displayName,
isSubscribed = dto.isSubscribed,
subscriptionTitle = dto.subscriptionTitle
)
return dto.isSubscribed
}
override suspend fun getProducts(): Result<List<SubscriptionProduct>> =
withContext(Dispatchers.IO) {
try {
val products = api.getProducts(packageName = Config.PACKAGE_NAME).map {
SubscriptionProduct(
id = it.id,
title = it.title ?: "",
price = it.price ?: 0L,
uuid = it.uuid,
description = it.descriptions
)
}
Result.success(products)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun subscribe(
productId: Int,
purchaseToken: String,
gateway: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
api.subscribe(
packageName = Config.PACKAGE_NAME,
productId = productId,
body = SubscribeRequest(purchaseToken = purchaseToken, gateway = gateway)
)
// پس از ثبت خرید، وضعیت واقعی را از سرور بخوان.
runCatching { fetchAndStoreStatus() }
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -1,13 +1,20 @@
package com.approagency.pharmacy.di
import com.approagency.pharmacy.data.billing.StubPurchaseGateway
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.data.local.database.LabDatabase
import com.approagency.pharmacy.data.remote.ApproApiService
import com.approagency.pharmacy.data.remote.AuthInterceptor
import com.approagency.pharmacy.data.remote.DarooyabApiService
import com.approagency.pharmacy.data.remote.DrugApiService
import com.approagency.pharmacy.data.remote.DrugDetailParser
import com.approagency.pharmacy.data.remote.DrugHtmlParser
import com.approagency.pharmacy.data.repository.AuthRepositoryImpl
import com.approagency.pharmacy.data.repository.DrugRepositoryImpl
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.domain.repository.DrugRepository
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
import com.approagency.pharmacy.domain.usecase.GetDarmanUseCase
@@ -19,6 +26,8 @@ import com.approagency.pharmacy.domain.usecase.GetTestGroupUseCase
import com.approagency.pharmacy.domain.usecase.GetTestItemByGroupId
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase
import com.approagency.pharmacy.presentation.account.AccountSheetController
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
import com.approagency.pharmacy.presentation.viewModel.LabViewModel
@@ -39,10 +48,14 @@ import java.net.CookiePolicy
import java.util.concurrent.TimeUnit
val jsonRetrofitQualifier = qualifier("jsonRetrofit")
val scalarRetrofitQualifier = qualifier("scalarRetrofit")
val authRetrofitQualifier = qualifier("authRetrofit")
val appModule= module {
single { LabDatabase.getInstance(androidContext()) }
// نشست کاربر روی دستگاه (توکن، موبایل، سهمیه‌ی جستجوی رایگان، وضعیت اشتراک)
single { SessionManager(androidContext()) }
single { get<LabDatabase>().testGroupDao() }
single { get<LabDatabase>().testItemDao() }
@@ -89,6 +102,35 @@ val appModule= module {
retrofit.create(DarooyabApiService::class.java)
}
// ========== Retrofit for APPROAGENCY auth/subscription backend ==========
// کلاینت مجزا با AuthInterceptor تا توکن فقط به این بک‌اند ارسال شود.
single<Retrofit>(authRetrofitQualifier) {
val authClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(get()))
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
Retrofit.Builder()
.baseUrl(Config.AUTH_BASE_URL)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single<ApproApiService> {
val retrofit: Retrofit = get(authRetrofitQualifier)
retrofit.create(ApproApiService::class.java)
}
single<AuthRepository> { AuthRepositoryImpl(get(), get()) }
// درگاه پرداخت: روی main استاب؛ شاخه‌های myket/bazar جایگزین می‌کنند.
single<PurchaseGateway> { StubPurchaseGateway() }
factory { DrugHtmlParser() }
factory { DrugDetailParser() }
@@ -150,7 +192,14 @@ val appModule= module {
}
viewModel {
SearchViewModel(get())
SearchViewModel(get(), get(), get())
}
// کنترلر سراسری نمایش شیت حساب
single { AccountSheetController() }
viewModel {
AccountViewModel(get(), get(), get())
}
viewModel {
PharmacyViewModel(get() , get ())
@@ -0,0 +1,35 @@
package com.approagency.pharmacy.domain.billing
import android.app.Activity
import com.approagency.pharmacy.domain.model.SubscriptionProduct
/**
* انتزاع درگاه پرداخت درون‌برنامه‌ای.
*
* این اینترفیس مرز میان منطقِ مشترکِ اشتراک و پیاده‌سازیِ مخصوصِ هر فروشگاه است:
* - شاخه‌ی `myket` → پیاده‌سازی مایکت (Myket IAB)
* - شاخه‌ی `bazar` → پیاده‌سازی کافه‌بازار (Poolakey)
*
* جریان: درگاه خرید را انجام می‌دهد و یک [PurchaseResult.purchaseToken] برمی‌گرداند؛
* سپس لایه‌ی اشتراک آن توکن را با `gateway = [name]` به سرور آپرواجنسی می‌فرستد.
*/
interface PurchaseGateway {
/** نام درگاه که به سرور ارسال می‌شود (مثلاً "myket" یا "bazaar"). */
val name: String
/** آیا این درگاه روی این بیلد فعال/پیکربندی شده است؟ */
val isAvailable: Boolean
/**
* خرید [product] را در فروشگاه آغاز می‌کند.
* نیاز به [activity] برای باز کردن جریان پرداخت فروشگاه دارد.
*/
suspend fun purchase(activity: Activity, product: SubscriptionProduct): Result<PurchaseResult>
}
/** نتیجه‌ی یک خرید موفق در فروشگاه. */
data class PurchaseResult(
val purchaseToken: String,
val orderId: String? = null
)
@@ -0,0 +1,10 @@
package com.approagency.pharmacy.domain.model
/** محصول اشتراک قابل‌نمایش در صفحه‌ی خرید. */
data class SubscriptionProduct(
val id: Int,
val title: String,
val price: Long,
val uuid: String?,
val description: String?
)
@@ -0,0 +1,27 @@
package com.approagency.pharmacy.domain.repository
import com.approagency.pharmacy.domain.model.SubscriptionProduct
interface AuthRepository {
/** ارسال کد یک‌بارمصرف به [mobile]. */
suspend fun loginOtp(mobile: String): Result<Unit>
/** تأیید [code] برای [mobile]؛ در صورت موفقیت توکن ذخیره می‌شود. */
suspend fun checkOtp(mobile: String, code: String): Result<Unit>
/** خروج از حساب و پاک‌سازی نشست. */
suspend fun logout(): Result<Unit>
/**
* خواندن `/status` و به‌روزرسانی [SessionManager.account].
* مقدار بازگشتی نشان می‌دهد کاربر اشتراک فعال دارد یا نه.
*/
suspend fun refreshStatus(): Result<Boolean>
/** فهرست محصولات اشتراک قابل خرید. */
suspend fun getProducts(): Result<List<SubscriptionProduct>>
/** ثبت خرید اشتراک پس از پرداخت موفق در درگاه. */
suspend fun subscribe(productId: Int, purchaseToken: String, gateway: String): Result<Unit>
}
@@ -1,26 +1,102 @@
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavHostController
import com.approagency.pharmacy.data.local.AccountState
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.presentation.account.AccountSheet
import com.approagency.pharmacy.presentation.account.AccountSheetController
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainContainer(
navController: NavHostController,
content: @Composable () -> Unit
) {
val session: SessionManager = koinInject()
val sheetController: AccountSheetController = koinInject()
val account by session.account.collectAsState()
val sheetVisible by sheetController.visible.collectAsState()
Scaffold(
bottomBar = {
BottomBar(navController)
}
topBar = {
AccountAppBar(
account = account,
onAccountClick = { sheetController.show() }
)
},
bottomBar = { BottomBar(navController) }
) { padding ->
Box(modifier = Modifier.padding(padding)) {
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
)
}
} else {
Text(text = "دارویاب", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
if (account.isLoggedIn) {
IconButton(onClick = onAccountClick) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = "حساب کاربری",
tint = MaterialTheme.colorScheme.primary
)
}
} else {
TextButton(onClick = onAccountClick) {
Text("ورود", textAlign = TextAlign.Center)
}
}
}
)
}
@@ -0,0 +1,219 @@
package com.approagency.pharmacy.presentation.account
import android.app.Activity
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.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.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.PrimaryButton
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSheet(
onDismiss: () -> Unit,
viewModel: AccountViewModel = koinViewModel()
) {
val ui by viewModel.ui.collectAsState()
val account by viewModel.account.collectAsState()
val activity = LocalContext.current as? Activity
LaunchedEffect(Unit) { viewModel.onSheetOpened() }
// پس از خرید موفق، شیت بسته می‌شود (نوار بالای اپ خودش به‌روز می‌شود).
LaunchedEffect(ui.purchaseSuccess) {
if (ui.purchaseSuccess) {
viewModel.consumePurchaseSuccess()
onDismiss()
}
}
CustomModalBottomSheet(onDismiss = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 220.dp)
.padding(MaterialTheme.dime.lg)
) {
when (ui.phase) {
AccountPhase.EnterMobile -> {
SheetTitle("ورود به حساب")
Text(
"برای ادامه‌ی جستجو شماره موبایل خود را وارد کنید",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
OutlinedTextField(
value = viewModel.mobile,
onValueChange = viewModel::updateMobile,
label = { Text("شماره موبایل") },
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)
}
AccountPhase.EnterOtp -> {
SheetTitle("تأیید شماره")
Text(
"کد ارسال‌شده به ${viewModel.mobile} را وارد کنید",
style = MaterialTheme.typography.bodyMedium,
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),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(MaterialTheme.dime.md))
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = viewModel::verifyOtp)
TextButton(onClick = viewModel::editMobile, enabled = !ui.busy) {
Text("ویرایش شماره موبایل")
}
}
AccountPhase.Products -> {
SheetTitle("اشتراک ویژه")
Text(
"سهمیه‌ی جستجوی رایگان شما به پایان رسیده است. برای جستجوی نامحدود اشتراک تهیه کنید.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
when {
ui.productsLoading -> Loading(
modifier = Modifier.fillMaxWidth().height(160.dp)
)
else -> Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
) {
ui.products.forEach { product ->
ProductCard(
product = product,
isPurchasing = ui.purchasingProductId == product.id,
onBuy = { activity?.let { viewModel.purchase(it, product) } }
)
}
}
}
if (!viewModel.gatewayAvailable) {
Spacer(Modifier.height(MaterialTheme.dime.sm))
Text(
"درگاه پرداخت روی این نسخه فعال نیست.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
AccountPhase.Subscribed -> {
SheetTitle("اشتراک فعال")
Text(
account.subscriptionTitle?.let { "اشتراک شما فعال است: $it" }
?: "اشتراک شما فعال است.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
TextButton(onClick = viewModel::logout) { Text("خروج از حساب") }
}
}
ui.error?.let { message ->
Spacer(Modifier.height(MaterialTheme.dime.sm))
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
}
}
}
}
@Composable
private fun SheetTitle(text: String) {
Text(text = text, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(MaterialTheme.dime.xs))
}
@Composable
private fun 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)
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)
}
}
}
/** قالب‌بندی قیمت با جداکننده‌ی هزارگان. */
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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* کنترلر سراسریِ نمایش شیتِ حساب (ورود/اشتراک).
*
* تکنمونه در Koin؛ هم نوار بالای اپ و هم گیتِ جستجو میتوانند با [show] آن را
* باز کنند و [MainContainer] با مشاهدهی [visible] شیت را نمایش میدهد.
*/
class AccountSheetController {
private val _visible = MutableStateFlow(false)
val visible: StateFlow<Boolean> = _visible.asStateFlow()
fun show() { _visible.value = true }
fun hide() { _visible.value = false }
}
@@ -1,9 +1,11 @@
package com.approagency.pharmacy.presentation.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -22,9 +24,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavController
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.model.DrugSearchResult
import com.approagency.pharmacy.navigation.Screen
import com.approagency.pharmacy.presentation.account.AccountSheetController
import com.approagency.pharmacy.presentation.common.CustomTextFilled
import com.approagency.pharmacy.presentation.common.EmptySearchState
import com.approagency.pharmacy.presentation.common.EndOfListIndicator
@@ -42,6 +47,7 @@ import com.approagency.pharmacy.presentation.viewModel.SearchViewModel
import com.vada.caller.ui.theme.LocalDime
import com.vada.caller.ui.theme.dime
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Composable
@@ -54,6 +60,10 @@ fun SearchScreen(
) {
val dime = LocalDime.current
val keyboardController = LocalSoftwareKeyboardController.current
val sheetController: AccountSheetController = koinInject()
val session: SessionManager = koinInject()
val account by session.account.collectAsState()
val remainingFree by viewModel.remainingFreeSearches.collectAsState()
val searchText = viewModel.searchText
val state by viewModel.searchState.collectAsState()
val lazyListState = rememberLazyListState()
@@ -125,8 +135,28 @@ fun SearchScreen(
}
)
// شمارش جستجوهای رایگانِ باقی‌مانده برای کاربرانِ بدون اشتراک
if (!account.isSubscribed) {
Spacer(modifier = Modifier.height(MaterialTheme.dime.xs))
Text(
text = "جستجوی رایگان باقی‌مانده: $remainingFree",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
// PrimaryButton(
// onClick = {
// session.testForIncrease()
// } ,
// text = "",
// isLoading = false
// )
if (showPharmacyBottomSheet && selectedDrugForPharmacy != null) {
// درخواست بر اساس نوع صفحه فرق می‌کند:
// - صفحات برند (/B-...): شناسه باید به‌عنوان brandIrc ارسال شود
@@ -228,9 +258,55 @@ fun SearchScreen(
)
}
SearchState.RequireLogin -> {
SearchGatePrompt(
message = "سهمیه‌ی جستجوی رایگان شما به پایان رسیده است. برای ادامه وارد شوید.",
buttonText = "ورود",
onClick = { sheetController.show() }
)
}
SearchState.RequireSubscription -> {
SearchGatePrompt(
message = "برای جستجوی نامحدودِ دارو، اشتراک ویژه تهیه کنید.",
buttonText = "تهیه اشتراک",
onClick = { sheetController.show() }
)
}
SearchState.Idle -> {
EmptySearchState(onRetry = {})
}
}
}
}
@Composable
private fun SearchGatePrompt(
message: String,
buttonText: String,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(MaterialTheme.dime.lg),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(MaterialTheme.dime.lg))
Box(modifier = Modifier.fillMaxWidth()) {
PrimaryButton(
text = buttonText,
isLoading = false,
onClick = onClick
)
}
}
}
@@ -0,0 +1,185 @@
package com.approagency.pharmacy.presentation.viewModel
import android.app.Activity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.data.local.AccountState
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.billing.PurchaseGateway
import com.approagency.pharmacy.domain.model.SubscriptionProduct
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.utils.toUserMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* مغزِ جریانِ حساب: ورود با کد یکبارمصرف، نمایش/خرید محصولات و بهروزرسانی وضعیت.
*
* منبعِ حقیقتِ وضعیت حساب [SessionManager.account] است؛ این ViewModel فقط آن را
* میخواند و با فراخوانی سرور بهروزرسانی میکند تا کل اپ واکنشی بماند.
*/
class AccountViewModel(
private val repository: AuthRepository,
private val gateway: PurchaseGateway,
private val session: SessionManager
) : ViewModel() {
val account: StateFlow<AccountState> = session.account
val gatewayAvailable: Boolean get() = gateway.isAvailable
var mobile by mutableStateOf(session.account.value.mobile.orEmpty())
private set
var otp by mutableStateOf("")
private set
private val _ui = MutableStateFlow(AccountUiState())
val ui: StateFlow<AccountUiState> = _ui.asStateFlow()
fun updateMobile(value: String) {
mobile = value.filter { it.isDigit() }.take(11)
}
fun updateOtp(value: String) {
otp = value.filter { it.isDigit() }.take(6)
}
/** هنگام باز شدن شیت: فاز مناسب را بر اساس وضعیت حساب تعیین کن. */
fun onSheetOpened() {
val state = account.value
when {
!state.isLoggedIn -> _ui.update { it.copy(phase = AccountPhase.EnterMobile, error = null) }
!state.isSubscribed -> {
_ui.update { it.copy(phase = AccountPhase.Products, error = null) }
loadProducts()
}
else -> _ui.update { it.copy(phase = AccountPhase.Subscribed, error = null) }
}
}
fun sendOtp() {
val normalized = mobile.trim()
if (normalized.length != 11 || !normalized.startsWith("09")) {
_ui.update { it.copy(error = "شماره موبایل معتبر نیست.") }
return
}
_ui.update { it.copy(busy = true, error = null) }
viewModelScope.launch {
repository.loginOtp(normalized).fold(
onSuccess = { _ui.update { it.copy(busy = false, phase = AccountPhase.EnterOtp) } },
onFailure = { e -> _ui.update { it.copy(busy = false, error = e.toUserMessage()) } }
)
}
}
fun verifyOtp() {
val code = otp.trim()
if (code.isBlank()) {
_ui.update { it.copy(error = "کد را وارد کنید.") }
return
}
_ui.update { it.copy(busy = true, error = null) }
viewModelScope.launch {
repository.checkOtp(mobile.trim(), code).fold(
onSuccess = {
otp = ""
// پس از ورود، وضعیت حساب از سرور آمده است؛ فاز مناسب را تعیین کن.
if (account.value.isSubscribed) {
_ui.update { it.copy(busy = false, phase = AccountPhase.Subscribed) }
} else {
_ui.update { it.copy(busy = false, phase = AccountPhase.Products) }
loadProducts()
}
},
onFailure = { e -> _ui.update { it.copy(busy = false, error = e.toUserMessage()) } }
)
}
}
fun editMobile() {
otp = ""
_ui.update { it.copy(phase = AccountPhase.EnterMobile, error = null) }
}
fun loadProducts() {
_ui.update { it.copy(productsLoading = true, error = null) }
viewModelScope.launch {
repository.getProducts().fold(
onSuccess = { list -> _ui.update { it.copy(productsLoading = false, products = list) } },
onFailure = { e -> _ui.update { it.copy(productsLoading = false, error = e.toUserMessage()) } }
)
}
}
/** خرید [product]: ابتدا درگاه فروشگاه، سپس ثبت روی سرور آپرواجنسی. */
fun purchase(activity: Activity, product: SubscriptionProduct) {
if (!gateway.isAvailable) {
_ui.update { it.copy(error = "درگاه پرداخت روی این نسخه فعال نیست.") }
return
}
_ui.update { it.copy(purchasingProductId = product.id, error = null) }
viewModelScope.launch {
val purchase = gateway.purchase(activity, product)
val result = purchase.getOrNull()
if (result == null) {
_ui.update {
it.copy(purchasingProductId = null, error = purchase.exceptionOrNull().toUserMessage())
}
return@launch
}
repository.subscribe(product.id, result.purchaseToken, gateway.name).fold(
onSuccess = {
_ui.update {
it.copy(
purchasingProductId = null,
phase = if (account.value.isSubscribed) AccountPhase.Subscribed else it.phase,
purchaseSuccess = true
)
}
},
onFailure = { e ->
_ui.update { it.copy(purchasingProductId = null, error = e.toUserMessage()) }
}
)
}
}
/** به‌روزرسانی وضعیت اشتراک از سرور (شروع اپ / بازگشت از درگاه). */
fun refreshStatus() {
if (!session.isLoggedIn) return
viewModelScope.launch { repository.refreshStatus() }
}
fun logout() {
viewModelScope.launch {
repository.logout()
_ui.update { it.copy(phase = AccountPhase.EnterMobile, products = emptyList()) }
}
}
fun consumePurchaseSuccess() {
_ui.update { it.copy(purchaseSuccess = false) }
}
fun dismissError() {
_ui.update { it.copy(error = null) }
}
}
enum class AccountPhase { EnterMobile, EnterOtp, Products, Subscribed }
data class AccountUiState(
val phase: AccountPhase = AccountPhase.EnterMobile,
val busy: Boolean = false,
val error: String? = null,
val productsLoading: Boolean = false,
val products: List<SubscriptionProduct> = emptyList(),
val purchasingProductId: Int? = null,
val purchaseSuccess: Boolean = false
)
@@ -5,26 +5,45 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.model.DrugSearchResult
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
import com.approagency.pharmacy.utils.Config
import com.approagency.pharmacy.utils.toUserMessage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SearchViewModel(
private val searchDrugsUseCase: SearchDrugsYabUseCase
private val searchDrugsUseCase: SearchDrugsYabUseCase,
private val session: SessionManager,
private val authRepository: AuthRepository
) : ViewModel() {
private val _searchState = MutableStateFlow<SearchState>(SearchState.Idle)
val searchState: StateFlow<SearchState> = _searchState.asStateFlow()
/** تعداد جستجوی رایگانِ باقی‌مانده برای نمایش به کاربر. */
val remainingFreeSearches: StateFlow<Int> = session.freeSearchCount
.map { remaining(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = remaining(session.freeSearchCount.value)
)
var searchText by mutableStateOf("")
private set
fun updateSearchText(value: String) {
searchText = value
}
private var currentQuery = ""
private var currentPage = 1
private var totalPages = 1
@@ -38,52 +57,82 @@ class SearchViewModel(
return
}
if (isNewSearch) {
// Reset everything for new search
currentQuery = query
currentPage = 1
allDrugs.clear()
isLoadingMore = false
hasMorePages = true
_searchState.value = SearchState.Loading(isNew = true)
} else {
// Don't load if already loading or no more pages
if (!isNewSearch) {
// صفحه‌بندیِ همان جستجو نیازی به بررسی مجدد سهمیه ندارد.
if (isLoadingMore || !hasMorePages) return
isLoadingMore = true
_searchState.value = SearchState.LoadingMore(
currentItems = allDrugs.toList(),
currentPage = currentPage
)
viewModelScope.launch { fetchPage(isNewSearch = false) }
return
}
viewModelScope.launch {
val result = searchDrugsUseCase(currentQuery, currentPage)
if (!ensureCanSearch()) return@launch
_searchState.value = when {
result.isSuccess -> {
val searchResult = result.getOrNull()!!
totalPages = searchResult.totalPages
hasMorePages = currentPage < totalPages
currentQuery = query
currentPage = 1
allDrugs.clear()
isLoadingMore = false
hasMorePages = true
_searchState.value = SearchState.Loading(isNew = true)
fetchPage(isNewSearch = true)
}
}
allDrugs.addAll(searchResult.drugs)
isLoadingMore = false
private suspend fun fetchPage(isNewSearch: Boolean) {
val result = searchDrugsUseCase(currentQuery, currentPage)
SearchState.Success(
drugs = allDrugs.toList(),
currentPage = searchResult.currentPage,
totalPages = searchResult.totalPages,
isLoadingMore = false,
hasMorePages = hasMorePages
)
}
else -> {
isLoadingMore = false
SearchState.Error(result.exceptionOrNull().toUserMessage())
_searchState.value = when {
result.isSuccess -> {
val searchResult = result.getOrNull()!!
totalPages = searchResult.totalPages
hasMorePages = currentPage < totalPages
allDrugs.addAll(searchResult.drugs)
isLoadingMore = false
// فقط یک «جستجوی جدیدِ» موفق از سهمیه‌ی رایگان کم می‌کند.
if (isNewSearch && !session.isSubscribed) {
session.incrementFreeSearchCount()
}
SearchState.Success(
drugs = allDrugs.toList(),
currentPage = searchResult.currentPage,
totalPages = searchResult.totalPages,
isLoadingMore = false,
hasMorePages = hasMorePages
)
}
else -> {
isLoadingMore = false
SearchState.Error(result.exceptionOrNull().toUserMessage())
}
}
}
/**
* سهمیهی جستجو را بررسی میکند. در صورت اتمام سهمیهی رایگان، وضعیت مناسب
* (نیاز به ورود / نیاز به اشتراک) منتشر شده و false برمیگردد.
*/
private suspend fun ensureCanSearch(): Boolean {
if (session.isSubscribed) return true
if (session.freeSearchCount.value < Config.FREE_SEARCH_LIMIT) return true
if (!session.isLoggedIn) {
_searchState.value = SearchState.RequireLogin
return false
}
// کاربر واردشده ولی کش اشتراک ندارد → بررسی مجدد با سرور.
if (authRepository.refreshStatus().getOrDefault(false)) return true
_searchState.value = SearchState.RequireSubscription
return false
}
fun loadNextPage() {
if (hasMorePages && !isLoadingMore && currentPage < totalPages) {
currentPage++
@@ -106,15 +155,26 @@ class SearchViewModel(
searchDrugs(currentQuery, isNewSearch = true)
}
}
private fun remaining(count: Int): Int =
(Config.FREE_SEARCH_LIMIT - count).coerceAtLeast(0)
}
sealed class SearchState {
object Idle : SearchState()
/** سهمیه‌ی رایگان تمام شده و کاربر باید وارد شود. */
object RequireLogin : SearchState()
/** کاربر واردشده ولی اشتراک فعال ندارد. */
object RequireSubscription : SearchState()
data class Loading(val isNew: Boolean) : SearchState()
data class LoadingMore(
val currentItems: List<DrugSearchResult>,
val currentPage: Int
) : SearchState()
data class Success(
val drugs: List<DrugSearchResult>,
val currentPage: Int,
@@ -122,5 +182,6 @@ sealed class SearchState {
val isLoadingMore: Boolean = false,
val hasMorePages: Boolean = true
) : SearchState()
data class Error(val message: String) : SearchState()
}
}
@@ -3,4 +3,13 @@ package com.approagency.pharmacy.utils
object Config {
const val BASE_URL = "https://drug.approagency.ir/api/"
const val Darro_Url = "https://www.darooyab.ir/"
/** بک‌اند اشتراک/احراز هویت آپرواجنسی (لاگین، وضعیت، محصولات، خرید). */
const val AUTH_BASE_URL = "https://api.approagency.ir/api/"
/** نام پکیج این اپ در سامانه‌ی آپرواجنسی (برای status / products / subscribe). */
const val PACKAGE_NAME = "com.approagency.pharmacy"
/** تعداد جستجوی رایگان دارویاب پیش از نیاز به ورود و اشتراک. */
const val FREE_SEARCH_LIMIT = 2
}