From 675123309a46301979b9e2c478f751f4dcbcca70 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 12 Jun 2026 20:02:21 +0330 Subject: [PATCH] feat: add otp reciver --- app/build.gradle.kts | 3 + .../com/approagency/pharmacy/MainActivity.kt | 70 ++++++++++ .../com/approagency/pharmacy/di/AppModule.kt | 4 + .../pharmacy/navigation/MainContainer.kt | 6 +- .../presentation/account/AccountSheet.kt | 32 ++++- .../presentation/account/OtpAutoFillBus.kt | 19 +++ .../presentation/common/OtpTextField.kt | 127 ++++++++++++++++++ .../viewModel/AccountViewModel.kt | 7 +- gradle/libs.versions.toml | 2 + 9 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/approagency/pharmacy/presentation/account/OtpAutoFillBus.kt create mode 100644 app/src/main/java/com/approagency/pharmacy/presentation/common/OtpTextField.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce6c382..3840b4e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,6 +77,9 @@ dependencies { // Location services implementation(libs.location.services) + // SMS User Consent API (OTP auto-fill) + implementation(libs.play.services.auth.api.phone) + // Koin for DI implementation(libs.koin.android) implementation(libs.koin.androidx.compose.v410) diff --git a/app/src/main/java/com/approagency/pharmacy/MainActivity.kt b/app/src/main/java/com/approagency/pharmacy/MainActivity.kt index d27c5eb..57b6986 100644 --- a/app/src/main/java/com/approagency/pharmacy/MainActivity.kt +++ b/app/src/main/java/com/approagency/pharmacy/MainActivity.kt @@ -1,17 +1,29 @@ 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 androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts 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 com.approagency.pharmacy.data.local.SessionManager import com.approagency.pharmacy.domain.repository.AuthRepository import com.approagency.pharmacy.navigation.AppNavGraph +import com.approagency.pharmacy.presentation.account.OtpAutoFillBus 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 org.koin.android.ext.android.inject @@ -19,6 +31,19 @@ class MainActivity : ComponentActivity() { private val session: SessionManager 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) override fun onCreate(savedInstanceState: Bundle?) { @@ -42,4 +67,49 @@ class MainActivity : ComponentActivity() { 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() + } } diff --git a/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt b/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt index f001aaa..40d5027 100644 --- a/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt +++ b/app/src/main/java/com/approagency/pharmacy/di/AppModule.kt @@ -27,6 +27,7 @@ import com.approagency.pharmacy.domain.usecase.GetTestItemByGroupId import com.approagency.pharmacy.domain.usecase.SearchDrugsYabUseCase import com.approagency.pharmacy.domain.usecase.SearchTestsUseCase import com.approagency.pharmacy.presentation.account.AccountSheetController +import com.approagency.pharmacy.presentation.account.OtpAutoFillBus import com.approagency.pharmacy.presentation.viewModel.AccountViewModel import com.approagency.pharmacy.presentation.viewModel.DrugDetailViewModel import com.approagency.pharmacy.presentation.viewModel.HomeViewModel @@ -198,6 +199,9 @@ val appModule= module { // کنترلر سراسری نمایش شیت حساب single { AccountSheetController() } + // پلِ تحویل کدِ خوانده‌شده از پیامک به شیت ورود + single { OtpAutoFillBus() } + viewModel { AccountViewModel(get(), get(), get()) } diff --git a/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt b/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt index 835558b..b329a8c 100644 --- a/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt +++ b/app/src/main/java/com/approagency/pharmacy/navigation/MainContainer.kt @@ -136,9 +136,13 @@ private fun AccountAppBar( }, actions = { if (!account.isLoggedIn) { - TextButton(onClick = onAccountClick) { + ElevatedButton(onClick = onAccountClick) { Text("ورود", textAlign = TextAlign.Center) } + }else if (!account.isSubscribed){ + ElevatedButton(onClick = onAccountClick) { + Text("خرید اشتراک", textAlign = TextAlign.Center) + } } } ) diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt b/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt index c43a31a..8588549 100644 --- a/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt +++ b/app/src/main/java/com/approagency/pharmacy/presentation/account/AccountSheet.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -33,14 +34,17 @@ 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.dp +import com.approagency.pharmacy.MainActivity import com.approagency.pharmacy.domain.model.SubscriptionProduct import com.approagency.pharmacy.presentation.common.CustomModalBottomSheet import com.approagency.pharmacy.presentation.common.Loading +import com.approagency.pharmacy.presentation.common.OtpTextField import com.approagency.pharmacy.presentation.common.PrimaryButton import com.approagency.pharmacy.presentation.viewModel.AccountPhase import com.approagency.pharmacy.presentation.viewModel.AccountViewModel import com.vada.caller.ui.theme.dime import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,6 +55,7 @@ fun AccountSheet( val ui by viewModel.ui.collectAsState() val account by viewModel.account.collectAsState() val activity = LocalContext.current as? Activity + val otpAutoFillBus: OtpAutoFillBus = koinInject() LaunchedEffect(Unit) { viewModel.onSheetOpened() } @@ -62,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) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { Column( @@ -103,12 +124,11 @@ fun AccountSheet( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(Modifier.height(MaterialTheme.dime.lg)) - OutlinedTextField( - value = viewModel.otp, - onValueChange = { viewModel.updateOtp(it) }, - label = { Text("کد تأیید") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + OtpTextField( + otpText = viewModel.otp, + otpCount = 5, + onOtpTextChange = { value, _ -> viewModel.updateOtp(value) }, + onComplete = { viewModel.verifyOtp() }, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(MaterialTheme.dime.md)) diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/account/OtpAutoFillBus.kt b/app/src/main/java/com/approagency/pharmacy/presentation/account/OtpAutoFillBus.kt new file mode 100644 index 0000000..6cbdf17 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/presentation/account/OtpAutoFillBus.kt @@ -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(extraBufferCapacity = 1) + val codes: SharedFlow = _codes.asSharedFlow() + + fun submit(code: String) { + _codes.tryEmit(code) + } +} diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/common/OtpTextField.kt b/app/src/main/java/com/approagency/pharmacy/presentation/common/OtpTextField.kt new file mode 100644 index 0000000..c36edb3 --- /dev/null +++ b/app/src/main/java/com/approagency/pharmacy/presentation/common/OtpTextField.kt @@ -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)) + } + } + } + } + ) + } +} diff --git a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt index fcb614c..5941b8d 100644 --- a/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt +++ b/app/src/main/java/com/approagency/pharmacy/presentation/viewModel/AccountViewModel.kt @@ -47,7 +47,7 @@ class AccountViewModel( } 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() { _ui.update { it.copy(error = null) } } + + private companion object { + /** طول کد یک‌بارمصرفِ سامانه‌ی آپرواجنسی. */ + const val OTP_LENGTH = 5 + } } enum class AccountPhase { EnterMobile, EnterOtp, Products, Subscribed } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 812d583..0e1c3eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ okhttp = "4.12.0" gson = "2.13.1" coil = "2.7.0" location = "21.3.0" +playServicesAuthApiPhone = "18.1.0" koinAndroid = "4.1.0" koinAndroidxCompose = "4.1.0" 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" } 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" } +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-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }