diff --git a/app/src/main/java/com/approagency/pharmacy/MainActivity.kt b/app/src/main/java/com/approagency/pharmacy/MainActivity.kt index 07993d9..f185d17 100644 --- a/app/src/main/java/com/approagency/pharmacy/MainActivity.kt +++ b/app/src/main/java/com/approagency/pharmacy/MainActivity.kt @@ -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() } + } + } } diff --git a/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt b/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt new file mode 100644 index 0000000..dbaf786 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/data/billing/StubPurchaseGateway.kt @@ -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 = + Result.failure(IllegalStateException("درگاه پرداخت روی این نسخه فعال نیست.")) +} diff --git a/app/src/main/java/com/approagency/pharmacy/data/dto/AuthModels.kt b/app/src/main/java/com/approagency/pharmacy/data/dto/AuthModels.kt new file mode 100644 index 0000000..daa85cf --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/data/dto/AuthModels.kt @@ -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? = 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 +) diff --git a/app/src/main/java/com/approagency/pharmacy/data/local/SessionManager.kt b/app/src/main/java/com/approagency/pharmacy/data/local/SessionManager.kt new file mode 100644 index 0000000..3504f73 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/data/local/SessionManager.kt @@ -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 = _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 = _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" + } +} diff --git a/app/src/main/java/com/approagency/pharmacy/data/remote/ApproApiService.kt b/app/src/main/java/com/approagency/pharmacy/data/remote/ApproApiService.kt new file mode 100644 index 0000000..6055220 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/data/remote/ApproApiService.kt @@ -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 + + /** ثبت خرید اشتراک پس از پرداخت موفق در درگاه (مایکت/بازار). */ + @PUT("package-names/{name}/products/{product_id}/subscribe") + suspend fun subscribe( + @Path("name") packageName: String, + @Path("product_id") productId: Int, + @Body body: SubscribeRequest + ): SubscribeResponse +} diff --git a/app/src/main/java/com/approagency/pharmacy/data/remote/AuthInterceptor.kt b/app/src/main/java/com/approagency/pharmacy/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..96655ec --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/data/remote/AuthInterceptor.kt @@ -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 ` در صورت وجود توکن + * + * این اینترسپتور فقط روی کلاینت اختصاصیِ آپرواجنسی نصب می‌شود؛ بنابراین توکن + * هرگز به دارویاب یا بک‌اند جستجوی دارو ارسال نمی‌شود. + */ +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()) + } +} diff --git a/app/src/main/java/com/approagency/pharmacy/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/approagency/pharmacy/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 0000000..03299ca --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/data/repository/AuthRepositoryImpl.kt @@ -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 = 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 = + 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 = 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 = 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> = + 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 = 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) + } + } +} diff --git a/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt b/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt index c0299ce..f001aaa 100644 --- a/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt +++ b/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt @@ -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().testGroupDao() } single { get().testItemDao() } @@ -89,6 +102,35 @@ val appModule= module { retrofit.create(DarooyabApiService::class.java) } + // ========== Retrofit for APPROAGENCY auth/subscription backend ========== + // کلاینت مجزا با AuthInterceptor تا توکن فقط به این بک‌اند ارسال شود. + single(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 { + val retrofit: Retrofit = get(authRetrofitQualifier) + retrofit.create(ApproApiService::class.java) + } + + single { AuthRepositoryImpl(get(), get()) } + + // درگاه پرداخت: روی main استاب؛ شاخه‌های myket/bazar جایگزین می‌کنند. + single { 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 ()) diff --git a/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt b/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt new file mode 100644 index 0000000..9ad82ea --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/domain/billing/PurchaseGateway.kt @@ -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 +} + +/** نتیجه‌ی یک خرید موفق در فروشگاه. */ +data class PurchaseResult( + val purchaseToken: String, + val orderId: String? = null +) diff --git a/app/src/main/java/com/approagency/pharmacy/domain/model/Subscription.kt b/app/src/main/java/com/approagency/pharmacy/domain/model/Subscription.kt new file mode 100644 index 0000000..5f9b6b7 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/domain/model/Subscription.kt @@ -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? +) diff --git a/app/src/main/java/com/approagency/pharmacy/domain/repository/AuthRepository.kt b/app/src/main/java/com/approagency/pharmacy/domain/repository/AuthRepository.kt new file mode 100644 index 0000000..45644f7 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/domain/repository/AuthRepository.kt @@ -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 + + /** تأیید [code] برای [mobile]؛ در صورت موفقیت توکن ذخیره می‌شود. */ + suspend fun checkOtp(mobile: String, code: String): Result + + /** خروج از حساب و پاک‌سازی نشست. */ + suspend fun logout(): Result + + /** + * خواندن `/status` و به‌روزرسانی [SessionManager.account]. + * مقدار بازگشتی نشان می‌دهد کاربر اشتراک فعال دارد یا نه. + */ + suspend fun refreshStatus(): Result + + /** فهرست محصولات اشتراک قابل خرید. */ + suspend fun getProducts(): Result> + + /** ثبت خرید اشتراک پس از پرداخت موفق در درگاه. */ + suspend fun subscribe(productId: Int, purchaseToken: String, gateway: String): Result +} diff --git a/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt b/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt index daccaf6..484f3b4 100644 --- a/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt +++ b/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt @@ -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() } } -} \ No newline at end of file + + 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) + } + } + } + ) +} diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt b/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt new file mode 100644 index 0000000..81dc082 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt @@ -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() diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheetController.kt b/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheetController.kt new file mode 100644 index 0000000..dac004e --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheetController.kt @@ -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 = _visible.asStateFlow() + + fun show() { _visible.value = true } + fun hide() { _visible.value = false } +} diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/screens/SearchScreen.kt b/app/src/main/java/com/approagency/pharmacy/presentation/screens/SearchScreen.kt index 98078ce..80514ba 100644 --- a/app/src/main/java/com/approagency/pharmacy/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/approagency/pharmacy/presentation/screens/SearchScreen.kt @@ -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 + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt new file mode 100644 index 0000000..fcb614c --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt @@ -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 = 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 = _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 = emptyList(), + val purchasingProductId: Int? = null, + val purchaseSuccess: Boolean = false +) diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/SearchViewModel.kt b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/SearchViewModel.kt index 4f1e312..a6ba06c 100644 --- a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/SearchViewModel.kt +++ b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/SearchViewModel.kt @@ -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.Idle) val searchState: StateFlow = _searchState.asStateFlow() + + /** تعداد جستجوی رایگانِ باقی‌مانده برای نمایش به کاربر. */ + val remainingFreeSearches: StateFlow = 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, val currentPage: Int ) : SearchState() + data class Success( val drugs: List, 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() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/approagency/pharmacy/utils/Config.kt b/app/src/main/java/com/approagency/pharmacy/utils/Config.kt index afe5aa8..163a516 100644 --- a/app/src/main/java/com/approagency/pharmacy/utils/Config.kt +++ b/app/src/main/java/com/approagency/pharmacy/utils/Config.kt @@ -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 } \ No newline at end of file