Compare commits
2
Commits
c3496e3de4
...
675123309a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675123309a | ||
|
|
fdf15913a9 |
@@ -77,6 +77,9 @@ dependencies {
|
|||||||
// Location services
|
// Location services
|
||||||
implementation(libs.location.services)
|
implementation(libs.location.services)
|
||||||
|
|
||||||
|
// SMS User Consent API (OTP auto-fill)
|
||||||
|
implementation(libs.play.services.auth.api.phone)
|
||||||
|
|
||||||
// Koin for DI
|
// Koin for DI
|
||||||
implementation(libs.koin.android)
|
implementation(libs.koin.android)
|
||||||
implementation(libs.koin.androidx.compose.v410)
|
implementation(libs.koin.androidx.compose.v410)
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
package com.approagency.pharmacy
|
package com.approagency.pharmacy
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.approagency.pharmacy.data.local.SessionManager
|
import com.approagency.pharmacy.data.local.SessionManager
|
||||||
import com.approagency.pharmacy.domain.repository.AuthRepository
|
import com.approagency.pharmacy.domain.repository.AuthRepository
|
||||||
import com.approagency.pharmacy.navigation.AppNavGraph
|
import com.approagency.pharmacy.navigation.AppNavGraph
|
||||||
|
import com.approagency.pharmacy.presentation.account.OtpAutoFillBus
|
||||||
import com.approagency.pharmacy.ui.theme.DrugTheme
|
import com.approagency.pharmacy.ui.theme.DrugTheme
|
||||||
|
import com.google.android.gms.auth.api.phone.SmsRetriever
|
||||||
|
import com.google.android.gms.common.api.CommonStatusCodes
|
||||||
|
import com.google.android.gms.common.api.Status
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
|
|
||||||
@@ -17,13 +31,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val session: SessionManager by inject()
|
private val session: SessionManager by inject()
|
||||||
private val authRepository: AuthRepository by inject()
|
private val authRepository: AuthRepository by inject()
|
||||||
|
private val otpAutoFillBus: OtpAutoFillBus by inject()
|
||||||
|
|
||||||
|
private var otpSmsReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
// نتیجهی دیالوگ رضایتِ خواندن پیامک: کد ۵ رقمی استخراج و به شیت ورود تحویل میشود.
|
||||||
|
private val smsConsentLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||||
|
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE)
|
||||||
|
val code = Regex("\\b\\d{5}\\b").find(message ?: "")?.value
|
||||||
|
code?.let { otpAutoFillBus.submit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
DrugTheme {
|
val themeMode by session.themeMode.collectAsState()
|
||||||
|
DrugTheme(themeMode = themeMode) {
|
||||||
AppNavGraph()
|
AppNavGraph()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,4 +67,49 @@ class MainActivity : ComponentActivity() {
|
|||||||
lifecycleScope.launch { authRepository.refreshStatus() }
|
lifecycleScope.launch { authRepository.refreshStatus() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- خودکارپُرکُنِ کد پیامک (SMS User Consent API) ----------
|
||||||
|
|
||||||
|
/** آغاز گوشدادن به پیامکِ کد؛ کدِ یافتشده از طریق [OtpAutoFillBus] تحویل میشود. */
|
||||||
|
fun startOtpAutofill() {
|
||||||
|
SmsRetriever.getClient(this).startSmsUserConsent(null)
|
||||||
|
if (otpSmsReceiver != null) return
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context?, received: Intent?) {
|
||||||
|
if (received?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
|
||||||
|
val extras = received.extras ?: return
|
||||||
|
val status = extras.get(SmsRetriever.EXTRA_STATUS) as? Status ?: return
|
||||||
|
if (status.statusCode != CommonStatusCodes.SUCCESS) return
|
||||||
|
val consentIntent: Intent? =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
extras.getParcelable(SmsRetriever.EXTRA_CONSENT_INTENT)
|
||||||
|
}
|
||||||
|
consentIntent?.let { runCatching { smsConsentLauncher.launch(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otpSmsReceiver = receiver
|
||||||
|
// این برودکست را Google Play services با مجوز SEND میفرستد؛ پس گیرنده باید
|
||||||
|
// همان مجوز را الزام کند تا پیامک به آن تحویل شود.
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
receiver,
|
||||||
|
IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION),
|
||||||
|
SmsRetriever.SEND_PERMISSION,
|
||||||
|
null,
|
||||||
|
ContextCompat.RECEIVER_EXPORTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopOtpAutofill() {
|
||||||
|
otpSmsReceiver?.let { runCatching { unregisterReceiver(it) } }
|
||||||
|
otpSmsReceiver = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
stopOtpAutofill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,21 @@ data class ProductDto(
|
|||||||
@SerializedName("price") val price: Long? = null,
|
@SerializedName("price") val price: Long? = null,
|
||||||
@SerializedName("type") val type: Int? = null,
|
@SerializedName("type") val type: Int? = null,
|
||||||
@SerializedName("uuid") val uuid: String? = null,
|
@SerializedName("uuid") val uuid: String? = null,
|
||||||
@SerializedName("descriptions") val descriptions: String? = null
|
@SerializedName("descriptions") val descriptions: String? = null,
|
||||||
|
@SerializedName("expires_at") val expiresAt: String? = null,
|
||||||
|
@SerializedName("expire_at") val expireAt: String? = null,
|
||||||
|
@SerializedName("pivot") val pivot: ProductPivot? = null
|
||||||
|
) {
|
||||||
|
/** تاریخ انقضای اشتراکِ این کاربر؛ از pivot یا فیلدهای مستقیمِ محصول. */
|
||||||
|
val resolvedExpireAt: String?
|
||||||
|
get() = pivot?.expiresAt ?: pivot?.expireAt ?: expiresAt ?: expireAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** اطلاعات رابطهی کاربر-محصول (شامل تاریخ انقضای خرید). */
|
||||||
|
data class ProductPivot(
|
||||||
|
@SerializedName("expires_at") val expiresAt: String? = null,
|
||||||
|
@SerializedName("expire_at") val expireAt: String? = null,
|
||||||
|
@SerializedName("created_at") val createdAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +69,9 @@ data class StatusDto(
|
|||||||
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
|
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
|
||||||
val subscriptionTitle: String? get() = products?.firstOrNull()?.title
|
val subscriptionTitle: String? get() = products?.firstOrNull()?.title
|
||||||
|
|
||||||
|
/** تاریخ انقضای اشتراک جاری. */
|
||||||
|
val subscriptionExpireAt: String? get() = products?.firstOrNull()?.resolvedExpireAt
|
||||||
|
|
||||||
val displayName: String? get() = fullName?.takeIf { it.isNotBlank() }
|
val displayName: String? get() = fullName?.takeIf { it.isNotBlank() }
|
||||||
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
|
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.approagency.pharmacy.data.local
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import com.approagency.pharmacy.ui.theme.ThemeMode
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -12,7 +13,8 @@ data class AccountState(
|
|||||||
val mobile: String? = null,
|
val mobile: String? = null,
|
||||||
val displayName: String? = null,
|
val displayName: String? = null,
|
||||||
val isSubscribed: Boolean = false,
|
val isSubscribed: Boolean = false,
|
||||||
val subscriptionTitle: String? = null
|
val subscriptionTitle: String? = null,
|
||||||
|
val subscriptionExpireAt: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,13 +77,15 @@ class SessionManager(context: Context) {
|
|||||||
mobile: String?,
|
mobile: String?,
|
||||||
displayName: String?,
|
displayName: String?,
|
||||||
isSubscribed: Boolean,
|
isSubscribed: Boolean,
|
||||||
subscriptionTitle: String?
|
subscriptionTitle: String?,
|
||||||
|
subscriptionExpireAt: String?
|
||||||
) {
|
) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString(KEY_MOBILE, mobile)
|
.putString(KEY_MOBILE, mobile)
|
||||||
.putString(KEY_DISPLAY_NAME, displayName)
|
.putString(KEY_DISPLAY_NAME, displayName)
|
||||||
.putBoolean(KEY_IS_SUBSCRIBED, isSubscribed)
|
.putBoolean(KEY_IS_SUBSCRIBED, isSubscribed)
|
||||||
.putString(KEY_SUB_TITLE, subscriptionTitle)
|
.putString(KEY_SUB_TITLE, subscriptionTitle)
|
||||||
|
.putString(KEY_SUB_EXPIRE, subscriptionExpireAt)
|
||||||
.apply()
|
.apply()
|
||||||
publishAccount()
|
publishAccount()
|
||||||
}
|
}
|
||||||
@@ -101,6 +105,7 @@ class SessionManager(context: Context) {
|
|||||||
.remove(KEY_DISPLAY_NAME)
|
.remove(KEY_DISPLAY_NAME)
|
||||||
.remove(KEY_IS_SUBSCRIBED)
|
.remove(KEY_IS_SUBSCRIBED)
|
||||||
.remove(KEY_SUB_TITLE)
|
.remove(KEY_SUB_TITLE)
|
||||||
|
.remove(KEY_SUB_EXPIRE)
|
||||||
.apply()
|
.apply()
|
||||||
publishAccount()
|
publishAccount()
|
||||||
}
|
}
|
||||||
@@ -109,12 +114,27 @@ class SessionManager(context: Context) {
|
|||||||
_account.value = readAccount()
|
_account.value = readAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- حالت نمایش (تم) ----------
|
||||||
|
|
||||||
|
private val _themeMode = MutableStateFlow(readThemeMode())
|
||||||
|
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
|
||||||
|
|
||||||
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
|
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
|
||||||
|
_themeMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readThemeMode(): ThemeMode =
|
||||||
|
runCatching { ThemeMode.valueOf(prefs.getString(KEY_THEME_MODE, null) ?: ThemeMode.SYSTEM.name) }
|
||||||
|
.getOrDefault(ThemeMode.SYSTEM)
|
||||||
|
|
||||||
private fun readAccount() = AccountState(
|
private fun readAccount() = AccountState(
|
||||||
isLoggedIn = !token.isNullOrBlank(),
|
isLoggedIn = !token.isNullOrBlank(),
|
||||||
mobile = prefs.getString(KEY_MOBILE, null),
|
mobile = prefs.getString(KEY_MOBILE, null),
|
||||||
displayName = prefs.getString(KEY_DISPLAY_NAME, null),
|
displayName = prefs.getString(KEY_DISPLAY_NAME, null),
|
||||||
isSubscribed = prefs.getBoolean(KEY_IS_SUBSCRIBED, false),
|
isSubscribed = prefs.getBoolean(KEY_IS_SUBSCRIBED, false),
|
||||||
subscriptionTitle = prefs.getString(KEY_SUB_TITLE, null)
|
subscriptionTitle = prefs.getString(KEY_SUB_TITLE, null),
|
||||||
|
subscriptionExpireAt = prefs.getString(KEY_SUB_EXPIRE, null)
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -125,5 +145,7 @@ class SessionManager(context: Context) {
|
|||||||
const val KEY_FREE_SEARCH_COUNT = "free_search_count"
|
const val KEY_FREE_SEARCH_COUNT = "free_search_count"
|
||||||
const val KEY_IS_SUBSCRIBED = "is_subscribed"
|
const val KEY_IS_SUBSCRIBED = "is_subscribed"
|
||||||
const val KEY_SUB_TITLE = "subscription_title"
|
const val KEY_SUB_TITLE = "subscription_title"
|
||||||
|
const val KEY_SUB_EXPIRE = "subscription_expire_at"
|
||||||
|
const val KEY_THEME_MODE = "theme_mode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ class AuthRepositoryImpl(
|
|||||||
mobile = dto.mobile ?: session.account.value.mobile,
|
mobile = dto.mobile ?: session.account.value.mobile,
|
||||||
displayName = dto.displayName,
|
displayName = dto.displayName,
|
||||||
isSubscribed = dto.isSubscribed,
|
isSubscribed = dto.isSubscribed,
|
||||||
subscriptionTitle = dto.subscriptionTitle
|
subscriptionTitle = dto.subscriptionTitle,
|
||||||
|
subscriptionExpireAt = dto.subscriptionExpireAt
|
||||||
)
|
)
|
||||||
return dto.isSubscribed
|
return dto.isSubscribed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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.account.AccountSheetController
|
||||||
|
import com.approagency.pharmacy.presentation.account.OtpAutoFillBus
|
||||||
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
|
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
|
||||||
@@ -198,6 +199,9 @@ val appModule= module {
|
|||||||
// کنترلر سراسری نمایش شیت حساب
|
// کنترلر سراسری نمایش شیت حساب
|
||||||
single { AccountSheetController() }
|
single { AccountSheetController() }
|
||||||
|
|
||||||
|
// پلِ تحویل کدِ خواندهشده از پیامک به شیت ورود
|
||||||
|
single { OtpAutoFillBus() }
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
AccountViewModel(get(), get(), get())
|
AccountViewModel(get(), get(), get())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,35 @@ 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.Icons
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ElevatedButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.AccountState
|
||||||
import com.approagency.pharmacy.data.local.SessionManager
|
import com.approagency.pharmacy.data.local.SessionManager
|
||||||
|
import com.approagency.pharmacy.domain.repository.AuthRepository
|
||||||
|
import com.approagency.pharmacy.presentation.account.AccountDrawer
|
||||||
import com.approagency.pharmacy.presentation.account.AccountSheet
|
import com.approagency.pharmacy.presentation.account.AccountSheet
|
||||||
import com.approagency.pharmacy.presentation.account.AccountSheetController
|
import com.approagency.pharmacy.presentation.account.AccountSheetController
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -33,21 +44,52 @@ fun MainContainer(
|
|||||||
) {
|
) {
|
||||||
val session: SessionManager = koinInject()
|
val session: SessionManager = koinInject()
|
||||||
val sheetController: AccountSheetController = koinInject()
|
val sheetController: AccountSheetController = koinInject()
|
||||||
|
val authRepository: AuthRepository = koinInject()
|
||||||
|
|
||||||
val account by session.account.collectAsState()
|
val account by session.account.collectAsState()
|
||||||
val sheetVisible by sheetController.visible.collectAsState()
|
val sheetVisible by sheetController.visible.collectAsState()
|
||||||
|
val themeMode by session.themeMode.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
topBar = {
|
val scope = rememberCoroutineScope()
|
||||||
AccountAppBar(
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val appVersion = remember {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
AccountDrawer(
|
||||||
account = account,
|
account = account,
|
||||||
onAccountClick = { sheetController.show() }
|
appVersion = appVersion,
|
||||||
|
themeMode = themeMode,
|
||||||
|
onThemeModeChange = { session.setThemeMode(it) },
|
||||||
|
onLogout = {
|
||||||
|
scope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
drawerState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
bottomBar = { BottomBar(navController) }
|
) {
|
||||||
) { padding ->
|
Scaffold(
|
||||||
Box(modifier = Modifier.padding(padding)) {
|
topBar = {
|
||||||
content()
|
AccountAppBar(
|
||||||
|
account = account,
|
||||||
|
onMenuClick = { scope.launch { drawerState.open() } },
|
||||||
|
onAccountClick = { sheetController.show() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = { BottomBar(navController) }
|
||||||
|
) { padding ->
|
||||||
|
Box(modifier = Modifier.padding(padding)) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +102,18 @@ fun MainContainer(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AccountAppBar(
|
private fun AccountAppBar(
|
||||||
account: AccountState,
|
account: AccountState,
|
||||||
|
onMenuClick: () -> Unit,
|
||||||
onAccountClick: () -> Unit
|
onAccountClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onMenuClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Menu,
|
||||||
|
contentDescription = "منو"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
title = {
|
title = {
|
||||||
if (account.isLoggedIn) {
|
if (account.isLoggedIn) {
|
||||||
Column {
|
Column {
|
||||||
@@ -84,18 +135,14 @@ private fun AccountAppBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (account.isLoggedIn) {
|
if (!account.isLoggedIn) {
|
||||||
IconButton(onClick = onAccountClick) {
|
ElevatedButton(onClick = onAccountClick) {
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.AccountCircle,
|
|
||||||
contentDescription = "حساب کاربری",
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TextButton(onClick = onAccountClick) {
|
|
||||||
Text("ورود", textAlign = TextAlign.Center)
|
Text("ورود", textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
|
}else if (!account.isSubscribed){
|
||||||
|
ElevatedButton(onClick = onAccountClick) {
|
||||||
|
Text("خرید اشتراک", textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package com.approagency.pharmacy.presentation.account
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.BrightnessAuto
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Phone
|
||||||
|
import androidx.compose.material.icons.filled.WorkspacePremium
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.approagency.pharmacy.data.local.AccountState
|
||||||
|
import com.approagency.pharmacy.ui.theme.ThemeMode
|
||||||
|
import com.vada.caller.ui.theme.dime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* کشوی کناری حساب کاربری با طراحی بهبودیافته:
|
||||||
|
* - سربرگ با آواتار، نام/موبایل و نشانِ وضعیت اشتراک
|
||||||
|
* - کارت اطلاعات (موبایل، اشتراک، تاریخ انقضا، نسخهی برنامه)
|
||||||
|
* - انتخاب حالت نمایش (سیستم/روشن/تاریک)
|
||||||
|
* - دکمهی خروج
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountDrawer(
|
||||||
|
account: AccountState,
|
||||||
|
appVersion: String,
|
||||||
|
themeMode: ThemeMode,
|
||||||
|
onThemeModeChange: (ThemeMode) -> Unit,
|
||||||
|
onLogout: () -> Unit
|
||||||
|
) {
|
||||||
|
ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surface) {
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = MaterialTheme.dime.lg, vertical = MaterialTheme.dime.xl)
|
||||||
|
) {
|
||||||
|
Header(account)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.xl))
|
||||||
|
|
||||||
|
// کارت اطلاعات حساب
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(MaterialTheme.dime.md)) {
|
||||||
|
InfoRow(
|
||||||
|
icon = Icons.Filled.Phone,
|
||||||
|
label = "شماره موبایل",
|
||||||
|
value = account.mobile ?: "-"
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
icon = Icons.Filled.WorkspacePremium,
|
||||||
|
label = "اشتراک",
|
||||||
|
value = account.subscriptionTitle ?: "بدون اشتراک",
|
||||||
|
highlight = account.isSubscribed
|
||||||
|
)
|
||||||
|
if (account.isSubscribed) {
|
||||||
|
InfoRow(
|
||||||
|
icon = Icons.Filled.CalendarMonth,
|
||||||
|
label = "تاریخ انقضا",
|
||||||
|
value = account.subscriptionExpireAt ?: "-"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InfoRow(
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
label = "نسخه برنامه",
|
||||||
|
value = appVersion.ifBlank { "-" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.xl))
|
||||||
|
|
||||||
|
// انتخاب حالت نمایش
|
||||||
|
Text(
|
||||||
|
text = "حالت نمایش",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.sm))
|
||||||
|
ThemeModeSelector(selected = themeMode, onSelect = onThemeModeChange)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (account.isLoggedIn) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onLogout,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(MaterialTheme.dime.sm))
|
||||||
|
Text(
|
||||||
|
text = "خروج از حساب",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(account: AccountState) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(MaterialTheme.dime.md))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = account.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
?: account.mobile
|
||||||
|
?: "کاربر مهمان",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(MaterialTheme.dime.xs))
|
||||||
|
SubscriptionBadge(isSubscribed = account.isSubscribed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SubscriptionBadge(isSubscribed: Boolean) {
|
||||||
|
val container = if (isSubscribed)
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
val content = if (isSubscribed)
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
Surface(color = container, shape = CircleShape) {
|
||||||
|
Text(
|
||||||
|
text = if (isSubscribed) "اشتراک فعال" else "بدون اشتراک",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = content,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
horizontal = MaterialTheme.dime.sm,
|
||||||
|
vertical = MaterialTheme.dime.xxs
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoRow(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
highlight: Boolean = false
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = MaterialTheme.dime.sm),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(MaterialTheme.dime.sm))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (highlight)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ThemeModeSelector(
|
||||||
|
selected: ThemeMode,
|
||||||
|
onSelect: (ThemeMode) -> Unit
|
||||||
|
) {
|
||||||
|
val options = listOf(
|
||||||
|
Triple(ThemeMode.SYSTEM, "سیستم", Icons.Filled.BrightnessAuto),
|
||||||
|
Triple(ThemeMode.LIGHT, "روشن", Icons.Filled.LightMode),
|
||||||
|
Triple(ThemeMode.DARK, "تاریک", Icons.Filled.DarkMode)
|
||||||
|
)
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
options.forEachIndexed { index, (mode, label, icon) ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = selected == mode,
|
||||||
|
onClick = { onSelect(mode) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(label, style = MaterialTheme.typography.labelMedium) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.approagency.pharmacy.presentation.account
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -20,6 +21,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -29,16 +31,20 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.approagency.pharmacy.MainActivity
|
||||||
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
import com.approagency.pharmacy.domain.model.SubscriptionProduct
|
||||||
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
|
import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet
|
||||||
import com.approagency.pharmacy.presentation.common.Loading
|
import com.approagency.pharmacy.presentation.common.Loading
|
||||||
|
import com.approagency.pharmacy.presentation.common.OtpTextField
|
||||||
import com.approagency.pharmacy.presentation.common.PrimaryButton
|
import com.approagency.pharmacy.presentation.common.PrimaryButton
|
||||||
import com.approagency.pharmacy.presentation.viewModel.AccountPhase
|
import com.approagency.pharmacy.presentation.viewModel.AccountPhase
|
||||||
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
|
import com.approagency.pharmacy.presentation.viewModel.AccountViewModel
|
||||||
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
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -49,6 +55,7 @@ fun AccountSheet(
|
|||||||
val ui by viewModel.ui.collectAsState()
|
val ui by viewModel.ui.collectAsState()
|
||||||
val account by viewModel.account.collectAsState()
|
val account by viewModel.account.collectAsState()
|
||||||
val activity = LocalContext.current as? Activity
|
val activity = LocalContext.current as? Activity
|
||||||
|
val otpAutoFillBus: OtpAutoFillBus = koinInject()
|
||||||
|
|
||||||
LaunchedEffect(Unit) { viewModel.onSheetOpened() }
|
LaunchedEffect(Unit) { viewModel.onSheetOpened() }
|
||||||
|
|
||||||
@@ -60,6 +67,22 @@ fun AccountSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// در مرحلهی کد، گوشدادن به پیامک را آغاز کن و با خروج متوقفش کن.
|
||||||
|
val isOtpStep = ui.phase == AccountPhase.EnterOtp
|
||||||
|
DisposableEffect(isOtpStep) {
|
||||||
|
val mainActivity = activity as? MainActivity
|
||||||
|
if (isOtpStep) mainActivity?.startOtpAutofill()
|
||||||
|
onDispose { mainActivity?.stopOtpAutofill() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// کدِ خواندهشده از پیامک را در فیلد بگذار و بهصورت خودکار تأیید کن.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
otpAutoFillBus.codes.collect { code ->
|
||||||
|
viewModel.updateOtp(code)
|
||||||
|
viewModel.verifyOtp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CustomModalBottomSheet(onDismiss = onDismiss) {
|
CustomModalBottomSheet(onDismiss = onDismiss) {
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||||
Column(
|
Column(
|
||||||
@@ -77,17 +100,20 @@ fun AccountSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||||
OutlinedTextField(
|
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||||
value = viewModel.mobile,
|
OutlinedTextField(
|
||||||
onValueChange = viewModel::updateMobile,
|
value = viewModel.mobile,
|
||||||
label = { Text("شماره موبایل") },
|
shape = MaterialTheme.shapes.large,
|
||||||
placeholder = { Text("09xxxxxxxxx") },
|
onValueChange = { viewModel.updateMobile(it) },
|
||||||
singleLine = true,
|
label = { Text("شماره موبایل" , textAlign = TextAlign.Right ) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
placeholder = { Text("09xxxxxxxxx") },
|
||||||
modifier = Modifier.fillMaxWidth()
|
singleLine = true,
|
||||||
)
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||||
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = viewModel::sendOtp)
|
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = { viewModel.sendOtp() })
|
||||||
}
|
}
|
||||||
|
|
||||||
AccountPhase.EnterOtp -> {
|
AccountPhase.EnterOtp -> {
|
||||||
@@ -98,17 +124,16 @@ fun AccountSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||||
OutlinedTextField(
|
OtpTextField(
|
||||||
value = viewModel.otp,
|
otpText = viewModel.otp,
|
||||||
onValueChange = viewModel::updateOtp,
|
otpCount = 5,
|
||||||
label = { Text("کد تأیید") },
|
onOtpTextChange = { value, _ -> viewModel.updateOtp(value) },
|
||||||
singleLine = true,
|
onComplete = { viewModel.verifyOtp() },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(MaterialTheme.dime.md))
|
Spacer(Modifier.height(MaterialTheme.dime.md))
|
||||||
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = viewModel::verifyOtp)
|
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = { viewModel.verifyOtp() })
|
||||||
TextButton(onClick = viewModel::editMobile, enabled = !ui.busy) {
|
TextButton(onClick = { viewModel.editMobile() }, enabled = !ui.busy) {
|
||||||
Text("ویرایش شماره موبایل")
|
Text("ویرایش شماره موبایل")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +148,9 @@ fun AccountSheet(
|
|||||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||||
when {
|
when {
|
||||||
ui.productsLoading -> Loading(
|
ui.productsLoading -> Loading(
|
||||||
modifier = Modifier.fillMaxWidth().height(160.dp)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(160.dp)
|
||||||
)
|
)
|
||||||
else -> Column(
|
else -> Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
|
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
|
||||||
@@ -156,7 +183,7 @@ fun AccountSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
Spacer(Modifier.height(MaterialTheme.dime.lg))
|
||||||
TextButton(onClick = viewModel::logout) { Text("خروج از حساب") }
|
TextButton(onClick = { viewModel.logout() }) { Text("خروج از حساب") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.approagency.pharmacy.presentation.account
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پلِ تکنمونه برای رساندن کدِ خواندهشده از پیامک (SMS User Consent) از
|
||||||
|
* [MainActivity] به شیتِ ورود. اکتیویتی کد را [submit] میکند و شیت آن را
|
||||||
|
* از [codes] میخواند و در فیلد OTP قرار میدهد.
|
||||||
|
*/
|
||||||
|
class OtpAutoFillBus {
|
||||||
|
private val _codes = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||||
|
val codes: SharedFlow<String> = _codes.asSharedFlow()
|
||||||
|
|
||||||
|
fun submit(code: String) {
|
||||||
|
_codes.tryEmit(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.approagency.pharmacy.presentation.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فیلد ورود کدِ یکبارمصرف بهصورت خانههای مجزا با انیمیشن خانهی فعال.
|
||||||
|
* همیشه LTR است و رنگها از تم گرفته میشوند تا در حالت تاریک/روشن هماهنگ باشد.
|
||||||
|
*
|
||||||
|
* @param onOtpTextChange (متن، آیا کامل شد) — با هر تغییر صدا زده میشود.
|
||||||
|
* @param onComplete وقتی همهی خانهها پر شد یکبار صدا زده میشود.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OtpTextField(
|
||||||
|
otpText: String,
|
||||||
|
onOtpTextChange: (String, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
otpCount: Int = 5,
|
||||||
|
size: Dp = 48.dp,
|
||||||
|
focusedSize: Dp = 56.dp,
|
||||||
|
onComplete: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
var isCompleted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||||
|
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||||
|
|
||||||
|
BasicTextField(
|
||||||
|
modifier = modifier.focusRequester(focusRequester),
|
||||||
|
value = TextFieldValue(otpText, selection = TextRange(otpText.length)),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
if (newValue.text.length <= otpCount) {
|
||||||
|
onOtpTextChange(newValue.text, newValue.text.length == otpCount)
|
||||||
|
if (newValue.text.length == otpCount && !isCompleted) {
|
||||||
|
isCompleted = true
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onComplete()
|
||||||
|
isCompleted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.NumberPassword,
|
||||||
|
imeAction = if (otpText.length == otpCount) ImeAction.Done else ImeAction.Next
|
||||||
|
),
|
||||||
|
decorationBox = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.animateContentSize(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
repeat(otpCount) { index ->
|
||||||
|
val isFocused = otpText.length == index
|
||||||
|
val animatedSize by animateDpAsState(
|
||||||
|
targetValue = if (isFocused) focusedSize else size,
|
||||||
|
animationSpec = tween(durationMillis = 300),
|
||||||
|
label = "otpCellSize"
|
||||||
|
)
|
||||||
|
val char = when {
|
||||||
|
index == otpText.length -> "_"
|
||||||
|
index > otpText.length -> ""
|
||||||
|
else -> otpText[index].toString()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isFocused)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
)
|
||||||
|
.size(animatedSize),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = char,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (index < otpCount - 1) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -47,7 +47,7 @@ class AccountViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateOtp(value: String) {
|
fun updateOtp(value: String) {
|
||||||
otp = value.filter { it.isDigit() }.take(6)
|
otp = value.filter { it.isDigit() }.take(OTP_LENGTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** هنگام باز شدن شیت: فاز مناسب را بر اساس وضعیت حساب تعیین کن. */
|
/** هنگام باز شدن شیت: فاز مناسب را بر اساس وضعیت حساب تعیین کن. */
|
||||||
@@ -170,6 +170,11 @@ class AccountViewModel(
|
|||||||
fun dismissError() {
|
fun dismissError() {
|
||||||
_ui.update { it.copy(error = null) }
|
_ui.update { it.copy(error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** طول کد یکبارمصرفِ سامانهی آپرواجنسی. */
|
||||||
|
const val OTP_LENGTH = 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AccountPhase { EnterMobile, EnterOtp, Products, Subscribed }
|
enum class AccountPhase { EnterMobile, EnterOtp, Products, Subscribed }
|
||||||
|
|||||||
@@ -108,11 +108,16 @@ private val DarkColorScheme = darkColorScheme(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DrugTheme(
|
fun DrugTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
// Dynamic color is available on Android 12+
|
// Dynamic color is available on Android 12+
|
||||||
dynamicColor: Boolean = false,
|
dynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val darkTheme = when (themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
}
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.approagency.pharmacy.ui.theme
|
||||||
|
|
||||||
|
/** حالت نمایش انتخابی کاربر؛ پیشفرض پیروی از سیستم است. */
|
||||||
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
|||||||
gson = "2.13.1"
|
gson = "2.13.1"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
location = "21.3.0"
|
location = "21.3.0"
|
||||||
|
playServicesAuthApiPhone = "18.1.0"
|
||||||
koinAndroid = "4.1.0"
|
koinAndroid = "4.1.0"
|
||||||
koinAndroidxCompose = "4.1.0"
|
koinAndroidxCompose = "4.1.0"
|
||||||
lifecycleViewmodelKtx = "2.9.4"
|
lifecycleViewmodelKtx = "2.9.4"
|
||||||
@@ -51,6 +52,7 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
|
|||||||
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location" }
|
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location" }
|
||||||
|
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
|
||||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||||
|
|||||||
Reference in New Issue
Block a user