feat: login and otp added
This commit is contained in:
@@ -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()
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.approagency.pharmacy.presentation.account
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* کنترلر سراسریِ نمایش شیتِ حساب (ورود/اشتراک).
|
||||
*
|
||||
* تکنمونه در Koin؛ هم نوار بالای اپ و هم گیتِ جستجو میتوانند با [show] آن را
|
||||
* باز کنند و [MainContainer] با مشاهدهی [visible] شیت را نمایش میدهد.
|
||||
*/
|
||||
class AccountSheetController {
|
||||
private val _visible = MutableStateFlow(false)
|
||||
val visible: StateFlow<Boolean> = _visible.asStateFlow()
|
||||
|
||||
fun show() { _visible.value = true }
|
||||
fun hide() { _visible.value = false }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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
|
||||
)
|
||||
+92
-31
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user