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.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.navigation.AppNavGraph
|
||||||
import com.approagency.pharmacy.ui.theme.DrugTheme
|
import com.approagency.pharmacy.ui.theme.DrugTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val session: SessionManager by inject()
|
||||||
|
private val authRepository: AuthRepository by inject()
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
DrugTheme {
|
DrugTheme {
|
||||||
|
AppNavGraph()
|
||||||
AppNavGraph(
|
|
||||||
// navController = navController,
|
|
||||||
// modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
|
||||||
// Scaffold(modifier = Modifier.fillMaxSize(),
|
|
||||||
// ) { innerPadding ->
|
|
||||||
// AppNavGraph(
|
|
||||||
//// navController = navController,
|
|
||||||
//// modifier = Modifier.padding(innerPadding)
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* هنگام شروع اپ و هر بار بازگشت به اپ (مثلاً پس از بازگشت از درگاه پرداختِ
|
||||||
|
* مایکت/بازار) وضعیت اشتراک از سرور تازهسازی میشود تا کل اپ بهروز بماند.
|
||||||
|
*/
|
||||||
|
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
|
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.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.DarooyabApiService
|
||||||
import com.approagency.pharmacy.data.remote.DrugApiService
|
import com.approagency.pharmacy.data.remote.DrugApiService
|
||||||
import com.approagency.pharmacy.data.remote.DrugDetailParser
|
import com.approagency.pharmacy.data.remote.DrugDetailParser
|
||||||
import com.approagency.pharmacy.data.remote.DrugHtmlParser
|
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.DrugRepositoryImpl
|
||||||
import com.approagency.pharmacy.data.repository.LabRepositoryImpl
|
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.repository.DrugRepository
|
||||||
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
|
import com.approagency.pharmacy.domain.usecase.DrugDetailYabUseCase
|
||||||
import com.approagency.pharmacy.domain.usecase.GetDarmanUseCase
|
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.GetTestItemByGroupId
|
||||||
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
|
import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase
|
||||||
import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase
|
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.DrugDetailViewModel
|
||||||
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
|
import com.approagency.pharmacy.presentation.viewModel.HomeViewModel
|
||||||
import com.approagency.pharmacy.presentation.viewModel.LabViewModel
|
import com.approagency.pharmacy.presentation.viewModel.LabViewModel
|
||||||
@@ -39,10 +48,14 @@ import java.net.CookiePolicy
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
val jsonRetrofitQualifier = qualifier("jsonRetrofit")
|
val jsonRetrofitQualifier = qualifier("jsonRetrofit")
|
||||||
val scalarRetrofitQualifier = qualifier("scalarRetrofit")
|
val scalarRetrofitQualifier = qualifier("scalarRetrofit")
|
||||||
|
val authRetrofitQualifier = qualifier("authRetrofit")
|
||||||
val appModule= module {
|
val appModule= module {
|
||||||
|
|
||||||
single { LabDatabase.getInstance(androidContext()) }
|
single { LabDatabase.getInstance(androidContext()) }
|
||||||
|
|
||||||
|
// نشست کاربر روی دستگاه (توکن، موبایل، سهمیهی جستجوی رایگان، وضعیت اشتراک)
|
||||||
|
single { SessionManager(androidContext()) }
|
||||||
|
|
||||||
single { get<LabDatabase>().testGroupDao() }
|
single { get<LabDatabase>().testGroupDao() }
|
||||||
single { get<LabDatabase>().testItemDao() }
|
single { get<LabDatabase>().testItemDao() }
|
||||||
|
|
||||||
@@ -89,6 +102,35 @@ val appModule= module {
|
|||||||
retrofit.create(DarooyabApiService::class.java)
|
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 { DrugHtmlParser() }
|
||||||
factory { DrugDetailParser() }
|
factory { DrugDetailParser() }
|
||||||
@@ -150,7 +192,14 @@ val appModule= module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
SearchViewModel(get())
|
SearchViewModel(get(), get(), get())
|
||||||
|
}
|
||||||
|
|
||||||
|
// کنترلر سراسری نمایش شیت حساب
|
||||||
|
single { AccountSheetController() }
|
||||||
|
|
||||||
|
viewModel {
|
||||||
|
AccountViewModel(get(), get(), get())
|
||||||
}
|
}
|
||||||
viewModel {
|
viewModel {
|
||||||
PharmacyViewModel(get() , get ())
|
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
|
package com.approagency.pharmacy.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.navigation.NavHostController
|
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
|
@Composable
|
||||||
fun MainContainer(
|
fun MainContainer(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val session: SessionManager = koinInject()
|
||||||
|
val sheetController: AccountSheetController = koinInject()
|
||||||
|
|
||||||
|
val account by session.account.collectAsState()
|
||||||
|
val sheetVisible by sheetController.visible.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
topBar = {
|
||||||
BottomBar(navController)
|
AccountAppBar(
|
||||||
}
|
account = account,
|
||||||
|
onAccountClick = { sheetController.show() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = { BottomBar(navController) }
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
||||||
Box(modifier = Modifier.padding(padding)) {
|
Box(modifier = Modifier.padding(padding)) {
|
||||||
content()
|
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
|
package com.approagency.pharmacy.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -22,9 +24,12 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
import com.approagency.pharmacy.data.local.SessionManager
|
||||||
import com.approagency.pharmacy.domain.model.DrugSearchResult
|
import com.approagency.pharmacy.domain.model.DrugSearchResult
|
||||||
import com.approagency.pharmacy.navigation.Screen
|
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.CustomTextFilled
|
||||||
import com.approagency.pharmacy.presentation.common.EmptySearchState
|
import com.approagency.pharmacy.presentation.common.EmptySearchState
|
||||||
import com.approagency.pharmacy.presentation.common.EndOfListIndicator
|
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.LocalDime
|
||||||
import com.vada.caller.ui.theme.dime
|
import com.vada.caller.ui.theme.dime
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -54,6 +60,10 @@ fun SearchScreen(
|
|||||||
) {
|
) {
|
||||||
val dime = LocalDime.current
|
val dime = LocalDime.current
|
||||||
val keyboardController = LocalSoftwareKeyboardController.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 searchText = viewModel.searchText
|
||||||
val state by viewModel.searchState.collectAsState()
|
val state by viewModel.searchState.collectAsState()
|
||||||
val lazyListState = rememberLazyListState()
|
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))
|
Spacer(modifier = Modifier.height(MaterialTheme.dime.md))
|
||||||
|
|
||||||
|
// PrimaryButton(
|
||||||
|
// onClick = {
|
||||||
|
// session.testForIncrease()
|
||||||
|
// } ,
|
||||||
|
// text = "",
|
||||||
|
// isLoading = false
|
||||||
|
// )
|
||||||
|
|
||||||
if (showPharmacyBottomSheet && selectedDrugForPharmacy != null) {
|
if (showPharmacyBottomSheet && selectedDrugForPharmacy != null) {
|
||||||
// درخواست بر اساس نوع صفحه فرق میکند:
|
// درخواست بر اساس نوع صفحه فرق میکند:
|
||||||
// - صفحات برند (/B-...): شناسه باید بهعنوان brandIrc ارسال شود
|
// - صفحات برند (/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 -> {
|
SearchState.Idle -> {
|
||||||
EmptySearchState(onRetry = {})
|
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
|
||||||
|
)
|
||||||
+72
-11
@@ -5,26 +5,45 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.approagency.pharmacy.data.local.SessionManager
|
||||||
import com.approagency.pharmacy.domain.model.DrugSearchResult
|
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.domain.usecase.SearchDrugsYabUseCase
|
||||||
|
import com.approagency.pharmacy.utils.Config
|
||||||
import com.approagency.pharmacy.utils.toUserMessage
|
import com.approagency.pharmacy.utils.toUserMessage
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SearchViewModel(
|
class SearchViewModel(
|
||||||
private val searchDrugsUseCase: SearchDrugsYabUseCase
|
private val searchDrugsUseCase: SearchDrugsYabUseCase,
|
||||||
|
private val session: SessionManager,
|
||||||
|
private val authRepository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _searchState = MutableStateFlow<SearchState>(SearchState.Idle)
|
private val _searchState = MutableStateFlow<SearchState>(SearchState.Idle)
|
||||||
val searchState: StateFlow<SearchState> = _searchState.asStateFlow()
|
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("")
|
var searchText by mutableStateOf("")
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun updateSearchText(value: String) {
|
fun updateSearchText(value: String) {
|
||||||
searchText = value
|
searchText = value
|
||||||
}
|
}
|
||||||
|
|
||||||
private var currentQuery = ""
|
private var currentQuery = ""
|
||||||
private var currentPage = 1
|
private var currentPage = 1
|
||||||
private var totalPages = 1
|
private var totalPages = 1
|
||||||
@@ -38,25 +57,32 @@ class SearchViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNewSearch) {
|
if (!isNewSearch) {
|
||||||
// Reset everything for new search
|
// صفحهبندیِ همان جستجو نیازی به بررسی مجدد سهمیه ندارد.
|
||||||
currentQuery = query
|
|
||||||
currentPage = 1
|
|
||||||
allDrugs.clear()
|
|
||||||
isLoadingMore = false
|
|
||||||
hasMorePages = true
|
|
||||||
_searchState.value = SearchState.Loading(isNew = true)
|
|
||||||
} else {
|
|
||||||
// Don't load if already loading or no more pages
|
|
||||||
if (isLoadingMore || !hasMorePages) return
|
if (isLoadingMore || !hasMorePages) return
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
_searchState.value = SearchState.LoadingMore(
|
_searchState.value = SearchState.LoadingMore(
|
||||||
currentItems = allDrugs.toList(),
|
currentItems = allDrugs.toList(),
|
||||||
currentPage = currentPage
|
currentPage = currentPage
|
||||||
)
|
)
|
||||||
|
viewModelScope.launch { fetchPage(isNewSearch = false) }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
if (!ensureCanSearch()) return@launch
|
||||||
|
|
||||||
|
currentQuery = query
|
||||||
|
currentPage = 1
|
||||||
|
allDrugs.clear()
|
||||||
|
isLoadingMore = false
|
||||||
|
hasMorePages = true
|
||||||
|
_searchState.value = SearchState.Loading(isNew = true)
|
||||||
|
fetchPage(isNewSearch = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchPage(isNewSearch: Boolean) {
|
||||||
val result = searchDrugsUseCase(currentQuery, currentPage)
|
val result = searchDrugsUseCase(currentQuery, currentPage)
|
||||||
|
|
||||||
_searchState.value = when {
|
_searchState.value = when {
|
||||||
@@ -68,6 +94,11 @@ class SearchViewModel(
|
|||||||
allDrugs.addAll(searchResult.drugs)
|
allDrugs.addAll(searchResult.drugs)
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
|
|
||||||
|
// فقط یک «جستجوی جدیدِ» موفق از سهمیهی رایگان کم میکند.
|
||||||
|
if (isNewSearch && !session.isSubscribed) {
|
||||||
|
session.incrementFreeSearchCount()
|
||||||
|
}
|
||||||
|
|
||||||
SearchState.Success(
|
SearchState.Success(
|
||||||
drugs = allDrugs.toList(),
|
drugs = allDrugs.toList(),
|
||||||
currentPage = searchResult.currentPage,
|
currentPage = searchResult.currentPage,
|
||||||
@@ -82,6 +113,24 @@ class SearchViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* سهمیهی جستجو را بررسی میکند. در صورت اتمام سهمیهی رایگان، وضعیت مناسب
|
||||||
|
* (نیاز به ورود / نیاز به اشتراک) منتشر شده و 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() {
|
fun loadNextPage() {
|
||||||
@@ -106,15 +155,26 @@ class SearchViewModel(
|
|||||||
searchDrugs(currentQuery, isNewSearch = true)
|
searchDrugs(currentQuery, isNewSearch = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun remaining(count: Int): Int =
|
||||||
|
(Config.FREE_SEARCH_LIMIT - count).coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class SearchState {
|
sealed class SearchState {
|
||||||
object Idle : SearchState()
|
object Idle : SearchState()
|
||||||
|
|
||||||
|
/** سهمیهی رایگان تمام شده و کاربر باید وارد شود. */
|
||||||
|
object RequireLogin : SearchState()
|
||||||
|
|
||||||
|
/** کاربر واردشده ولی اشتراک فعال ندارد. */
|
||||||
|
object RequireSubscription : SearchState()
|
||||||
|
|
||||||
data class Loading(val isNew: Boolean) : SearchState()
|
data class Loading(val isNew: Boolean) : SearchState()
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
val currentItems: List<DrugSearchResult>,
|
val currentItems: List<DrugSearchResult>,
|
||||||
val currentPage: Int
|
val currentPage: Int
|
||||||
) : SearchState()
|
) : SearchState()
|
||||||
|
|
||||||
data class Success(
|
data class Success(
|
||||||
val drugs: List<DrugSearchResult>,
|
val drugs: List<DrugSearchResult>,
|
||||||
val currentPage: Int,
|
val currentPage: Int,
|
||||||
@@ -122,5 +182,6 @@ sealed class SearchState {
|
|||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val hasMorePages: Boolean = true
|
val hasMorePages: Boolean = true
|
||||||
) : SearchState()
|
) : SearchState()
|
||||||
|
|
||||||
data class Error(val message: String) : SearchState()
|
data class Error(val message: String) : SearchState()
|
||||||
}
|
}
|
||||||
@@ -3,4 +3,13 @@ package com.approagency.pharmacy.utils
|
|||||||
object Config {
|
object Config {
|
||||||
const val BASE_URL = "https://drug.approagency.ir/api/"
|
const val BASE_URL = "https://drug.approagency.ir/api/"
|
||||||
const val Darro_Url = "https://www.darooyab.ir/"
|
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