Compare commits

..
2 Commits
Author SHA1 Message Date
Amirmahdi 675123309a feat: add otp reciver 2026-06-12 20:02:21 +03:30
Amirmahdi fdf15913a9 feat: theme manage and auth 2026-06-12 15:42:09 +03:30
15 changed files with 668 additions and 47 deletions
+3
View File
@@ -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))
}
}
}
}
)
}
}
@@ -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 }
+2
View File
@@ -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" }