diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ab86f60..280578c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/main/java/com/offpay/app/OffPayApplication.kt b/app/src/main/java/com/offpay/app/OffPayApplication.kt index b44c955..61e6494 100644 --- a/app/src/main/java/com/offpay/app/OffPayApplication.kt +++ b/app/src/main/java/com/offpay/app/OffPayApplication.kt @@ -6,6 +6,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore import com.offpay.app.data.AppDatabase +import com.offpay.app.data.ContactRepository import com.offpay.app.data.HistoryRepository import com.offpay.app.data.PreferencesRepository import com.offpay.app.domain.ActionRunner @@ -37,6 +38,9 @@ class OffPayApplication : Application() { lateinit var prefsRepo: PreferencesRepository private set + lateinit var contactRepo: ContactRepository + private set + // ─── Platform Layer ──────────────────────────────────────────────────────── lateinit var overlayController: OverlayControllerImpl @@ -64,6 +68,7 @@ class OffPayApplication : Application() { database = AppDatabase.create(this, passphrase) historyRepo = HistoryRepository(database.transactionDao()) prefsRepo = PreferencesRepository(dataStore) + contactRepo = ContactRepository(this) // Platform layer overlayController = OverlayControllerImpl(this) diff --git a/app/src/main/java/com/offpay/app/data/ContactRepository.kt b/app/src/main/java/com/offpay/app/data/ContactRepository.kt new file mode 100644 index 0000000..cf660d4 --- /dev/null +++ b/app/src/main/java/com/offpay/app/data/ContactRepository.kt @@ -0,0 +1,48 @@ +package com.offpay.app.data + +import android.content.ContentResolver +import android.content.Context +import android.provider.ContactsContract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class Contact( + val name: String, + val phoneNumber: String +) + +class ContactRepository(private val context: Context) { + + suspend fun fetchContacts(): List = withContext(Dispatchers.IO) { + val contactList = mutableListOf() + val contentResolver: ContentResolver = context.contentResolver + val cursor = contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + arrayOf( + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER + ), + null, + null, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC" + ) + + cursor?.use { + val nameIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) + val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + + while (it.moveToNext()) { + val name = it.getString(nameIndex) + val number = it.getString(numberIndex).filter { char -> char.isDigit() } + + + if (number.length >= 10) { + val cleanedNumber = number.takeLast(10) + contactList.add(Contact(name, cleanedNumber)) + } + } + } + + contactList.distinctBy { it.phoneNumber } + } +} diff --git a/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt b/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt index 063743e..052f085 100644 --- a/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt +++ b/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.offpay.app.domain.OperationMode @@ -16,10 +17,49 @@ object PreferencesKeys { val FIRST_LAUNCH_COMPLETE = booleanPreferencesKey("first_launch_complete") val LAST_BALANCE_TEXT = stringPreferencesKey("last_balance_text") val LAST_BALANCE_TIMESTAMP = longPreferencesKey("last_balance_timestamp") + val UPI_PIN_LENGTH = intPreferencesKey("upi_pin_length") + val DEFAULT_SIM_SLOT = intPreferencesKey("default_sim_slot") + val SELECTED_SIM_CARRIER = stringPreferencesKey("selected_sim_carrier") + val LAST_KNOWN_SIM_IDS = stringPreferencesKey("last_known_sim_ids") } class PreferencesRepository(private val dataStore: DataStore) { + val selectedSimCarrier: Flow = dataStore.data.map { it[PreferencesKeys.SELECTED_SIM_CARRIER] } + + suspend fun setSelectedSimCarrier(name: String?) { + dataStore.edit { prefs -> + if (name == null) prefs.remove(PreferencesKeys.SELECTED_SIM_CARRIER) + else prefs[PreferencesKeys.SELECTED_SIM_CARRIER] = name + } + } + + val lastKnownSimIds: Flow = dataStore.data.map { it[PreferencesKeys.LAST_KNOWN_SIM_IDS] } + + suspend fun setLastKnownSimIds(ids: String) { + dataStore.edit { it[PreferencesKeys.LAST_KNOWN_SIM_IDS] = ids } + } + + val upiPinLength: Flow = dataStore.data.map { preferences -> + preferences[PreferencesKeys.UPI_PIN_LENGTH] ?: 6 + } + + suspend fun setUpiPinLength(length: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.UPI_PIN_LENGTH] = length + } + } + + val defaultSimSlot: Flow = dataStore.data.map { preferences -> + preferences[PreferencesKeys.DEFAULT_SIM_SLOT] ?: -1 + } + + suspend fun setDefaultSimSlot(slot: Int) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.DEFAULT_SIM_SLOT] = slot + } + } + val operationMode: Flow = dataStore.data.map { preferences -> val stored = preferences[PreferencesKeys.OPERATION_MODE] if (stored != null) { diff --git a/app/src/main/java/com/offpay/app/domain/ActionRunner.kt b/app/src/main/java/com/offpay/app/domain/ActionRunner.kt index da4f92d..31a5428 100644 --- a/app/src/main/java/com/offpay/app/domain/ActionRunner.kt +++ b/app/src/main/java/com/offpay/app/domain/ActionRunner.kt @@ -38,7 +38,7 @@ data class ActionRun( class ActionRunner(private val engine: UssdEnginePort) { companion object { - const val DEFAULT_PACING_DELAY_MS = 250L + const val DEFAULT_PACING_DELAY_MS = 50L /** * Universal success patterns. Checked BEFORE step matching on every @@ -57,9 +57,22 @@ class ActionRunner(private val engine: UssdEnginePort) { Regex("transaction\\s+successful", RegexOption.IGNORE_CASE), Regex("txn\\s+successful", RegexOption.IGNORE_CASE), Regex("payment\\s+successful", RegexOption.IGNORE_CASE), + Regex("request\\s+is\\s+being\\s+processed", RegexOption.IGNORE_CASE), // A real referenceId in the text is a strong success signal Regex("ref(?:erence)?\\s*(?:id|no|number|#)?\\s*[:\\-]?\\s*\\d{6,}", RegexOption.IGNORE_CASE) ) + + /** + * Transient "loading" patterns. Frames matching these are ignored + * to prevent "Unexpected carrier response" during transient network states. + */ + private val TRANSIENT_PATTERNS: List = listOf( + Regex("requesting", RegexOption.IGNORE_CASE), + Regex("processing", RegexOption.IGNORE_CASE), + Regex("please\\s+wait", RegexOption.IGNORE_CASE), + Regex("sending", RegexOption.IGNORE_CASE), + Regex("ussd\\s+code\\s+running", RegexOption.IGNORE_CASE) + ) } /** @@ -97,8 +110,9 @@ class ActionRunner(private val engine: UssdEnginePort) { try { // 1. Dismiss any leftover dialog and dial the action code. + val dialedCode = fillTemplate(action.code, vars) engine.dismissDialog() - engine.dial(action.code) + engine.dial(dialedCode) // 2. Capture our session ID. Frames with a different // sessionId are leftovers from a prior run and discarded. @@ -140,8 +154,8 @@ class ActionRunner(private val engine: UssdEnginePort) { val step = action.steps[matchedIndex] currentStepIndex = matchedIndex + 1 - eventFlow.emit(ActionEvent.Progress(matchedIndex, totalSteps, step.label)) eventFlow.emit(ActionEvent.Frame(frame, matchedIndex)) + eventFlow.emit(ActionEvent.Progress(matchedIndex, totalSteps, step.label)) if (step.done) { terminated = true @@ -171,7 +185,12 @@ class ActionRunner(private val engine: UssdEnginePort) { // progression instead of a slot-machine autofill. delay(step.delayMs) - engine.sendReply(reply) + if (step.autoSubmit) { + engine.sendReply(reply) + } else { + engine.fillReply(reply) + } + eventFlow.emit(ActionEvent.Reply(reply, matchedIndex)) } return@collect @@ -179,6 +198,11 @@ class ActionRunner(private val engine: UssdEnginePort) { // ── Priority 4: terminal frame with no match ─────────── if (frame.isTerminal) { + // Ignore transient "loading" frames to wait for the final message. + if (matchesPattern(text, TRANSIENT_PATTERNS)) { + return@collect + } + terminated = true eventFlow.emit(ActionEvent.Error("Unexpected carrier response", text)) resultDeferred.complete(ActionResult(success = false, resultText = text)) @@ -247,4 +271,7 @@ class ActionRunner(private val engine: UssdEnginePort) { fun matchesFailurePattern(text: String, patterns: List): Boolean = patterns.any { it.containsMatchIn(text) } + + private fun matchesPattern(text: String, patterns: List): Boolean = + patterns.any { it.containsMatchIn(text) } } diff --git a/app/src/main/java/com/offpay/app/domain/Actions.kt b/app/src/main/java/com/offpay/app/domain/Actions.kt index 52a7615..a03238a 100644 --- a/app/src/main/java/com/offpay/app/domain/Actions.kt +++ b/app/src/main/java/com/offpay/app/domain/Actions.kt @@ -52,6 +52,7 @@ object Actions { Regex("psp\\s+(is\\s+)?not\\s+(registered|recognised|recognized)", RegexOption.IGNORE_CASE), Regex("vpa\\s+(does\\s+not\\s+exist|is\\s+not\\s+(registered|valid))", RegexOption.IGNORE_CASE), Regex("upi\\s*id\\s+(is\\s+)?(invalid|incorrect|wrong)", RegexOption.IGNORE_CASE), + Regex("merchant\\s+error|payee\\s+psp|payee\\s+not\\s+found", RegexOption.IGNORE_CASE), // Account / user not found Regex("(no|not\\s+a)\\s+(account|user|customer)\\s+(found|registered|exists)", RegexOption.IGNORE_CASE), @@ -71,6 +72,9 @@ object Actions { Regex("service\\s+(unavailable|not\\s+available|down)", RegexOption.IGNORE_CASE), Regex("try\\s+again\\s+later|temporarily\\s+unavailable", RegexOption.IGNORE_CASE), Regex("session\\s+(timed\\s+out|expired|terminated)", RegexOption.IGNORE_CASE), + Regex("not\\s+able\\s+to\\s+raise\\s+a\\s+request|connect\\s+with\\s+your\\s+bank", RegexOption.IGNORE_CASE), + Regex("network\\s+problem|network\\s+busy|connection\\s+problem|timed\\s+out", RegexOption.IGNORE_CASE), + Regex("cannot\\s+be\\s+processed|try\\s+after\\s+some\\s+time", RegexOption.IGNORE_CASE), // *99# user-not-onboarded phrases — the carrier returns these when // the user's mobile number isn't linked to a bank account for *99#. @@ -81,6 +85,56 @@ object Actions { Regex("bank\\s+not\\s+found|no\\s+bank\\s+(linked|found)", RegexOption.IGNORE_CASE) ) + val SendToMobile = Action( + code = "*99*1*1*{mobileNumber}#", + steps = listOf( + ActionStep( + match = Regex("(mobile\\s*number|mobile|msisdn|enter\\s+number|enter\\s+mobile)", RegexOption.IGNORE_CASE), + reply = "{mobileNumber}", + label = "Sending mobile number", + delayMs = 0L + ), + + ActionStep( + match = Regex("(enter\\s+amount|amount\\s+in\\s+rs|\\bamount\\b|\\bamt\\b)", RegexOption.IGNORE_CASE), + reply = "{amount}", + label = "Sending amount", + autoSubmit = false, // STOP HERE: Show Payee/Amount info + delayMs = 0L + ), + ActionStep( + match = Regex("\\b(remark|comment|note)\\b", RegexOption.IGNORE_CASE), + reply = "{note}", + label = "Adding note", + delayMs = 0L + ), + ActionStep( + match = Regex( + "\\bupi\\s*pin\\b|\\b(enter|6\\s*digit).*pin\\b", + RegexOption.IGNORE_CASE + ), + reply = "{pin}", + label = "Entering UPI PIN", + delayMs = 0L + ), + ActionStep( + match = Regex("\\b(confirm|press\\s*1|are you sure)\\b", RegexOption.IGNORE_CASE), + reply = "1", + label = "Confirming", + delayMs = 0L + ), + ActionStep( + match = Regex( + "successful|payment\\s+(?:sent|completed|done)|thank\\s*you\\s*for\\s*using|reference\\s+(?:no|number|id)\\s*[:\\-]", + RegexOption.IGNORE_CASE + ), + done = true, + label = "Payment complete" + ) + ), + failurePatterns = COMMON_FAILURES, + timeoutMs = 90_000L + ) /** * Send money via UPI using *99*1*3#. * 6-step flow: VPA → Amount → Remark → PIN → Confirm → Success. @@ -94,27 +148,41 @@ object Actions { // collide with "Enter amount" and "Enter UPI PIN". match = Regex("(receiver|payee|recipient|vpa|virtual.*payment|upi.*id)", RegexOption.IGNORE_CASE), reply = "{vpa}", - label = "Sending UPI ID" + label = "Sending UPI ID", + delayMs = 0L + ), + // Optional: Merchant or Payee verification step that some carriers + // insert for business VPAs like DMRC or Blinkit. + ActionStep( + match = Regex("\\b(merchant|payee|verified|verify|continue|confirm|accept|proceed|yes|press\\s*1)\\b", RegexOption.IGNORE_CASE), + reply = "1", + label = "Verifying payee", + delayMs = 0L ), ActionStep( match = Regex("\\bamount\\b", RegexOption.IGNORE_CASE), reply = "{amount}", - label = "Sending amount" + label = "Sending amount", + autoSubmit = false, // STOP HERE: Show Payee/Amount info + delayMs = 0L ), ActionStep( match = Regex("\\b(remark|comment|note)\\b", RegexOption.IGNORE_CASE), reply = "{note}", - label = "Adding note" + label = "Adding note", + delayMs = 0L ), ActionStep( match = Regex("\\bupi\\s*pin\\b|\\b(enter|6\\s*digit).*pin\\b", RegexOption.IGNORE_CASE), reply = "{pin}", - label = "Entering UPI PIN" + label = "Entering UPI PIN", + delayMs = 0L ), ActionStep( match = Regex("\\b(confirm|press\\s*1|are you sure)\\b", RegexOption.IGNORE_CASE), reply = "1", - label = "Confirming" + label = "Confirming", + delayMs = 0L ), ActionStep( // Success terminal frame. Most success frames hit @@ -126,7 +194,7 @@ object Actions { ) ), failurePatterns = COMMON_FAILURES, - timeoutMs = 25_000L + timeoutMs = 90_000L ) /** @@ -157,6 +225,6 @@ object Actions { Regex("max(imum)?\\s*(attempts|tries|retries)", RegexOption.IGNORE_CASE), Regex("pin\\s*(blocked|locked|expired)", RegexOption.IGNORE_CASE) ), - timeoutMs = 18_000L + timeoutMs = 90_000L ) } diff --git a/app/src/main/java/com/offpay/app/domain/InputValidator.kt b/app/src/main/java/com/offpay/app/domain/InputValidator.kt index 6fc3314..ed591ec 100644 --- a/app/src/main/java/com/offpay/app/domain/InputValidator.kt +++ b/app/src/main/java/com/offpay/app/domain/InputValidator.kt @@ -6,12 +6,13 @@ package com.offpay.app.domain */ object InputValidator { - private val VPA_PATTERN = Regex("^[a-zA-Z0-9._-]+@[a-zA-Z0-9]+$") + private val VPA_PATTERN = Regex("^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+$") private const val VPA_MAX_LENGTH = 50 private const val AMOUNT_MIN = 1.0 private const val AMOUNT_MAX = 5000.0 private val PIN_PATTERN = Regex("^\\d{4,6}$") private val DECIMAL_PLACES_PATTERN = Regex("^\\d+(\\.\\d{1,2})?$") + private val MOBILE_NUMBER_PATTERN = Regex("^[6-9]\\d{9}$") /** * Validates a UPI VPA (Virtual Payment Address). @@ -32,6 +33,17 @@ object InputValidator { return ValidationResult(isValid = true, errorMessage = null) } + fun validateMobileNumber(mobileNumber: String): ValidationResult { + val trimmed = mobileNumber.trim() + if (trimmed.isEmpty()) { + return ValidationResult(isValid = false, errorMessage = "Mobile number is required") + } + if (!MOBILE_NUMBER_PATTERN.matches(trimmed)) { + return ValidationResult(isValid = false, errorMessage = "Enter a valid 10-digit mobile number") + } + return ValidationResult(isValid = true, errorMessage = null) + } + /** * Validates a payment amount string. * Must parse as a number between ₹1 and ₹5000 with at most 2 decimal places. diff --git a/app/src/main/java/com/offpay/app/domain/SessionState.kt b/app/src/main/java/com/offpay/app/domain/SessionState.kt index 1a670d7..07d373d 100644 --- a/app/src/main/java/com/offpay/app/domain/SessionState.kt +++ b/app/src/main/java/com/offpay/app/domain/SessionState.kt @@ -5,7 +5,7 @@ package com.offpay.app.domain */ sealed class SessionState { object Idle : SessionState() - data class Running(val label: String, val stepIndex: Int, val total: Int) : SessionState() + data class Running(val label: String, val stepIndex: Int, val total: Int,val carrierText: String? = null) : SessionState() data class Success(val resultText: String) : SessionState() data class Failed(val message: String, val resultText: String) : SessionState() } diff --git a/app/src/main/java/com/offpay/app/domain/UpiParser.kt b/app/src/main/java/com/offpay/app/domain/UpiParser.kt index 630cac9..e1f4e63 100644 --- a/app/src/main/java/com/offpay/app/domain/UpiParser.kt +++ b/app/src/main/java/com/offpay/app/domain/UpiParser.kt @@ -28,14 +28,45 @@ object UpiParser { val vpa = params["pa"]?.let { decodeParam(it) } ?: return null if (!isValidVpa(vpa)) return null + val rawAmount = params["am"]?.let { decodeParam(it) } + val sanitizedAmount = sanitizeAmount(rawAmount) + + // Workaround for Dynamic QRs: + // Dynamic QRs use 'tr' (Transaction Ref) or 'tid' (Txn ID) to link the payment + // to an order. *99# doesn't have a dedicated field for these, but we can + // try passing them in the 'tn' (Note) field as a best-effort workaround. + // We limit to 20 chars as many USSD gateways truncate remarks anyway. + val note = (params["tn"] ?: params["tr"] ?: params["tid"]) + ?.let { decodeParam(it) } + ?.take(20) + return UpiData( vpa = vpa, payeeName = params["pn"]?.let { decodeParam(it) }, - amount = params["am"]?.let { decodeParam(it) }, - transactionNote = params["tn"]?.let { decodeParam(it) } + amount = sanitizedAmount, + transactionNote = note ) } + private fun sanitizeAmount(amount: String?): String? { + if (amount == null) return null + return try { + // Strip trailing .00 or .0 which often break USSD integer inputs + if (amount.contains(".")) { + val d = amount.toDouble() + if (d == d.toLong().toDouble()) { + d.toLong().toString() + } else { + "%.2f".format(d) + } + } else { + amount + } + } catch (_: Exception) { + amount + } + } + /** * Validates whether the given string is a valid VPA format. * Pattern: [a-zA-Z0-9.\-_]{3,}@[a-zA-Z0-9.\-_]{3,} diff --git a/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt b/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt index 55c030a..25d7c39 100644 --- a/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt +++ b/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt @@ -10,9 +10,16 @@ interface UssdEnginePort { /** Dial a USSD code (e.g. "*99*1*3#") via ACTION_CALL intent. */ suspend fun dial(code: String) + /** Set the SIM to use for the next dial attempt, when the user chose one in-app. */ + fun setPreferredSim(simInfo: SimInfo?) + /** Send a reply string to the active carrier dialog. Returns true if successful. */ suspend fun sendReply(reply: String): Boolean + suspend fun fillReply(reply: String): Boolean + + suspend fun submitFilledReply(): Boolean + /** Cancel the active USSD session and dismiss the carrier dialog. */ suspend fun cancel() diff --git a/app/src/main/java/com/offpay/app/domain/UssdModels.kt b/app/src/main/java/com/offpay/app/domain/UssdModels.kt index 26304d3..1721dfa 100644 --- a/app/src/main/java/com/offpay/app/domain/UssdModels.kt +++ b/app/src/main/java/com/offpay/app/domain/UssdModels.kt @@ -30,7 +30,8 @@ data class ActionStep( val reply: String? = null, val done: Boolean = false, val label: String? = null, - val delayMs: Long = 250L + val delayMs: Long = 250L, + val autoSubmit: Boolean = true ) /** diff --git a/app/src/main/java/com/offpay/app/domain/Validation.kt b/app/src/main/java/com/offpay/app/domain/Validation.kt index 7710a08..1d66ab1 100644 --- a/app/src/main/java/com/offpay/app/domain/Validation.kt +++ b/app/src/main/java/com/offpay/app/domain/Validation.kt @@ -15,4 +15,4 @@ data class FormValidationResult(val errors: Map) /** * Identifiers for the payment form fields. */ -enum class FormField { VPA, AMOUNT, PIN } +enum class FormField { VPA, MOBILE_NUMBER, AMOUNT, PIN } diff --git a/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt b/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt index b58eedf..7ead5b9 100644 --- a/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt +++ b/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt @@ -18,25 +18,25 @@ class CarrierDetector(private val context: Context) { * Reads the active SIM's carrier information via SubscriptionManager/TelephonyManager. * Returns null if no SIM is present or READ_PHONE_STATE permission is not granted. */ - suspend fun getActiveSimInfo(): SimInfo? = withContext(Dispatchers.IO) { + suspend fun getAvailableSims(): List = withContext(Dispatchers.IO) { try { val subscriptionManager = context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? SubscriptionManager - ?: return@withContext fallbackFromTelephonyManager() + ?: return@withContext listOfNotNull(fallbackFromTelephonyManager()) val activeSubscriptions = try { subscriptionManager.activeSubscriptionInfoList } catch (e: SecurityException) { // READ_PHONE_STATE not granted - return@withContext fallbackFromTelephonyManager() + return@withContext listOfNotNull(fallbackFromTelephonyManager()) } if (activeSubscriptions.isNullOrEmpty()) { - return@withContext fallbackFromTelephonyManager() + return@withContext listOfNotNull(fallbackFromTelephonyManager()) } + activeSubscriptions.map { info -> // Use the first active subscription (default data SIM) - val info = activeSubscriptions[0] SimInfo( slotIndex = info.simSlotIndex, subscriptionId = info.subscriptionId, @@ -55,10 +55,12 @@ class CarrierDetector(private val context: Context) { info.mnc.toString() } ) + } } catch (e: Exception) { - fallbackFromTelephonyManager() + listOfNotNull(fallbackFromTelephonyManager()) } } + suspend fun getActiveSimInfo(): SimInfo? = getAvailableSims().firstOrNull() /** * Fallback: use TelephonyManager when SubscriptionManager is unavailable. diff --git a/app/src/main/java/com/offpay/app/platform/OverlayController.kt b/app/src/main/java/com/offpay/app/platform/OverlayController.kt index 325393d..af4530f 100644 --- a/app/src/main/java/com/offpay/app/platform/OverlayController.kt +++ b/app/src/main/java/com/offpay/app/platform/OverlayController.kt @@ -43,6 +43,9 @@ interface OverlayController { /** Callback invoked when the user taps cancel on the overlay. */ var onCancel: (() -> Unit)? + /** Callback invoked when the user taps send/confirm on the full overlay. */ + var onConfirm: (() -> Unit)? + /** Callback invoked when the user taps the minimal bar (to bring app forward). */ var onMinimalTapped: (() -> Unit)? } diff --git a/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt b/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt index adf3dc4..d9fbc1c 100644 --- a/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt +++ b/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt @@ -4,12 +4,14 @@ import android.content.Context import android.graphics.Color import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable import android.os.Build import android.os.Handler import android.os.Looper -import android.provider.Settings +import android.util.DisplayMetrics import android.util.TypedValue import android.view.Gravity +import android.view.HapticFeedbackConstants import android.view.View import android.view.WindowManager import android.widget.Button @@ -34,17 +36,16 @@ import android.widget.TextView */ class OverlayControllerImpl(private val context: Context) : OverlayController { - private val windowManager: WindowManager = - context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - private val handler = Handler(Looper.getMainLooper()) // ── Full overlay state ───────────────────────────────────────────────────── - private var overlayView: FrameLayout? = null + private var overlayView: View? = null private var titleView: TextView? = null private var subtitleView: TextView? = null private var stepLabelView: TextView? = null private var spinnerView: ProgressBar? = null + private var sendButton: Button? = null + private var cancelButton: Button? = null // ── Minimal floating bar state ───────────────────────────────────────────── private var minimalView: LinearLayout? = null @@ -54,18 +55,25 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { private var minimalProgressTrack: FrameLayout? = null override var onCancel: (() -> Unit)? = null + override var onConfirm: (() -> Unit)? = null + set(value) { + field = value + runOnMain { + sendButton?.visibility = if (value != null) View.VISIBLE else View.GONE + } + } override var onMinimalTapped: (() -> Unit)? = null private fun fullParams(): WindowManager.LayoutParams { - val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + val type = if (UssdAccessibilityService.instance != null) { + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY } else { - @Suppress("DEPRECATION") - WindowManager.LayoutParams.TYPE_SYSTEM_ALERT + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } + val realHeight = getRealDisplayHeight() return WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.MATCH_PARENT, + realHeight, type, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or @@ -74,33 +82,33 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START + windowAnimations = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } } } - private fun minimalParams(): WindowManager.LayoutParams { - val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + private fun getWindowManager(): WindowManager { + val service = UssdAccessibilityService.instance + return if (service != null) { + service.getSystemService(Context.WINDOW_SERVICE) as WindowManager } else { - @Suppress("DEPRECATION") - WindowManager.LayoutParams.TYPE_SYSTEM_ALERT - } - return WindowManager.LayoutParams( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.WRAP_CONTENT, - type, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or - WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or - WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, - PixelFormat.TRANSLUCENT - ).apply { - gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - y = dp(48) + context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } } - override fun canShow(): Boolean = Settings.canDrawOverlays(context) + private fun getHostContext(): Context = UssdAccessibilityService.instance ?: context + override fun canShow(): Boolean = android.provider.Settings.canDrawOverlays(context) + + private fun runOnMain(action: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + action() + } else { + handler.post(action) + } + } // ─── Full overlay ────────────────────────────────────────────────────────── override fun show(title: String, subtitle: String, stepLabel: String) { @@ -108,28 +116,34 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { // If the minimal bar is up, dismiss it before opening the full overlay. removeMinimalView() - handler.post { + runOnMain { if (overlayView != null) { update(title, subtitle, stepLabel) - return@post + return@runOnMain } try { val v = buildFullView(title, subtitle, stepLabel) - windowManager.addView(v, fullParams()) + getWindowManager().addView(v, fullParams()) overlayView = v - } catch (_: Exception) { + sendButton?.visibility = if (onConfirm != null) View.VISIBLE else View.GONE + } catch (e: Exception) { overlayView = null } } } override fun update(title: String, subtitle: String, stepLabel: String) { - handler.post { + runOnMain { spinnerView?.visibility = View.VISIBLE titleView?.setTextColor(NEOPOP_WHITE) titleView?.text = title subtitleView?.text = subtitle stepLabelView?.text = stepLabel.uppercase() + sendButton?.visibility = if (onConfirm != null) View.VISIBLE else View.GONE + + sendButton?.background = neoPopButtonBg(NEOPOP_ACCENT) + sendButton?.setTextColor(NEOPOP_ACCENT) + sendButton?.isClickable = true } } @@ -140,52 +154,56 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { // If the full overlay is up, dismiss it before opening the minimal bar. removeFullView() - handler.post { + runOnMain { if (minimalView != null) { updateMinimal(progress, total, label) - return@post + return@runOnMain } try { val v = buildMinimalView(progress, total, label) - windowManager.addView(v, minimalParams()) + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (UssdAccessibilityService.instance != null) WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY else WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + y = (48 * context.resources.displayMetrics.density).toInt() + } + getWindowManager().addView(v, params) minimalView = v - } catch (_: Exception) { + } catch (e: Exception) { minimalView = null } } } override fun updateMinimal(progress: Int, total: Int, label: String) { - handler.post { - minimalLabel?.text = label.uppercase() + "…" - minimalLabel?.setTextColor(NEOPOP_WHITE) + runOnMain { + minimalLabel?.text = label.uppercase() + "..." val safeTotal = total.coerceAtLeast(1) val displayedStep = (progress + 1).coerceIn(1, safeTotal) minimalStepCount?.text = "STEP $displayedStep / $safeTotal" - // Update progress fill width using weights/layout params. minimalProgressTrack?.let { track -> track.post { val fraction = (progress.toFloat() / safeTotal).coerceIn(0f, 1f) val newWidth = (track.width * fraction).toInt() - minimalProgressFill?.layoutParams = - FrameLayout.LayoutParams( - newWidth, - FrameLayout.LayoutParams.MATCH_PARENT - ) + minimalProgressFill?.layoutParams = FrameLayout.LayoutParams(newWidth, FrameLayout.LayoutParams.MATCH_PARENT) } } } } override fun showError(title: String, message: String, holdMs: Long) { - handler.post { + runOnMain { // Prefer minimal bar feedback if it's already up. if (minimalView != null) { minimalLabel?.text = title.uppercase() minimalLabel?.setTextColor(NEOPOP_DANGER) minimalStepCount?.text = message.take(40) handler.postDelayed({ hide() }, holdMs) - return@post + return@runOnMain } if (overlayView == null) { show(title, message, "") @@ -200,7 +218,7 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { } override fun hide() { - handler.post { + runOnMain { removeFullView() removeMinimalView() } @@ -209,23 +227,23 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { private fun removeFullView() { overlayView?.let { view -> try { - if (view.parent != null) windowManager.removeView(view) - } catch (_: Exception) { - } + getWindowManager().removeView(view) + } catch (e: Exception) {} } overlayView = null titleView = null subtitleView = null stepLabelView = null spinnerView = null + sendButton = null + cancelButton = null } private fun removeMinimalView() { minimalView?.let { view -> try { - if (view.parent != null) windowManager.removeView(view) - } catch (_: Exception) { - } + getWindowManager().removeView(view) + } catch (e: Exception) {} } minimalView = null minimalLabel = null @@ -235,20 +253,34 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { } // ─── Full overlay view construction ──────────────────────────────────────── + private fun buildFullView(title: String, subtitle: String, stepLabel: String): View { + val host = getHostContext() + val realHeight = getRealDisplayHeight() + val statusBarHeight = getStatusBarHeight(host) + + val root = FrameLayout(host).apply { + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, realHeight) + setBackgroundColor(Color.TRANSPARENT) + } - private fun buildFullView(title: String, subtitle: String, stepLabel: String): FrameLayout { - val dim = FrameLayout(context).apply { - setBackgroundColor(NEOPOP_DIM) + // The Shield: Solid body that anchors to the bottom and extends UP + // to the status bar line. This ensures the navigation bar area + // at the bottom is perfectly masked. + val shield = FrameLayout(host).apply { + val lp = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, realHeight - statusBarHeight) + lp.gravity = Gravity.BOTTOM + layoutParams = lp + setBackgroundColor(Color.BLACK) isClickable = true } - val card = LinearLayout(context).apply { + val card = LinearLayout(host).apply { orientation = LinearLayout.VERTICAL background = neoPopCardBg() setPadding(dp(24), dp(24), dp(24), dp(24)) } - val brand = TextView(context).apply { + val brand = TextView(host).apply { text = "OFFPAY" setTextColor(NEOPOP_ACCENT) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) @@ -256,18 +288,18 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { typeface = android.graphics.Typeface.DEFAULT_BOLD } - spinnerView = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply { + spinnerView = ProgressBar(host, null, android.R.attr.progressBarStyleHorizontal).apply { isIndeterminate = true indeterminateTintList = android.content.res.ColorStateList.valueOf(NEOPOP_ACCENT) val lp = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT - ) + ) lp.topMargin = dp(20) layoutParams = lp } - titleView = TextView(context).apply { + titleView = TextView(host).apply { text = title setTextColor(NEOPOP_WHITE) setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) @@ -280,7 +312,7 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { layoutParams = lp } - subtitleView = TextView(context).apply { + subtitleView = TextView(host).apply { text = subtitle setTextColor(NEOPOP_TEXT_SECONDARY) setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) @@ -292,7 +324,7 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { layoutParams = lp } - stepLabelView = TextView(context).apply { + stepLabelView = TextView(host).apply { text = stepLabel.uppercase() setTextColor(NEOPOP_ACCENT) setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) @@ -306,177 +338,156 @@ class OverlayControllerImpl(private val context: Context) : OverlayController { layoutParams = lp } - val cancel = Button(context).apply { + val actions = LinearLayout(host).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.END + val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + lp.topMargin = dp(20) + layoutParams = lp + } + + val send = Button(host).apply { + text = "SEND" + setTextColor(NEOPOP_ACCENT) + background = neoPopButtonBg(NEOPOP_ACCENT) + setPadding(dp(20), dp(10), dp(20), dp(10)) + isAllCaps = true + setOnClickListener { + it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + it.background = GradientDrawable().apply { setColor(NEOPOP_ACCENT); cornerRadius = dp(4).toFloat() } + (it as Button).setTextColor(Color.BLACK) + it.isClickable = false + onConfirm?.invoke() + } + } + sendButton = send + + val cancel = Button(host).apply { text = "CANCEL" setTextColor(NEOPOP_DANGER) background = neoPopButtonBg(NEOPOP_DANGER) setPadding(dp(20), dp(10), dp(20), dp(10)) isAllCaps = true - letterSpacing = 0.1f setOnClickListener { + it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) onCancel?.invoke() hide() } - val lp = LinearLayout.LayoutParams( + layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT - ) - lp.gravity = Gravity.END - lp.topMargin = dp(20) - layoutParams = lp + ).apply { leftMargin = dp(12) } } + cancelButton = cancel - card.addView(brand) - card.addView(spinnerView) - card.addView(titleView) - card.addView(subtitleView) - card.addView(stepLabelView) - card.addView(cancel) + card.addView(brand); card.addView(spinnerView); card.addView(titleView); card.addView(subtitleView); card.addView(stepLabelView) + actions.addView(send); actions.addView(cancel); card.addView(actions) - val cardLp = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ).apply { + val cardLp = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - topMargin = dp(80) - leftMargin = dp(20) - rightMargin = dp(20) + topMargin = dp(120); leftMargin = dp(20); rightMargin = dp(20) } - dim.addView(card, cardLp) - return dim + shield.addView(card, cardLp) + root.addView(shield) + return root } // ─── Minimal floating bar view construction ──────────────────────────────── private fun buildMinimalView(progress: Int, total: Int, label: String): LinearLayout { - val safeTotal = total.coerceAtLeast(1) - val displayedStep = (progress + 1).coerceIn(1, safeTotal) - - val container = LinearLayout(context).apply { + val host = getHostContext() + val container = LinearLayout(host).apply { orientation = LinearLayout.VERTICAL background = minimalBarBg() - setPadding(dp(16), dp(12), dp(16), dp(0)) + setPadding(dp(16), dp(12), dp(16), dp(10)) // 90% screen width — the WindowManager.LayoutParams.MATCH_PARENT // along with horizontal margin handles this approximately. - val sideMargin = dp(20) - val lp = WindowManager.LayoutParams() // Real margins are baked into params at attach time; here we just // pad the inner view to leave a gutter. - setPadding(dp(16) + sideMargin, dp(12), dp(16) + sideMargin, dp(0)) isClickable = true - setOnClickListener { - onMinimalTapped?.invoke() - } + setOnClickListener { onMinimalTapped?.invoke() } } // Top row: spinner + label + step count - val topRow = LinearLayout(context).apply { + val topRow = LinearLayout(host).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL - val lp = LinearLayout.LayoutParams( + layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) - layoutParams = lp } - val spinner = ProgressBar(context).apply { + val spinner = ProgressBar(host).apply { indeterminateTintList = android.content.res.ColorStateList.valueOf(NEOPOP_ACCENT) - val lp = LinearLayout.LayoutParams(dp(18), dp(18)) - lp.rightMargin = dp(10) - layoutParams = lp + layoutParams = LinearLayout.LayoutParams(dp(18), dp(18)).apply { rightMargin = dp(10) } } - minimalLabel = TextView(context).apply { - text = label.uppercase() + "…" + minimalLabel = TextView(host).apply { + text = label.uppercase() + "..." setTextColor(NEOPOP_WHITE) setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) - letterSpacing = 0.12f typeface = android.graphics.Typeface.DEFAULT_BOLD - val lp = LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - layoutParams = lp + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) } - minimalStepCount = TextView(context).apply { - text = "STEP $displayedStep / $safeTotal" + minimalStepCount = TextView(host).apply { + text = "STEP ${progress + 1} / $total" setTextColor(NEOPOP_TEXT_SECONDARY) setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f) - letterSpacing = 0.1f typeface = android.graphics.Typeface.DEFAULT_BOLD } - topRow.addView(spinner) - topRow.addView(minimalLabel) - topRow.addView(minimalStepCount) + topRow.addView(spinner); topRow.addView(minimalLabel); topRow.addView(minimalStepCount) // Bottom: 2dp progress track with lime fill - minimalProgressTrack = FrameLayout(context).apply { + minimalProgressTrack = FrameLayout(host).apply { setBackgroundColor(NEOPOP_BORDER) - val lp = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - dp(2) - ) - lp.topMargin = dp(10) - lp.bottomMargin = dp(10) - layoutParams = lp + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(2)).apply { topMargin = dp(10) } } - minimalProgressFill = View(context).apply { + minimalProgressFill = View(host).apply { setBackgroundColor(NEOPOP_ACCENT) - val fraction = (progress.toFloat() / safeTotal).coerceIn(0f, 1f) - val lp = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ) + layoutParams = FrameLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT) // Initial pixel-width is a fudge; updateMinimal() recomputes it // once the track has measured. - lp.width = 0 - layoutParams = lp } minimalProgressTrack?.addView(minimalProgressFill) - container.addView(topRow) - container.addView(minimalProgressTrack) + container.addView(topRow); container.addView(minimalProgressTrack) return container } - private fun minimalBarBg(): GradientDrawable { - return GradientDrawable().apply { - cornerRadius = dp(16).toFloat() - setColor(NEOPOP_MINIMAL_FILL) - setStroke(dp(1), NEOPOP_ACCENT) - } + private fun getStatusBarHeight(c: Context): Int { + val resourceId = c.resources.getIdentifier("status_bar_height", "dimen", "android") + return if (resourceId > 0) c.resources.getDimensionPixelSize(resourceId) else dp(24) } - private fun neoPopCardBg(): GradientDrawable { - return GradientDrawable().apply { - setColor(NEOPOP_SURFACE_HIGH) - setStroke(dp(1), NEOPOP_BORDER) - } + private fun getRealDisplayHeight(): Int { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display = wm.defaultDisplay + val metrics = DisplayMetrics() + display.getRealMetrics(metrics) + return metrics.heightPixels } - private fun neoPopButtonBg(stroke: Int): GradientDrawable { - return GradientDrawable().apply { - setColor(Color.TRANSPARENT) - setStroke(dp(1), stroke) - } + private fun minimalBarBg() = GradientDrawable().apply { cornerRadius = dp(16).toFloat(); setColor(NEOPOP_MINIMAL_FILL); setStroke(dp(1), NEOPOP_ACCENT) } + private fun neoPopCardBg() = GradientDrawable().apply { setColor(NEOPOP_SURFACE_HIGH); setStroke(dp(1), NEOPOP_BORDER) } + private fun neoPopButtonBg(stroke: Int) = StateListDrawable().apply { + addState(intArrayOf(android.R.attr.state_pressed), GradientDrawable().apply { setColor(stroke.withAlpha(0.15f)); setStroke(dp(2), stroke); cornerRadius = dp(4).toFloat() }) + addState(intArrayOf(), GradientDrawable().apply { setColor(Color.TRANSPARENT); setStroke(dp(1), stroke); cornerRadius = dp(4).toFloat() }) } - private fun dp(value: Int): Int = - (value * context.resources.displayMetrics.density).toInt() + private fun Int.withAlpha(alpha: Float) = (this and 0x00FFFFFF) or ((alpha * 255).toInt().coerceIn(0, 255) shl 24) + private fun dp(value: Int) = (value * context.resources.displayMetrics.density).toInt() companion object { // NeoPOP palette (mirrors presentation/ui/theme/Colors.kt) - private const val NEOPOP_DIM = 0xCC000000.toInt() private const val NEOPOP_SURFACE_HIGH = 0xFF16181D.toInt() private const val NEOPOP_BORDER = 0xFF2A2D34.toInt() private const val NEOPOP_WHITE = 0xFFFFFFFF.toInt() private const val NEOPOP_TEXT_SECONDARY = 0xFF9BA1A8.toInt() private const val NEOPOP_ACCENT = 0xFFC5F542.toInt() private const val NEOPOP_DANGER = 0xFFFF4D4D.toInt() - private const val NEOPOP_MINIMAL_FILL = 0xEB000000.toInt() // ~92% opaque black + private const val NEOPOP_MINIMAL_FILL = 0xEB000000.toInt() } } diff --git a/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt b/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt index 4780e07..38b759d 100644 --- a/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt +++ b/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt @@ -117,6 +117,9 @@ class QrScannerManager { val clamped = ratio.coerceIn(MIN_ZOOM, MAX_ZOOM) cameraControl?.setZoomRatio(clamped) } + fun toggleTorch(enabled: Boolean) { + cameraControl?.enableTorch(enabled) + } /** * Unbinds all camera use cases and releases the camera. diff --git a/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt b/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt index b068b39..b7d4bd6 100644 --- a/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt +++ b/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt @@ -86,7 +86,7 @@ class UssdAccessibilityService : AccessibilityService() { feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC flags = AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or AccessibilityServiceInfo.DEFAULT - notificationTimeout = 100 + notificationTimeout = 10 } Log.d(TAG, "service connected") } @@ -137,6 +137,46 @@ class UssdAccessibilityService : AccessibilityService() { * * @return true if the reply was successfully sent */ + + fun fillReply(reply: String): Boolean { + val root = findUssdRoot() ?: return false + val edit = findEditText(root) ?: return false + + val args = Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + reply + ) + } + val ok = edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + + lastEmittedText = null + frameFilter.reset() + + return ok + } + + fun submitFilledReply(): Boolean { + val root = findUssdRoot() ?: run { + Log.w(TAG, "submitFilledReply: no USSD window found") + return false + } + val edit = findEditText(root) ?: run { + Log.w(TAG, "submitFilledReply: no EditText in USSD window") + return false + } + + lastEmittedText = null + frameFilter.reset() + + val sendBtn = findClickableButton(root, SEND_LABELS) + val clicked = sendBtn?.performAction(AccessibilityNodeInfo.ACTION_CLICK) ?: false + if (!clicked) { + edit.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + return true + } + fun sendReply(reply: String): Boolean { val root = findUssdRoot() ?: run { Log.w(TAG, "sendReply: no USSD window found") diff --git a/app/src/main/java/com/offpay/app/platform/UssdEngine.kt b/app/src/main/java/com/offpay/app/platform/UssdEngine.kt index 8e23a0d..153ba9f 100644 --- a/app/src/main/java/com/offpay/app/platform/UssdEngine.kt +++ b/app/src/main/java/com/offpay/app/platform/UssdEngine.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.SystemClock +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import com.offpay.app.domain.SimInfo import com.offpay.app.domain.UssdEnginePort import com.offpay.app.domain.UssdFrame import kotlinx.coroutines.CoroutineScope @@ -43,6 +46,9 @@ class UssdEngine( /** Timestamp (elapsedRealtime) of the last dial() call for double-tap protection. */ private var lastDialTime: Long = 0L + @Volatile + private var preferredSim: SimInfo? = null + // ─── Coroutine Infrastructure ────────────────────────────────────────────── private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -62,9 +68,9 @@ class UssdEngine( companion object { const val DOUBLE_TAP_COOLDOWN_MS = 2_000L - const val SLOW_WATCH_TIMEOUT_MS = 12_000L - const val HARD_TIMEOUT_SEND_MS = 25_000L - const val HARD_TIMEOUT_OTHER_MS = 30_000L + const val SLOW_WATCH_TIMEOUT_MS = 25_000L + const val HARD_TIMEOUT_SEND_MS = 90_000L + const val HARD_TIMEOUT_OTHER_MS = 90_000L } // ─── UssdEnginePort Implementation ───────────────────────────────────────── @@ -79,7 +85,7 @@ class UssdEngine( * 4. Cancel any in-flight timers, clear counters * 5. Increment sessionId * 6. sessionActive = true on engine and service - * 7. Hide leftover overlay + * 7. Keep the current overlay visible * 8. Register frame listener * 9. Start timers * 10. startActivity(ACTION_CALL) @@ -117,7 +123,7 @@ class UssdEngine( service?.sessionActive = true // 7. Hide overlay from prior session - overlayController?.hide() + // 8. Register as frame listener service?.frameListener = this @@ -131,10 +137,73 @@ class UssdEngine( val callIntent = Intent(Intent.ACTION_CALL).apply { data = Uri.parse("tel:$encodedCode") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + preferredSim?.let { sim -> + + getPhoneAccountHandle(sim.subscriptionId)?.let { handle -> + putExtra(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, handle) + } + + + putExtra("com.android.phone.extra.slot", sim.slotIndex) + putExtra("slot", sim.slotIndex) + putExtra("simSlot", sim.slotIndex) + putExtra("subscription", sim.subscriptionId) + putExtra("subscription_id", sim.subscriptionId) + } } + + + delay(100) context.startActivity(callIntent) } + + private fun getPhoneAccountHandle(subscriptionId: Int): PhoneAccountHandle? { + if (subscriptionId == -1) return null + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + ?: return null + + return try { + telecomManager.getCallCapablePhoneAccounts().find { handle -> + // In modern Android, the handle ID is usually the subscription ID string. + handle.id == subscriptionId.toString() + } + } catch (_: SecurityException) { + null + } + } + + override fun setPreferredSim(simInfo: SimInfo?) { + preferredSim = simInfo + } + + + override suspend fun fillReply(reply: String): Boolean { + if (!sessionActive) return false + + val service = UssdAccessibilityService.instance ?: return false + val success = service.fillReply(reply) + + if (success) { + resetSlowWatch() + } + + return success + } + + override suspend fun submitFilledReply(): Boolean { + if (!sessionActive) return false + + val service = UssdAccessibilityService.instance ?: return false + val success = service.submitFilledReply() + + if (success) { + resetSlowWatch() + } + + return success + } + /** * Sends a reply to the active USSD carrier dialog. * Resets the Slow_Watch timer on successful reply. @@ -219,7 +288,7 @@ class UssdEngine( slowWatchJob = scope.launch { delay(SLOW_WATCH_TIMEOUT_MS) if (sessionActive) { - terminateSession("Carrier unresponsive — no activity for 12 seconds") + terminateSession("Carrier unresponsive — no activity for 1.5 minutes") } } } diff --git a/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt b/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt index ba89e9d..57c83d9 100644 --- a/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt +++ b/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt @@ -9,6 +9,9 @@ import com.offpay.app.domain.Actions import com.offpay.app.domain.InputValidator import com.offpay.app.domain.OperationMode import com.offpay.app.domain.SessionState +import com.offpay.app.domain.SimInfo +import com.offpay.app.domain.UssdEnginePort +import com.offpay.app.platform.CarrierDetector import com.offpay.app.platform.OverlayController import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -17,6 +20,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -35,7 +39,8 @@ data class BalanceResult(val text: String, val timestamp: Long) data class BalanceUiState( val pin: String = "", val pinError: String? = null, - val isSessionActive: Boolean = false + val isSessionActive: Boolean = false, + val simPickerTitle: String = "Choose SIM" ) /** @@ -56,6 +61,8 @@ data class BalanceUiState( class BalanceViewModel( private val actionRunner: ActionRunner, private val prefsRepo: PreferencesRepository, + private val carrierDetector: CarrierDetector, + private val ussdEngine: UssdEnginePort, private val overlayController: OverlayController? = null, private val onDialerFallback: (String) -> Unit = {}, private val clipboardWriter: (String) -> Unit = {}, @@ -65,6 +72,7 @@ class BalanceViewModel( */ private val systemToast: (String) -> Unit = {} ) : ViewModel() { + private data class PendingBalance(val pin: String) private val _uiState = MutableStateFlow(BalanceUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -75,6 +83,9 @@ class BalanceViewModel( private val _snackbar = MutableStateFlow(null) val snackbar: StateFlow = _snackbar.asStateFlow() + private val _simOptions = MutableStateFlow?>(null) + val simOptions: StateFlow?> = _simOptions.asStateFlow() + /** * Last persisted balance result. Hydrated from DataStore so the screen * renders the prior balance immediately on launch. @@ -89,11 +100,86 @@ class BalanceViewModel( val operationMode: StateFlow = prefsRepo.operationMode .stateIn(viewModelScope, SharingStarted.Eagerly, OperationMode.AUTO) + val upiPinLength: StateFlow = prefsRepo.upiPinLength + .stateIn(viewModelScope, SharingStarted.Eagerly, 6) + + val defaultSimSlot: StateFlow = prefsRepo.defaultSimSlot + .stateIn(viewModelScope, SharingStarted.Eagerly, -1) + private var sessionJob: Job? = null + private var pendingBalance: PendingBalance? = null + + init { + // Shared logic with PayViewModel: pre-fetch only + viewModelScope.launch { carrierDetector.getAvailableSims() } + } + + private fun getSimSignature(sims: List): String { + return sims.sortedBy { it.slotIndex }.joinToString("|") { "${it.slotIndex}:${it.carrierName ?: "Unknown"}" } + } + + fun onSimSelected(simInfo: SimInfo) { + val currentTitle = _uiState.value.simPickerTitle + _simOptions.value = null + viewModelScope.launch { + if (currentTitle.contains("change", ignoreCase = true)) { + val sims = carrierDetector.getAvailableSims() + prefsRepo.setDefaultSimSlot(simInfo.slotIndex) + prefsRepo.setSelectedSimCarrier(simInfo.carrierName ?: "Unknown") + prefsRepo.setLastKnownSimIds(getSimSignature(sims)) + } + val pending = pendingBalance + if (pending != null) { + pendingBalance = null + ussdEngine.setPreferredSim(simInfo) + runCheck(pending.pin) + } + } + } + + fun onAskEveryTimeSelected() { + _simOptions.value = null + viewModelScope.launch { + val sims = carrierDetector.getAvailableSims() + prefsRepo.setDefaultSimSlot(-1) + prefsRepo.setSelectedSimCarrier(null) + prefsRepo.setLastKnownSimIds(getSimSignature(sims)) + val pending = pendingBalance + if (pending != null) { + pendingBalance = null + _uiState.update { it.copy(simPickerTitle = "Choose SIM") } + _simOptions.value = sims + } + } + } + + fun dismissSimSelection() { pendingBalance = null; _simOptions.value = null } + + private suspend fun handleSimDetection(sims: List): SimInfo? { + val defaultSlot = prefsRepo.defaultSimSlot.first() + val selectedCarrier = prefsRepo.selectedSimCarrier.first() + val lastSig = prefsRepo.lastKnownSimIds.first() + val currentSig = getSimSignature(sims) + + if (sims.size == 1) return sims.first() + + if (defaultSlot != -1 && selectedCarrier != null) { + if (lastSig != currentSig) { + _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") } + return null + } + val currentInSlot = sims.find { it.slotIndex == defaultSlot } + if (currentInSlot != null && currentInSlot.carrierName == selectedCarrier) return currentInSlot + _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") } + return null + } + _uiState.update { it.copy(simPickerTitle = "Choose SIM") } + return null + } - /** Inline PIN editing — strips non-digits, caps at 6, clears any error. */ fun onPinChanged(pin: String) { - val digits = pin.filter { it.isDigit() }.take(6) + val maxLength = upiPinLength.value + val digits = pin.filter { it.isDigit() }.take(maxLength) _uiState.update { it.copy(pin = digits, pinError = null) } } @@ -107,164 +193,63 @@ class BalanceViewModel( */ fun attemptCheckBalance() { val mode = operationMode.value + if (mode == OperationMode.MANUAL) { onDialerFallback("*99*3#"); _sessionState.value = SessionState.Idle; return } + if (!actionRunner.isServiceEnabled()) { _sessionState.value = SessionState.Failed(message = "Accessibility service is disabled.", resultText = ""); return } + val validation = InputValidator.validatePin(_uiState.value.pin) + if (!validation.isValid) { _uiState.update { it.copy(pinError = validation.errorMessage) }; return } + maybeRequestSimThenRun(_uiState.value.pin) + } - if (mode == OperationMode.MANUAL) { - _snackbar.value = "Opening dialer for *99*3#" - onDialerFallback("*99*3#") - // System Toast — visible on top of the dialer so the user - // remembers what's queued. The in-app snackbar above only - // renders inside our activity which is about to lose focus. - systemToast("Opening dialer for *99*3#") - _sessionState.value = SessionState.Idle - return - } - - if (!actionRunner.isServiceEnabled()) { - _sessionState.value = SessionState.Failed( - message = "Accessibility service is disabled. Please enable it in Settings.", - resultText = "" - ) - return - } - - val pin = _uiState.value.pin - val validation = InputValidator.validatePin(pin) - if (!validation.isValid) { - _uiState.update { it.copy(pinError = validation.errorMessage) } - return + private fun maybeRequestSimThenRun(pin: String) { + viewModelScope.launch { + val sims = carrierDetector.getAvailableSims() + if (sims.isEmpty()) { runCheck(pin); return@launch } + if (operationMode.value == OperationMode.MANUAL) { runCheck(pin); return@launch } + val targetSim = handleSimDetection(sims) + if (targetSim != null) { ussdEngine.setPreferredSim(targetSim); runCheck(pin) } + else { pendingBalance = PendingBalance(pin); _simOptions.value = sims } } - - runCheck(pin) } - /** - * Runs the CheckBalance action with the captured PIN. - */ private fun runCheck(pin: String) { - val mode = operationMode.value - _uiState.update { it.copy(pinError = null, isSessionActive = true) } - _sessionState.value = SessionState.Running( - label = "Checking balance", - stepIndex = 0, - total = Actions.CheckBalance.steps.size - ) - - when (mode) { - OperationMode.AUTO -> overlayController?.show( - title = "Checking balance", - subtitle = "OffPay is asking your bank…", - stepLabel = "STARTING" - ) - OperationMode.ADVANCED -> overlayController?.showMinimal( - progress = 0, - total = Actions.CheckBalance.steps.size, - label = "CHECKING BALANCE" - ) - OperationMode.MANUAL -> Unit // not reached + _sessionState.value = SessionState.Running(label = "Checking balance", stepIndex = 0, total = Actions.CheckBalance.steps.size, carrierText = "Initializing...") + if (operationMode.value != OperationMode.MANUAL) { + overlayController?.show(title = "Checking balance", subtitle = "OffPay is asking your bank…", stepLabel = "STARTING") } overlayController?.onCancel = { cancelSession() } - val vars = mapOf("pin" to pin) val actionRun = actionRunner.runAction(Actions.CheckBalance, vars, viewModelScope) - sessionJob = viewModelScope.launch { launch { + var lastCarrierPrompt: String? = null actionRun.events.collect { event -> - // Drop any straggler events once we've already reached - // a terminal session state (Success/Failed). Mirrors - // the guard in PayViewModel and protects against a - // synthetic "User cancelled" frame racing through - // after engine.cancel() inside the runner. - val current = _sessionState.value - if (current is SessionState.Success || current is SessionState.Failed) { - return@collect - } + if (_sessionState.value !is SessionState.Running) return@collect when (event) { + is ActionEvent.Frame -> lastCarrierPrompt = cleanCarrierText(event.frame.text) is ActionEvent.Progress -> { - _sessionState.value = SessionState.Running( - label = event.label ?: "Processing", - stepIndex = event.stepIndex, - total = event.total - ) - when (mode) { - OperationMode.AUTO -> overlayController?.update( - title = "Checking balance", - subtitle = "OffPay is asking your bank…", - stepLabel = (event.label ?: "Processing").uppercase() - ) - OperationMode.ADVANCED -> overlayController?.updateMinimal( - progress = event.stepIndex, - total = event.total, - label = "CHECKING BALANCE" - ) - else -> Unit - } + _sessionState.value = SessionState.Running(label = event.label ?: "Processing", stepIndex = event.stepIndex, total = event.total, carrierText = lastCarrierPrompt) + overlayController?.update(title = "Checking balance", subtitle = lastCarrierPrompt ?: "OffPay is asking your bank…", stepLabel = (event.label ?: "Processing").uppercase()) } - is ActionEvent.Done -> { - _sessionState.value = SessionState.Success(resultText = event.resultText) - overlayController?.hide() - launch { - prefsRepo.setLastBalance( - text = event.resultText, - timestamp = System.currentTimeMillis() - ) - } - onSessionEnd() - } - is ActionEvent.Error -> { - _sessionState.value = SessionState.Failed( - message = event.message, - resultText = event.resultText - ) - overlayController?.showError( - title = "Check failed", - message = event.message - ) - onSessionEnd() - } - else -> { /* Frame and Reply events — no UI state change */ } + is ActionEvent.Done -> { _sessionState.value = SessionState.Success(event.resultText); overlayController?.hide(); launch { prefsRepo.setLastBalance(event.resultText, System.currentTimeMillis()) }; onSessionEnd() } + is ActionEvent.Error -> { _sessionState.value = SessionState.Failed(event.message, event.resultText); overlayController?.showError("Check failed", event.message); onSessionEnd() } + else -> Unit } } } - val result = actionRun.result.await() - if (!result.success && _sessionState.value is SessionState.Running) { - _sessionState.value = SessionState.Failed( - message = "Balance check failed", - resultText = result.resultText - ) - onSessionEnd() - } + if (!result.success && _sessionState.value is SessionState.Running) { _sessionState.value = SessionState.Failed("Balance check failed", result.resultText); onSessionEnd() } } } - /** - * Cancels the current balance check session. - */ - fun cancelSession() { - sessionJob?.cancel() - sessionJob = null - overlayController?.hide() - _sessionState.value = SessionState.Idle - onSessionEnd() + private fun cleanCarrierText(text: String): String { + val lines = text.split("\n").filter { it.isNotBlank() && !it.contains(Regex("^\\d+[.)]")) } + return if (lines.isEmpty()) text.trim() else lines.joinToString("\n").trim() } - fun dismissSession() { - _sessionState.value = SessionState.Idle - // Clear PIN so a retry forces fresh entry. - _uiState.update { it.copy(pin = "") } - } - - fun dismissSnackbar() { - _snackbar.value = null - } - - private fun onSessionEnd() { - _uiState.update { it.copy(isSessionActive = false) } - viewModelScope.launch { - delay(500) - _uiState.update { it.copy(pin = "") } - } - } + fun cancelSession() { sessionJob?.cancel(); sessionJob = null; overlayController?.hide(); _sessionState.value = SessionState.Idle; onSessionEnd() } + fun dismissSession() { _sessionState.value = SessionState.Idle; _uiState.update { it.copy(pin = "") } } + fun dismissSnackbar() { _snackbar.value = null } + override fun onCleared() { super.onCleared(); _uiState.update { it.copy(pin = "") } } + private fun onSessionEnd() { _uiState.update { it.copy(isSessionActive = false) }; pendingBalance = null; _simOptions.value = null; ussdEngine.setPreferredSim(null); viewModelScope.launch { delay(500); _uiState.update { it.copy(pin = "") } } } } diff --git a/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt b/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt index 9dea25c..7e7aeef 100644 --- a/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt +++ b/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt @@ -2,6 +2,8 @@ package com.offpay.app.presentation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.offpay.app.data.Contact +import com.offpay.app.data.ContactRepository import com.offpay.app.data.HistoryRepository import com.offpay.app.data.PreferencesRepository import com.offpay.app.domain.ActionEvent @@ -12,7 +14,8 @@ import com.offpay.app.domain.FormField import com.offpay.app.domain.InputValidator import com.offpay.app.domain.OperationMode import com.offpay.app.domain.SessionState -import com.offpay.app.domain.UpiData +import com.offpay.app.domain.SimInfo +import com.offpay.app.domain.UssdEnginePort import com.offpay.app.domain.UpiParser import com.offpay.app.platform.CarrierDetector import com.offpay.app.platform.OverlayController @@ -22,22 +25,26 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -/** - * UI state for the Pay screen form. PIN is captured inline on the form - * itself in a highlighted "ENTER UPI PIN" section — no dedicated screen. - */ +enum class PayTargetType { VPA, MOBILE_NUMBER } + data class PayUiState( + val targetType: PayTargetType = PayTargetType.VPA, val vpa: String = "", val payeeName: String = "", val amount: String = "", val note: String = "", val pin: String = "", + val mobileNumber: String = "", val errors: Map = emptyMap(), - val isSessionActive: Boolean = false + val isSessionActive: Boolean = false, + val contacts: List = emptyList(), + val contactSearchQuery: String = "", + val simPickerTitle: String = "Choose SIM" ) /** @@ -62,7 +69,9 @@ class PayViewModel( private val actionRunner: ActionRunner, private val historyRepo: HistoryRepository, private val prefsRepo: PreferencesRepository, + private val contactRepo: ContactRepository, private val carrierDetector: CarrierDetector, + private val ussdEngine: UssdEnginePort, private val overlayController: OverlayController? = null, private val onDialerFallback: (String) -> Unit = {}, private val clipboardWriter: (String) -> Unit = {}, @@ -75,6 +84,7 @@ class PayViewModel( */ private val systemToast: (String) -> Unit = {} ) : ViewModel() { + private data class PendingPayment(val recipient: String, val amount: String, val note: String, val pin: String) private val _uiState = MutableStateFlow(PayUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -85,339 +95,254 @@ class PayViewModel( private val _snackbar = MutableStateFlow(null) val snackbar: StateFlow = _snackbar.asStateFlow() - /** Currently selected operation mode, observed for routing payments. */ + private val _simOptions = MutableStateFlow?>(null) + val simOptions: StateFlow?> = _simOptions.asStateFlow() + val operationMode: StateFlow = prefsRepo.operationMode .stateIn(viewModelScope, SharingStarted.Eagerly, OperationMode.AUTO) + val upiPinLength: StateFlow = prefsRepo.upiPinLength + .stateIn(viewModelScope, SharingStarted.Eagerly, 6) + + val defaultSimSlot: StateFlow = prefsRepo.defaultSimSlot + .stateIn(viewModelScope, SharingStarted.Eagerly, -1) + private var activeRun: ActionRun? = null private var sessionJob: Job? = null + private var pendingPayment: PendingPayment? = null - /** - * Re-prefills the form from a past transaction (used by "Pay again" - * in the History screen). Drops any in-memory PIN so the user must - * re-enter it; we never silently re-execute payments. - */ - fun prefillFromTransaction( - vpa: String, - amount: String, - note: String? - ) { - _uiState.update { current -> - current.copy( - vpa = vpa, - amount = amount, - note = note ?: "", - pin = "", - errors = emptyMap() - ) + init { + // Startup Hardware Sync + viewModelScope.launch { + val sims = carrierDetector.getAvailableSims() + if (sims.isEmpty()) return@launch + + val defaultSlot = prefsRepo.defaultSimSlot.first() + val selectedCarrier = prefsRepo.selectedSimCarrier.first() + val currentSig = getSimSignature(sims) + val lastKnownSig = prefsRepo.lastKnownSimIds.first() + + if (sims.size == 1) { + val onlySim = sims.first() + if (lastKnownSig != currentSig) { + _snackbar.value = "Using only available SIM (${onlySim.carrierName})" + prefsRepo.setDefaultSimSlot(onlySim.slotIndex) + prefsRepo.setSelectedSimCarrier(onlySim.carrierName ?: "Unknown") + prefsRepo.setLastKnownSimIds(currentSig) + } + return@launch + } + + // If a default was set but hardware changed, ask immediately on startup. + if (defaultSlot != -1 && selectedCarrier != null && lastKnownSig != currentSig) { + _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") } + _simOptions.value = sims + } } } - /** - * Parses QR scanned data and autofills form fields for all non-null values. - */ - fun onQrScanned(raw: String) { - val upiData: UpiData = UpiParser.parse(raw) ?: return - _uiState.update { current -> - current.copy( - vpa = upiData.vpa, - payeeName = upiData.payeeName ?: current.payeeName, - amount = upiData.amount ?: current.amount, - note = upiData.transactionNote ?: current.note, - errors = emptyMap() - ) + private fun getSimSignature(sims: List): String { + return sims.sortedBy { it.slotIndex } + .joinToString("|") { "${it.slotIndex}:${it.carrierName ?: "Unknown"}" } + } + + fun onSimSelected(simInfo: SimInfo) { + val isHardwareChange = _uiState.value.simPickerTitle.contains("change") + _simOptions.value = null + + viewModelScope.launch { + // A choice in the "SIM Change" dialog makes the SIM permanent. + if (isHardwareChange) { + val sims = carrierDetector.getAvailableSims() + prefsRepo.setDefaultSimSlot(simInfo.slotIndex) + prefsRepo.setSelectedSimCarrier(simInfo.carrierName ?: "Unknown") + prefsRepo.setLastKnownSimIds(getSimSignature(sims)) + } + + val pending = pendingPayment + if (pending != null) { + pendingPayment = null + ussdEngine.setPreferredSim(simInfo) + runPayment(pending.recipient, pending.amount, pending.note, pending.pin) + } } } - /** - * Updates one or more form fields. Pass `null` to leave a field unchanged. - * Editing a field clears its validation error so the highlight goes away - * as soon as the user starts fixing it. - */ - fun onFormFieldChanged( - vpa: String? = null, - amount: String? = null, - note: String? = null - ) { + fun onAskEveryTimeSelected() { + _simOptions.value = null + viewModelScope.launch { + val sims = carrierDetector.getAvailableSims() + // Permanent choice to disable default + prefsRepo.setDefaultSimSlot(-1) + prefsRepo.setSelectedSimCarrier(null) + prefsRepo.setLastKnownSimIds(getSimSignature(sims)) + + val pending = pendingPayment + if (pending != null) { + pendingPayment = null + _uiState.update { it.copy(simPickerTitle = "Choose SIM") } + _simOptions.value = sims + } + } + } + + fun dismissSimSelection() { + pendingPayment = null + _simOptions.value = null + } + + private suspend fun handleSimDetection(sims: List): SimInfo? { + val defaultSlot = prefsRepo.defaultSimSlot.first() + val selectedCarrier = prefsRepo.selectedSimCarrier.first() + val lastSig = prefsRepo.lastKnownSimIds.first() + val currentSig = getSimSignature(sims) + + if (sims.size == 1) return sims.first() + + // Block transaction if hardware doesn't match saved signature + if (defaultSlot != -1 && selectedCarrier != null) { + if (lastSig != currentSig) { + _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") } + return null + } + val currentInSlot = sims.find { it.slotIndex == defaultSlot } + if (currentInSlot != null && currentInSlot.carrierName == selectedCarrier) { + return currentInSlot + } else { + _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") } + return null + } + } + + _uiState.update { it.copy(simPickerTitle = "Choose SIM") } + return null + } + + fun onTargetTypeChanged(targetType: PayTargetType) { + _uiState.update { it.copy(targetType = targetType, errors = it.errors - FormField.VPA - FormField.MOBILE_NUMBER) } + } + + fun syncContacts() { viewModelScope.launch { _uiState.update { it.copy(contacts = contactRepo.fetchContacts()) } } } + fun onContactSearch(query: String) { _uiState.update { it.copy(contactSearchQuery = query) } } + fun onContactSelected(contact: Contact) { _uiState.update { it.copy(mobileNumber = contact.phoneNumber, contactSearchQuery = "") } } + + fun prefillFromTransaction(vpa: String, amount: String, note: String?) { + _uiState.update { it.copy(vpa = vpa, amount = amount, note = note ?: "", pin = "", errors = emptyMap()) } + } + + fun onQrScanned(raw: String) { + val upiData = UpiParser.parse(raw) ?: return + _uiState.update { it.copy(vpa = upiData.vpa, payeeName = upiData.payeeName ?: it.payeeName, amount = upiData.amount ?: it.amount, note = upiData.transactionNote ?: it.note, errors = emptyMap()) } + } + + fun onFormFieldChanged(vpa: String? = null, amount: String? = null, mobileNumber: String? = null, note: String? = null) { _uiState.update { current -> val newErrors = current.errors.toMutableMap() if (vpa != null) newErrors.remove(FormField.VPA) if (amount != null) newErrors.remove(FormField.AMOUNT) - current.copy( - vpa = vpa ?: current.vpa, - amount = amount ?: current.amount, - note = note ?: current.note, - errors = newErrors - ) + if (mobileNumber != null) newErrors.remove(FormField.MOBILE_NUMBER) + current.copy(vpa = vpa ?: current.vpa, amount = amount ?: current.amount, mobileNumber = mobileNumber ?: current.mobileNumber, note = note ?: current.note, errors = newErrors) } } - /** - * Inline PIN editing. Strips non-digits and caps at 6. Clears any - * existing PIN error so the user sees the highlight clear as they type. - */ fun onPinChanged(pin: String) { - val digits = pin.filter { it.isDigit() }.take(6) - _uiState.update { current -> - current.copy( - pin = digits, - errors = current.errors - FormField.PIN - ) - } + val maxLength = upiPinLength.value + val digits = pin.filter { it.isDigit() }.take(maxLength) + _uiState.update { it.copy(pin = digits, errors = it.errors - FormField.PIN) } } - /** - * Attempts to start a payment. Validates the form, then either: - * - Manual mode: copies VPA to clipboard, opens dialer, resets state. - * - Advanced/Auto: runs the ActionRunner via [runPayment]. - * - * Auto-fires once the user types a 6-digit PIN (the screen calls this). - * Manual taps of the Pay button at 4-5 digits also reach here. - */ fun attemptPayment() { val state = _uiState.value val mode = operationMode.value - - // For non-manual modes the accessibility service must be alive. if (mode != OperationMode.MANUAL && !actionRunner.isServiceEnabled()) { - _sessionState.value = SessionState.Failed( - message = "Accessibility service is disabled. Enable it in Settings.", - resultText = "" - ) + _sessionState.value = SessionState.Failed(message = "Accessibility service is disabled. Enable it in Settings.", resultText = "") return } - - // Validate VPA and amount up front for every mode (manual still - // needs a valid VPA — that's what we copy to the clipboard). val errors = mutableMapOf() - InputValidator.validateVpa(state.vpa).also { - if (!it.isValid) errors[FormField.VPA] = it.errorMessage!! - } - InputValidator.validateAmount(state.amount).also { - if (!it.isValid) errors[FormField.AMOUNT] = it.errorMessage!! + when (state.targetType) { + PayTargetType.VPA -> InputValidator.validateVpa(state.vpa).also { if (!it.isValid) errors[FormField.VPA] = it.errorMessage!! } + PayTargetType.MOBILE_NUMBER -> InputValidator.validateMobileNumber(state.mobileNumber).also { if (!it.isValid) errors[FormField.MOBILE_NUMBER] = it.errorMessage!! } } + InputValidator.validateAmount(state.amount).also { if (!it.isValid) errors[FormField.AMOUNT] = it.errorMessage!! } + if (mode != OperationMode.MANUAL) InputValidator.validatePin(state.pin).also { if (!it.isValid) errors[FormField.PIN] = it.errorMessage!! } - // PIN is required only for automated modes; manual mode lets the - // user enter the PIN themselves in the dialer. - if (mode != OperationMode.MANUAL) { - InputValidator.validatePin(state.pin).also { - if (!it.isValid) errors[FormField.PIN] = it.errorMessage!! - } - } - - if (errors.isNotEmpty()) { - _uiState.update { it.copy(errors = errors) } - return - } + if (errors.isNotEmpty()) { _uiState.update { it.copy(errors = errors) }; return } _uiState.update { it.copy(errors = emptyMap()) } - val cleanedVpa = state.vpa.trim() - val cleanedAmount = state.amount.trim() - val cleanedNote = state.note - + val recipient = if (state.targetType == PayTargetType.VPA) state.vpa.trim() else state.mobileNumber.trim() if (mode == OperationMode.MANUAL) { - // Copy the recipient VPA so the user can paste it after the - // dialer opens (most carriers' *99# UI accepts a paste into the - // first prompt). Then fire the dialer with *99*1*3# prefilled. - clipboardWriter(cleanedVpa) - // In-app snackbar (visible only on the brief moment before the - // dialer takes the foreground). - _snackbar.value = "UPI ID copied — paste it on the *99# prompt" - onDialerFallback("*99*1*3#") - // System-level Toast so the same nudge actually surfaces on - // top of the dialer, where the in-app snackbar can't reach. - // We fire two toasts in sequence so the user sees one when - // the dialer first appears, and one a couple seconds later - // in case they missed the first. - systemToast("UPI ID copied — please paste it from the clipboard on the *99# prompt") - viewModelScope.launch { - delay(1_800) - systemToast("Paste the UPI ID from clipboard when the carrier asks for it") - } - // Reset transient session state — the user owns the dialer flow. - _sessionState.value = SessionState.Idle - return + clipboardWriter(recipient); onDialerFallback("*99*1*3#"); _sessionState.value = SessionState.Idle; return } - - runPayment(cleanedVpa, cleanedAmount, cleanedNote, state.pin) + maybeRequestSimThenRun(recipient, state.amount.trim(), state.note, state.pin) } - /** - * Validates inputs and starts the USSD payment session if valid. - * Routes through ADVANCED or AUTO based on the user's preference. - */ - private fun runPayment(vpa: String, amount: String, note: String, pin: String) { - val mode = operationMode.value - - _uiState.update { it.copy(errors = emptyMap(), isSessionActive = true) } - _sessionState.value = SessionState.Running( - label = "Starting payment", - stepIndex = 0, - total = Actions.SendUpi.steps.size - ) - - // Show the appropriate overlay based on mode. - when (mode) { - OperationMode.AUTO -> { - overlayController?.show( - title = "Paying ₹$amount", - subtitle = "to $vpa", - stepLabel = "STARTING" - ) - } - OperationMode.ADVANCED -> { - overlayController?.showMinimal( - progress = 0, - total = Actions.SendUpi.steps.size, - label = "PAYING" - ) - } - OperationMode.MANUAL -> { - // Shouldn't reach here; attemptPayment short-circuits MANUAL. + private fun maybeRequestSimThenRun(recipient: String, amount: String, note: String, pin: String) { + viewModelScope.launch { + val sims = carrierDetector.getAvailableSims() + if (sims.isEmpty()) { runPayment(recipient, amount, note, pin); return@launch } + if (operationMode.value == OperationMode.MANUAL) { runPayment(recipient, amount, note, pin); return@launch } + + val targetSim = handleSimDetection(sims) + if (targetSim != null) { + ussdEngine.setPreferredSim(targetSim) + runPayment(recipient, amount, note, pin) + } else { + pendingPayment = PendingPayment(recipient, amount, note, pin) + _simOptions.value = sims } } + } + + private fun runPayment(recipient: String, amount: String, note: String, pin: String) { + val action = if (_uiState.value.targetType == PayTargetType.VPA) Actions.SendUpi else Actions.SendToMobile + _uiState.update { it.copy(isSessionActive = true) } + _sessionState.value = SessionState.Running(label = "Starting payment", stepIndex = 0, total = action.steps.size) + overlayController?.show(title = "Paying ₹$amount", subtitle = recipient, stepLabel = "STARTING") overlayController?.onCancel = { cancelSession() } - val vars = mapOf( - "vpa" to vpa, - "amount" to amount, - "note" to note.ifBlank { "Payment" }, - "pin" to pin - ) + val vars = if (_uiState.value.targetType == PayTargetType.VPA) mapOf("vpa" to recipient, "amount" to amount, "note" to note.ifBlank { "Payment" }, "pin" to pin) + else mapOf("mobileNumber" to recipient, "amount" to amount, "note" to note.ifBlank { "Payment" }, "pin" to pin) - val run = actionRunner.runAction(Actions.SendUpi, vars, viewModelScope) + val run = actionRunner.runAction(action, vars, viewModelScope) activeRun = run - sessionJob = viewModelScope.launch { launch { + var lastCarrierPrompt: String? = null run.events.collect { event -> - // Once we've reached a terminal session state (Success - // or Failed), don't let any straggling events from the - // runner overwrite it. ActionRunner already guards - // its own re-entrancy, but this is belt-and-braces for - // any synthetic frame that races through. - val current = _sessionState.value - if (current is SessionState.Success || current is SessionState.Failed) { - return@collect - } + if (_sessionState.value !is SessionState.Running) return@collect when (event) { + is ActionEvent.Frame -> lastCarrierPrompt = cleanCarrierText(event.frame.text) is ActionEvent.Progress -> { - _sessionState.value = SessionState.Running( - label = event.label ?: "Processing", - stepIndex = event.stepIndex, - total = event.total - ) - when (mode) { - OperationMode.AUTO -> overlayController?.update( - title = "Paying ₹$amount", - subtitle = "to $vpa", - stepLabel = (event.label ?: "Processing").uppercase() - ) - OperationMode.ADVANCED -> overlayController?.updateMinimal( - progress = event.stepIndex, - total = event.total, - label = "PAYING" - ) - else -> Unit + _sessionState.value = SessionState.Running(label = event.label ?: "Processing", stepIndex = event.stepIndex, total = event.total, carrierText = lastCarrierPrompt) + if (action.steps.getOrNull(event.stepIndex)?.autoSubmit == false) { + overlayController?.onConfirm = { viewModelScope.launch { ussdEngine.submitFilledReply() } } + overlayController?.show(title = "Paying ₹$amount", subtitle = lastCarrierPrompt ?: recipient, stepLabel = "CONFIRM") + } else { + overlayController?.onConfirm = null + overlayController?.show(title = "Paying ₹$amount", subtitle = lastCarrierPrompt ?: recipient, stepLabel = (event.label ?: "Processing").uppercase()) } } - is ActionEvent.Done -> { - _sessionState.value = SessionState.Success(resultText = event.resultText) - overlayController?.hide() - onSessionEnded() - launch { - historyRepo.recordTransaction( - vpa = vpa, - payeeName = _uiState.value.payeeName.ifBlank { null }, - amount = amount, - note = note.ifBlank { null }, - carrierReply = event.resultText - ) - } - } - is ActionEvent.Error -> { - _sessionState.value = SessionState.Failed( - message = event.message, - resultText = event.resultText - ) - overlayController?.showError( - title = "Payment failed", - message = event.message - ) - onSessionEnded() - } - else -> { /* Frame/Reply — no UI state change needed */ } + is ActionEvent.Done -> { _sessionState.value = SessionState.Success(event.resultText); overlayController?.hide(); onSessionEnded(); launch { historyRepo.recordTransaction(recipient, _uiState.value.payeeName.ifBlank { null }, amount, note.ifBlank { null }, event.resultText) } } + is ActionEvent.Error -> { _sessionState.value = SessionState.Failed(event.message, event.resultText); overlayController?.showError("Payment failed", event.message); onSessionEnded() } + else -> Unit } } } - val result = run.result.await() - if (!result.success && _sessionState.value is SessionState.Running) { - _sessionState.value = SessionState.Failed( - message = result.resultText, - resultText = result.resultText - ) - overlayController?.showError(title = "Payment failed", message = result.resultText) - onSessionEnded() - } + if (!result.success && _sessionState.value is SessionState.Running) { _sessionState.value = SessionState.Failed(result.resultText, result.resultText); onSessionEnded() } } } - /** Cancel the active session. */ - fun cancelSession() { - viewModelScope.launch { - activeRun?.cancel?.invoke() - overlayController?.hide() - _sessionState.value = SessionState.Idle - onSessionEnded() - } + private fun cleanCarrierText(text: String): String { + val lines = text.split("\n").filter { line -> line.isNotBlank() && !line.contains(Regex("^\\d+[.)]")) } + return if (lines.isEmpty()) text.trim() else lines.joinToString("\n").trim() } - /** Dismiss a terminal (success/failed) session card and return to the form. */ - fun dismissSession() { - val wasSuccess = _sessionState.value is SessionState.Success - _sessionState.value = SessionState.Idle - if (wasSuccess) { - // After success, clear the form so the user starts fresh. - _uiState.update { PayUiState() } - } else { - // On failure, just clear the PIN so the user can re-enter it. - _uiState.update { it.copy(pin = "") } - } - } - - fun clearFieldError(field: FormField) { - _uiState.update { current -> - if (current.errors.containsKey(field)) { - current.copy(errors = current.errors - field) - } else current - } - } - - fun dismissSnackbar() { - _snackbar.value = null - } - - fun onNavigateAway() { - // Defensive: if the user backs out mid-form, drop the in-memory PIN. - _uiState.update { it.copy(pin = "") } - } - - fun onBackground() { - _uiState.update { it.copy(pin = "") } - } - - /** - * Called whenever the session reaches a terminal state. Marks the - * session as inactive and schedules a wipe of the PIN within 500ms so - * it never lingers in memory longer than necessary. - */ - private fun onSessionEnded() { - _uiState.update { it.copy(isSessionActive = false) } - activeRun = null - viewModelScope.launch { - delay(500) - _uiState.update { it.copy(pin = "") } - } - } + fun cancelSession() { viewModelScope.launch { activeRun?.cancel?.invoke(); _sessionState.value = SessionState.Idle; onSessionEnded() } } + fun dismissSession() { if (_sessionState.value is SessionState.Success) _uiState.update { PayUiState() } else _uiState.update { it.copy(pin = "") }; _sessionState.value = SessionState.Idle } + fun dismissSnackbar() { _snackbar.value = null } + fun onNavigateAway() { _uiState.update { it.copy(pin = "") } } + fun onBackground() { _uiState.update { it.copy(pin = "") } } + override fun onCleared() { super.onCleared(); _uiState.update { it.copy(pin = "") } } + private fun onSessionEnded() { _uiState.update { it.copy(isSessionActive = false) }; activeRun = null; pendingPayment = null; _simOptions.value = null; ussdEngine.setPreferredSim(null); viewModelScope.launch { delay(500); _uiState.update { it.copy(pin = "") } } } } diff --git a/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt b/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt index 4c22055..2b13d8c 100644 --- a/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt +++ b/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBalance import androidx.compose.material.icons.filled.Payments @@ -71,9 +72,12 @@ fun OffPayApp() { val scope = rememberCoroutineScope() if (!firstLaunchDone) { - OnboardingFlow(onComplete = { - scope.launch { app.prefsRepo.setFirstLaunchComplete(true) } - }) + OnboardingFlow( + prefsRepo = app.prefsRepo, + onComplete = { + scope.launch { app.prefsRepo.setFirstLaunchComplete(true) } + } + ) } else { MainScaffold( app = app, @@ -111,6 +115,7 @@ private fun MainScaffold(app: OffPayApplication, onReplayOnboarding: () -> Unit) Modifier .fillMaxSize() .background(NeoPopColors.Black) + .navigationBarsPadding() ) { Box(Modifier.weight(1f)) { NavHost( @@ -283,7 +288,9 @@ private fun rememberPayViewModel(app: OffPayApplication): PayViewModel { actionRunner = app.actionRunner, historyRepo = app.historyRepo, prefsRepo = app.prefsRepo, + contactRepo = app.contactRepo, carrierDetector = app.carrierDetector, + ussdEngine = app.ussdEngine, overlayController = app.overlayController, onDialerFallback = { code -> val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:$code")) @@ -303,6 +310,8 @@ private fun rememberBalanceViewModel(app: OffPayApplication): BalanceViewModel { BalanceViewModel( actionRunner = app.actionRunner, prefsRepo = app.prefsRepo, + carrierDetector = app.carrierDetector, + ussdEngine = app.ussdEngine, overlayController = app.overlayController, onDialerFallback = { code -> val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:$code")) diff --git a/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt b/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt index 5d5367e..6d56eb7 100644 --- a/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt +++ b/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt @@ -31,6 +31,7 @@ data class PermissionStatus( val callPhone: Boolean, val readPhoneState: Boolean, val camera: Boolean, + val contacts: Boolean, val accessibility: Boolean, val overlay: Boolean ) { @@ -73,6 +74,7 @@ private fun currentStatus(context: Context): PermissionStatus { callPhone = isGranted(context, Manifest.permission.CALL_PHONE), readPhoneState = isGranted(context, Manifest.permission.READ_PHONE_STATE), camera = isGranted(context, Manifest.permission.CAMERA), + contacts = isGranted(context, Manifest.permission.READ_CONTACTS), accessibility = isAccessibilityServiceEnabled(context), overlay = Settings.canDrawOverlays(context) ) @@ -163,5 +165,7 @@ class PermissionLaunchers( arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_PHONE_STATE) ) + fun requestContacts() = single.launch(Manifest.permission.READ_CONTACTS) + fun requestCamera() = single.launch(Manifest.permission.CAMERA) } diff --git a/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt index c008439..0502635 100644 --- a/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt +++ b/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ErrorOutline @@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -84,9 +86,12 @@ fun BalanceScreen( val ui by viewModel.uiState.collectAsState() val session by viewModel.sessionState.collectAsState() val mode by viewModel.operationMode.collectAsState() + val pinLength by viewModel.upiPinLength.collectAsState() + val defaultSimSlot by viewModel.defaultSimSlot.collectAsState() val last by viewModel.lastResult.collectAsState() val snackbar by viewModel.snackbar.collectAsState() val txns by historyViewModel.transactions.collectAsState() + val simOptions by viewModel.simOptions.collectAsState() val pinFocus = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current @@ -110,14 +115,16 @@ fun BalanceScreen( keyboard?.show() } } - // Auto-fire when the user has tapped in 6 digits. + // Auto-fire disabled at user request to prevent accidental submissions. + /* LaunchedEffect(ui.pin, mode) { - if (ui.pin.length == 6 && mode != OperationMode.MANUAL && session is SessionState.Idle) { + if (ui.pin.length == pinLength && mode != OperationMode.MANUAL && session is SessionState.Idle) { delay(250) keyboard?.hide() viewModel.attemptCheckBalance() } } + */ Box( modifier @@ -160,6 +167,7 @@ fun BalanceScreen( mode = mode, pin = ui.pin, pinError = ui.pinError, + pinLength = pinLength, onTapPin = { runCatching { pinFocus.requestFocus() } keyboard?.show() @@ -219,6 +227,16 @@ fun BalanceScreen( } } + if (!simOptions.isNullOrEmpty()) { + SimPickerDialog( + sims = simOptions.orEmpty(), + title = ui.simPickerTitle, + onSelect = viewModel::onSimSelected, + onAskEveryTime = if (defaultSimSlot != -1) viewModel::onAskEveryTimeSelected else null, + onDismiss = viewModel::dismissSimSelection + ) + } + // Hidden PIN field rendered as a SIBLING of the main content Column, // not nested inside it. This keeps it in the layout tree (so it can // hold focus and surface the IME) while staying out of any @@ -244,7 +262,16 @@ fun BalanceScreen( .focusRequester(pinFocus) .size(1.dp) .alpha(0f), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboard?.hide() + viewModel.attemptCheckBalance() + } + ), cursorBrush = SolidColor(NeoPopColors.Black), singleLine = true, textStyle = TextStyle(color = NeoPopColors.Black) @@ -310,6 +337,7 @@ private fun IdleHero( mode: OperationMode, pin: String, pinError: String?, + pinLength: Int, onTapPin: () -> Unit ) { Column( @@ -359,7 +387,8 @@ private fun IdleHero( BalancePinSection( pin = pin, onTap = onTapPin, - error = pinError + error = pinError, + length = pinLength ) } } @@ -398,7 +427,8 @@ private fun ManualHero() { private fun BalancePinSection( pin: String, onTap: () -> Unit, - error: String? + error: String?, + length: Int ) { NeoPopAccentCard( accent = NeoPopColors.Accent, @@ -415,7 +445,7 @@ private fun BalancePinSection( Spacer(Modifier.height(14.dp)) PinBoxes( value = pin, - length = 6, + length = length, modifier = Modifier.fillMaxWidth() ) if (error != null) { @@ -452,10 +482,18 @@ private fun SessionRunning(state: SessionState.Running) { NeoPopCard(modifier = Modifier.fillMaxWidth()) { Column { Text( - text = state.label, - style = NeoPopType.HeadlineLarge, + text = state.carrierText ?: state.label, + style = if (state.carrierText != null) NeoPopType.TitleLarge else NeoPopType.HeadlineLarge, color = NeoPopColors.TextPrimary ) + if (state.carrierText != null && state.carrierText != state.label) { + Spacer(Modifier.height(4.dp)) + Text( + text = state.label, + style = NeoPopType.LabelMedium, + color = NeoPopColors.Accent.copy(alpha = 0.7f) + ) + } Spacer(Modifier.height(8.dp)) Text( text = "Step ${state.stepIndex + 1} of ${state.total}", @@ -526,7 +564,7 @@ private fun FailedCard(message: String, resultText: String, onRetry: () -> Unit) verticalArrangement = Arrangement.Center ) { NeoPopAccentCard(accent = NeoPopColors.Danger, modifier = Modifier.fillMaxWidth()) { - Column { + Column(Modifier.fillMaxWidth()) { Box( Modifier .size(48.dp) diff --git a/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt index 10bf472..302e588 100644 --- a/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt +++ b/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt @@ -16,8 +16,10 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -28,9 +30,11 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.QrCodeScanner @@ -41,6 +45,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -59,7 +65,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.offpay.app.domain.FormField @@ -84,6 +92,8 @@ import com.offpay.app.presentation.ui.theme.NeoPopType import kotlinx.coroutines.delay import kotlinx.coroutines.launch import androidx.compose.runtime.withFrameNanos +import com.offpay.app.presentation.PayTargetType +import com.offpay.app.presentation.ui.components.NeoPopToggle @Composable fun PayScreen( @@ -97,13 +107,16 @@ fun PayScreen( val ui by viewModel.uiState.collectAsState() val session by viewModel.sessionState.collectAsState() val mode by viewModel.operationMode.collectAsState() + val pinLength by viewModel.upiPinLength.collectAsState() + val defaultSimSlot by viewModel.defaultSimSlot.collectAsState() val snackbar by viewModel.snackbar.collectAsState() + val simOptions by viewModel.simOptions.collectAsState() // ── OffPay-wordmark easter egg ──────────────────────────────────── // Five quick taps on the wordmark trigger a falling-money rain across // the screen. The counter resets if the user pauses for >1.4s. - var wordmarkTaps by remember { mutableStateOf(0) } - var lastWordmarkTap by remember { mutableStateOf(0L) } + var wordmarkTaps by remember { mutableIntStateOf(0) } + var lastWordmarkTap by remember { mutableLongStateOf(0L) } var showMoneyRain by remember { mutableStateOf(false) } LaunchedEffect(wordmarkTaps) { if (wordmarkTaps in 1..4) { @@ -133,13 +146,19 @@ fun PayScreen( is SessionState.Idle -> PayForm( ui = ui, mode = mode, + pinLength = pinLength, permissions = permissions, + onTargetTypeChanged = viewModel::onTargetTypeChanged, onPay = { viewModel.attemptPayment() }, onScan = onNavigateScan, onHistory = onNavigateHistory, onVpa = { v -> viewModel.onFormFieldChanged(vpa = v) }, onAmount = { v -> viewModel.onFormFieldChanged(amount = v) }, onNote = { v -> viewModel.onFormFieldChanged(note = v) }, + onMobileNumber = { v -> viewModel.onFormFieldChanged(mobileNumber = v) }, + onContactSearch = viewModel::onContactSearch, + onContactSelected = viewModel::onContactSelected, + onSyncContacts = viewModel::syncContacts, onPinChanged = viewModel::onPinChanged, onWordmarkTap = onWordmarkTap ) @@ -171,6 +190,16 @@ fun PayScreen( onDone = { showMoneyRain = false } ) } + + if (!simOptions.isNullOrEmpty()) { + SimPickerDialog( + sims = simOptions.orEmpty(), + title = ui.simPickerTitle, + onSelect = viewModel::onSimSelected, + onAskEveryTime = if (defaultSimSlot != -1) viewModel::onAskEveryTimeSelected else null, + onDismiss = viewModel::dismissSimSelection + ) + } } } @@ -222,12 +251,18 @@ private fun SnackbarBanner( private fun PayForm( ui: PayUiState, mode: OperationMode, + pinLength: Int, permissions: PermissionStatus, + onTargetTypeChanged: (PayTargetType) -> Unit, onPay: () -> Unit, onScan: () -> Unit, onHistory: () -> Unit, onVpa: (String) -> Unit, onAmount: (String) -> Unit, + onMobileNumber: (String) -> Unit, + onContactSearch: (String) -> Unit, + onContactSelected: (com.offpay.app.data.Contact) -> Unit, + onSyncContacts: () -> Unit, onNote: (String) -> Unit, onPinChanged: (String) -> Unit, onWordmarkTap: () -> Unit @@ -239,15 +274,6 @@ private fun PayForm( val pinFocus = remember { FocusRequester() } val scope = rememberCoroutineScope() - // Defer the initial focus request until after the first layout pass - // completes — requesting focus during composition (or before parent - // placement) crashes the BringIntoView responder on Compose-foundation. - LaunchedEffect(Unit) { - withFrameNanos { /* one frame: layout placement is now committed */ } - delay(50) - runCatching { amountFocus.requestFocus() } - } - // The PIN section only appears AFTER the user taps the primary Pay // button (and the form's VPA + amount validate cleanly). Auto-popping // it while the user was still typing the UPI ID was confusing — the @@ -256,10 +282,14 @@ private fun PayForm( // If the user edits the form back into an invalid state, hide the // section again so the next "Pay" tap re-validates cleanly. - val vpaLooksValid = ui.vpa.contains('@') && ui.vpa.substringAfter('@').isNotEmpty() + val recipientLooksValid = when (ui.targetType) { + PayTargetType.VPA -> ui.vpa.contains('@') && ui.vpa.substringAfter('@').isNotEmpty() + PayTargetType.MOBILE_NUMBER -> ui.mobileNumber.length == 10 + } val amountLooksValid = ui.amount.toDoubleOrNull()?.let { it > 0.0 } == true - LaunchedEffect(vpaLooksValid, amountLooksValid, mode) { - if (!vpaLooksValid || !amountLooksValid || mode == OperationMode.MANUAL) { + + LaunchedEffect(recipientLooksValid, amountLooksValid, mode, ui.targetType) { + if (!recipientLooksValid || !amountLooksValid || mode == OperationMode.MANUAL) { showPinSection = false } } @@ -287,14 +317,23 @@ private fun PayForm( } } - // Auto-fire when 6 digits entered and form is valid (debounced 250ms). + // Auto-fire disabled at user request. Users now manually tap the Pay button + // or press the keyboard's Done key. + /* LaunchedEffect(ui.pin) { - if (ui.pin.length == 6 && pinSectionVisible) { + if (ui.pin.length == pinLength && pinSectionVisible) { delay(250) keyboard?.hide() onPay() } } + */ + + LaunchedEffect(permissions.contacts, ui.targetType) { + if (permissions.contacts && ui.targetType == PayTargetType.MOBILE_NUMBER && ui.contacts.isEmpty()) { + onSyncContacts() + } + } // Click handler for the primary Pay button. In MANUAL mode it falls // straight through to the ViewModel (clipboard + dialer fallback). @@ -307,7 +346,7 @@ private fun PayForm( onPay() return@handler } - if (!vpaLooksValid || !amountLooksValid) { + if (!recipientLooksValid || !amountLooksValid) { // Surface the field-level errors via the ViewModel's normal path. onPay() return@handler @@ -357,21 +396,131 @@ private fun PayForm( Spacer(Modifier.height(24.dp)) Text( - text = "to", + text = "To", style = NeoPopType.LabelMedium, color = NeoPopColors.TextSecondary ) Spacer(Modifier.height(10.dp)) - NeoPopTextField( - value = ui.vpa, - onValueChange = onVpa, - label = "UPI ID", - placeholder = "username@bank", - error = ui.errors[FormField.VPA], - keyboardType = KeyboardType.Email, + + NeoPopToggle( + options = listOf( + PayTargetType.VPA to "UPI ID", + PayTargetType.MOBILE_NUMBER to "Mobile" + ), + selected = ui.targetType, + onSelect = onTargetTypeChanged, modifier = Modifier.fillMaxWidth() ) + + + Spacer(Modifier.height(16.dp)) + when (ui.targetType) { + PayTargetType.VPA -> { + NeoPopTextField( + value = ui.vpa, + onValueChange = onVpa, + label = "UPI ID", + placeholder = "username@bank", + error = ui.errors[FormField.VPA], + keyboardType = KeyboardType.Email, + modifier = Modifier.fillMaxWidth() + ) + } + + PayTargetType.MOBILE_NUMBER -> { + NeoPopTextField( + value = ui.mobileNumber, + onValueChange = { value -> + onMobileNumber(value.filter { it.isDigit() || it.isLetter() || it.isWhitespace() }) + onContactSearch(value) + }, + label = "Mobile Number / Name", + placeholder = "9876543210 or Name", + error = ui.errors[FormField.MOBILE_NUMBER], + keyboardType = KeyboardType.Text, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = "Contacts", + tint = if (permissions.contacts) NeoPopColors.Accent else NeoPopColors.TextMuted, + modifier = Modifier + .size(24.dp) + .clickable { + if (permissions.contacts) { + onSyncContacts() + } else { + launchers.requestContacts() + } + } + ) + } + ) + + // Contact search results + if (ui.contactSearchQuery.isNotEmpty() || (permissions.contacts && ui.mobileNumber.isEmpty())) { + val filteredContacts = remember(ui.contacts, ui.contactSearchQuery) { + if (ui.contactSearchQuery.isBlank()) { + ui.contacts.take(10) + } else { + ui.contacts.filter { + it.name.contains(ui.contactSearchQuery, ignoreCase = true) || + it.phoneNumber.contains(ui.contactSearchQuery) + }.take(10) + } + } + + if (filteredContacts.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + NeoPopCard(modifier = Modifier.fillMaxWidth()) { + Column { + filteredContacts.forEach { contact -> + Row( + Modifier + .fillMaxWidth() + .clickable { + onContactSelected(contact) + keyboard?.hide() + } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(NeoPopColors.SurfaceHigh), + contentAlignment = Alignment.Center + ) { + Text( + text = contact.name.take(1).uppercase(), + style = NeoPopType.LabelMedium, + color = NeoPopColors.Accent + ) + } + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = contact.name, + style = NeoPopType.BodyMedium, + color = NeoPopColors.TextPrimary + ) + Text( + text = contact.phoneNumber, + style = NeoPopType.LabelSmall, + color = NeoPopColors.TextSecondary + ) + } + } + } + } + } + } + } + } + } + Spacer(Modifier.height(16.dp)) NeoPopTextField( @@ -400,7 +549,8 @@ private fun PayForm( scrollState.animateScrollTo(scrollState.maxValue) } }, - error = ui.errors[FormField.PIN] + error = ui.errors[FormField.PIN], + length = pinLength ) } } @@ -460,7 +610,16 @@ private fun PayForm( .focusRequester(pinFocus) .size(1.dp) .alpha(0f), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + keyboard?.hide() + onPayClicked() + } + ), cursorBrush = SolidColor(NeoPopColors.Black), singleLine = true, textStyle = TextStyle(color = NeoPopColors.Black) @@ -487,7 +646,8 @@ private fun PayForm( private fun InlinePinSection( value: String, onTap: () -> Unit, - error: String? + error: String?, + length: Int ) { NeoPopAccentCard( accent = NeoPopColors.Accent, @@ -504,7 +664,7 @@ private fun InlinePinSection( Spacer(Modifier.height(14.dp)) PinBoxes( value = value, - length = 6, + length = length, modifier = Modifier.fillMaxWidth() ) if (error != null) { @@ -675,20 +835,25 @@ private fun AmountInput( val borderColor = if (error != null) NeoPopColors.Danger else NeoPopColors.Accent Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { Text( - text = "you pay", + text = "You pay", style = NeoPopType.LabelMedium, color = NeoPopColors.TextSecondary ) - Spacer(Modifier.height(10.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.height(16.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { Text( text = "₹", style = NeoPopType.MonoLarge.copy( color = NeoPopColors.TextSecondary, - fontSize = 40.sp + fontSize = 48.sp ) ) - Spacer(Modifier.width(8.dp)) + + Spacer(Modifier.width(12.dp)) + BasicTextField( value = value, onValueChange = { v -> @@ -698,35 +863,38 @@ private fun AmountInput( }, modifier = Modifier .focusRequester(focusRequester) - .padding(horizontal = 8.dp), + .width(IntrinsicSize.Min) + .defaultMinSize(minWidth = 40.dp), textStyle = NeoPopType.MonoLarge.copy( color = NeoPopColors.TextPrimary, - fontSize = 56.sp, + fontSize = 64.sp, fontWeight = FontWeight.Black ), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), cursorBrush = SolidColor(NeoPopColors.Accent), singleLine = true, decorationBox = { inner -> - if (value.isEmpty()) { - Text( - text = "0", - style = NeoPopType.MonoLarge.copy( - color = NeoPopColors.TextMuted, - fontSize = 56.sp, - fontWeight = FontWeight.Black + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) { + Text( + text = "0", + style = NeoPopType.MonoLarge.copy( + color = NeoPopColors.TextMuted, + fontSize = 64.sp, + fontWeight = FontWeight.Black + ) ) - ) + } + inner() } - inner() } ) } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(12.dp)) Box( Modifier - .height(2.dp) - .fillMaxWidth(0.6f) + .height(3.dp) + .fillMaxWidth(0.75f) .background(borderColor) ) if (error != null) { @@ -762,10 +930,18 @@ private fun SessionRunningCard(state: SessionState.Running, onCancel: () -> Unit NeoPopCard(modifier = Modifier.fillMaxWidth()) { Column { Text( - text = state.label, - style = NeoPopType.HeadlineLarge, + text = state.carrierText ?: state.label, + style = if (state.carrierText != null) NeoPopType.TitleLarge else NeoPopType.HeadlineLarge, color = NeoPopColors.TextPrimary ) + if (state.carrierText != null && state.carrierText != state.label) { + Spacer(Modifier.height(4.dp)) + Text( + text = state.label, + style = NeoPopType.LabelMedium, + color = NeoPopColors.Accent.copy(alpha = 0.7f) + ) + } Spacer(Modifier.height(8.dp)) Text( text = "Step ${state.stepIndex + 1} of ${state.total}", diff --git a/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt b/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt index 7d65a6c..82ba307 100644 --- a/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt +++ b/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt @@ -130,7 +130,7 @@ internal fun SessionSuccessCard(state: SessionState.Success, onDone: () -> Unit) accent = NeoPopColors.Success, modifier = Modifier.fillMaxWidth() ) { - Column { + Column(Modifier.fillMaxWidth()) { // Hero zone — confetti rendered behind, hero square in front. Box( Modifier @@ -274,7 +274,7 @@ internal fun SessionFailedCard( accent = NeoPopColors.Danger, modifier = Modifier.fillMaxWidth() ) { - Column { + Column(Modifier.fillMaxWidth()) { Box( Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt index 71c3c19..6023201 100644 --- a/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt +++ b/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt @@ -298,7 +298,10 @@ private fun CameraScannerContent( icon = if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff, contentDescription = "Torch", diameter = 56.dp, - onClick = { torchOn = !torchOn } + onClick = { + torchOn = !torchOn + qrManager.toggleTorch(torchOn) + } ) GlassyCircleButton( icon = Icons.Default.PhotoLibrary, diff --git a/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt index b8b2714..6e565b9 100644 --- a/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt @@ -76,6 +76,8 @@ import androidx.compose.ui.unit.dp import com.offpay.app.R import com.offpay.app.data.PreferencesRepository import com.offpay.app.domain.OperationMode +import com.offpay.app.domain.SimInfo +import com.offpay.app.offPayApp import com.offpay.app.presentation.HistoryViewModel import com.offpay.app.presentation.permissions.PermissionStatus import com.offpay.app.presentation.permissions.openAccessibilitySettings @@ -104,9 +106,19 @@ fun SettingsScreen( modifier: Modifier = Modifier ) { val mode by prefsRepo.operationMode.collectAsState(initial = OperationMode.AUTO) + val pinLength by prefsRepo.upiPinLength.collectAsState(initial = 6) + val defaultSim by prefsRepo.defaultSimSlot.collectAsState(initial = -1) + val scope = rememberCoroutineScope() val context = LocalContext.current + val app = context.offPayApp val launchers = rememberPermissionLaunchers() + + var detectedSims by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(Unit) { + detectedSims = app.carrierDetector.getAvailableSims() + } + val allGranted = permissions.phoneBundle && permissions.camera && permissions.accessibility && permissions.overlay var permissionsExpanded by remember { mutableStateOf(!allGranted) } @@ -148,6 +160,70 @@ fun SettingsScreen( Hairline() Spacer(Modifier.height(24.dp)) + // ── Preferences (PIN & SIM) ── + SectionHeader("Preferences") + Spacer(Modifier.height(16.dp)) + + Text( + text = "UPI PIN LENGTH", + style = NeoPopType.LabelSmall, + color = NeoPopColors.TextSecondary + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PreferencePill( + label = "4 Digits", + selected = pinLength == 4, + onClick = { scope.launch { prefsRepo.setUpiPinLength(4) } }, + modifier = Modifier.weight(1f) + ) + PreferencePill( + label = "6 Digits", + selected = pinLength == 6, + onClick = { scope.launch { prefsRepo.setUpiPinLength(6) } }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(Modifier.height(24.dp)) + + Text( + text = "DEFAULT SIM", + style = NeoPopType.LabelSmall, + color = NeoPopColors.TextSecondary + ) + Spacer(Modifier.height(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PreferencePill( + label = "Ask every time", + selected = defaultSim == -1, + onClick = { + scope.launch { + prefsRepo.setDefaultSimSlot(-1) + prefsRepo.setSelectedSimCarrier(null) + } + }, + modifier = Modifier.fillMaxWidth() + ) + detectedSims.forEach { sim -> + PreferencePill( + label = "SIM ${sim.slotIndex + 1} (${sim.carrierName ?: "Unknown"})", + selected = defaultSim == sim.slotIndex, + onClick = { + scope.launch { + prefsRepo.setDefaultSimSlot(sim.slotIndex) + prefsRepo.setSelectedSimCarrier(sim.carrierName ?: "Unknown") + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Spacer(Modifier.height(28.dp)) + Hairline() + Spacer(Modifier.height(24.dp)) + // ── Transaction History — right below mode for quick access ── ShortcutRow( icon = Icons.Default.History, @@ -540,6 +616,40 @@ private fun CreditRow(name: String, handle: String, onClick: () -> Unit) { } } +@Composable +private fun PreferencePill( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + Box( + modifier + .background(if (selected) NeoPopColors.Accent else NeoPopColors.SurfaceHigh) + .clickable { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + .padding(vertical = 12.dp, horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(8.dp) + .background(if (selected) NeoPopColors.Black else NeoPopColors.Border) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = label, + style = NeoPopType.LabelMedium, + color = if (selected) NeoPopColors.Black else NeoPopColors.TextPrimary + ) + } + } +} + @Composable private fun SectionHeader(label: String, danger: Boolean = false) { Text( diff --git a/app/src/main/java/com/offpay/app/presentation/screens/SimPickerDialog.kt b/app/src/main/java/com/offpay/app/presentation/screens/SimPickerDialog.kt new file mode 100644 index 0000000..48e547f --- /dev/null +++ b/app/src/main/java/com/offpay/app/presentation/screens/SimPickerDialog.kt @@ -0,0 +1,90 @@ +package com.offpay.app.presentation.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.offpay.app.domain.SimInfo +import com.offpay.app.presentation.ui.theme.NeoPopColors +import com.offpay.app.presentation.ui.theme.NeoPopType + +@Composable +fun SimPickerDialog( + sims: List, + title: String = "Choose SIM", + onSelect: (SimInfo) -> Unit, + onAskEveryTime: (() -> Unit)? = null, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + sims.forEach { sim -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(sim) } + .padding(vertical = 12.dp) + ) { + Column { + Text( + text = sim.carrierName?.ifBlank { "SIM ${sim.slotIndex + 1}" } + ?: "SIM ${sim.slotIndex + 1}", + fontWeight = FontWeight.Bold, + color = NeoPopColors.TextPrimary + ) + Spacer(Modifier.height(2.dp)) + Text( + text = "Slot ${sim.slotIndex + 1}", + color = NeoPopColors.TextSecondary, + style = NeoPopType.LabelSmall + ) + } + } + } + + if (onAskEveryTime != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onAskEveryTime() } + .padding(vertical = 12.dp) + ) { + Column { + Text( + text = "Ask every time", + fontWeight = FontWeight.Bold, + color = NeoPopColors.Accent + ) + Spacer(Modifier.height(2.dp)) + Text( + text = "Disable default SIM", + color = NeoPopColors.TextSecondary, + style = NeoPopType.LabelSmall + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt b/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt index a649a07..355b142 100644 --- a/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt +++ b/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -53,6 +54,8 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,13 +72,12 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.offpay.app.R +import com.offpay.app.data.PreferencesRepository +import com.offpay.app.domain.SimInfo +import com.offpay.app.offPayApp import com.offpay.app.presentation.permissions.PermissionStatus import com.offpay.app.presentation.permissions.openAccessibilitySettings import com.offpay.app.presentation.permissions.openOverlaySettings @@ -86,28 +88,24 @@ import com.offpay.app.presentation.ui.components.NeoPopPrimaryButton import com.offpay.app.presentation.ui.components.NeoPopSecondaryButton import com.offpay.app.presentation.ui.theme.NeoPopColors import com.offpay.app.presentation.ui.theme.NeoPopType +import kotlinx.coroutines.delay import kotlinx.coroutines.launch // ─── Hooks for the user to drop in real assets later ────────────────────────── -/** - * BHIM walkthrough screenshots, mirrored from FaqScreen so the same images - * surface here too. - */ private val bhimStep1ImageRes: Int? = R.drawable.bhim_step1 private val bhimStep2ImageRes: Int? = R.drawable.bhim_step2 private val bhimStep3ImageRes: Int? = R.drawable.bhim_step3 -/** - * Tutorial video URL. When non-null, the welcome page (page 1) renders a - * small "Watch tutorial" link below the hero CTA. - */ private val TUTORIAL_VIDEO_URL: String? = "https://youtube.com/playlist?list=PL6zhuU_l94t1y25MDt96Z-MltD3S6iPFj&si=GNlanTwR-IcfOBI" @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable -fun OnboardingFlow(onComplete: () -> Unit) { - val totalPages = 6 +fun OnboardingFlow( + prefsRepo: PreferencesRepository, + onComplete: () -> Unit +) { + val totalPages = 7 val pagerState = rememberPagerState(pageCount = { totalPages }) val scope = rememberCoroutineScope() @@ -119,10 +117,12 @@ fun OnboardingFlow(onComplete: () -> Unit) { .fillMaxSize() .background(NeoPopColors.Black) .statusBarsPadding() + .navigationBarsPadding() ) { HorizontalPager( state = pagerState, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + userScrollEnabled = false ) { page -> when (page) { 0 -> WelcomePage() @@ -132,7 +132,11 @@ fun OnboardingFlow(onComplete: () -> Unit) { scope.launch { pagerState.animateScrollToPage(4) } }) 4 -> PermissionsPage(permissions = permissions) - 5 -> ReadyPage() + 5 -> AppSetupPage( + prefsRepo = prefsRepo, + isVisible = pagerState.currentPage == 5 + ) + 6 -> ReadyPage() } } Spacer(Modifier.height(8.dp)) @@ -147,6 +151,7 @@ fun OnboardingFlow(onComplete: () -> Unit) { 2 -> "Next" 3 -> "Continue" 4 -> if (permissions.readyForOverlayPay) "Looks Good" else "Continue Anyway" + 5 -> "Save Preferences" else -> "Let's Pay" }, onClick = { @@ -171,8 +176,6 @@ private fun PageIndicator(current: Int, total: Int) { ) { repeat(total) { i -> val isActive = i == current - // 6dp dot inactive, 24dp lime pill active. Spring animation - // smooths the transition between states. val width by animateDpAsState( targetValue = if (isActive) 24.dp else 6.dp, animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessMedium), @@ -193,8 +196,6 @@ private fun PageIndicator(current: Int, total: Int) { } } -// ── Page 1: Welcome ── - @Composable private fun WelcomePage() { val context = LocalContext.current @@ -240,8 +241,6 @@ private fun WelcomePage() { style = NeoPopType.BodyLarge, color = NeoPopColors.TextSecondary ) - // Optional inline link to a tutorial video — only renders when the - // top-of-file constant is set. TUTORIAL_VIDEO_URL?.let { url -> Spacer(Modifier.height(20.dp)) Row( @@ -269,8 +268,6 @@ private fun WelcomePage() { } } -// ── Page 2: What is *99# ── - @Composable private fun Star99ExplainerPage() { Column( @@ -335,8 +332,6 @@ private fun CarrierPill(name: String, supported: Boolean, modifier: Modifier = M } } -// ── Page 3: *99# Banking Setup (NEW) ── - @Composable private fun Star99BankingSetupPage() { val context = LocalContext.current @@ -346,7 +341,6 @@ private fun Star99BankingSetupPage() { .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 24.dp) ) { - // ── One-time badge ── Box( Modifier .background(NeoPopColors.Accent.copy(alpha = 0.14f)) @@ -373,7 +367,6 @@ private fun Star99BankingSetupPage() { ) Spacer(Modifier.height(20.dp)) - // ── Instructions card ── NeoPopCard(modifier = Modifier.fillMaxWidth()) { Column { Text( @@ -393,7 +386,6 @@ private fun Star99BankingSetupPage() { Spacer(Modifier.height(20.dp)) - // ── Dial *99# button ── NeoPopPrimaryButton( text = "Dial *99#", leadingIcon = Icons.Default.Phone, @@ -407,7 +399,6 @@ private fun Star99BankingSetupPage() { Spacer(Modifier.height(16.dp)) - // ── Video guide link ── NeoPopSecondaryButton( text = "Watch Official *99# Guide", leadingIcon = Icons.Default.PlayArrow, @@ -423,7 +414,6 @@ private fun Star99BankingSetupPage() { Spacer(Modifier.height(20.dp)) - // ── Info note ── Box( Modifier .fillMaxWidth() @@ -448,8 +438,6 @@ private fun Star99BankingSetupPage() { } } -// ── Page 4: BHIM setup (screenshots) ── - @Composable private fun BhimSetupPage(onSkip: () -> Unit) { Column( @@ -479,20 +467,19 @@ private fun BhimSetupPage(onSkip: () -> Unit) { NeoPopCard(modifier = Modifier.fillMaxWidth()) { Column { - NumberedStep(1, "Open BHIM app → tap your profile avatar (initials in top-left).") + NumberedStep(1, "Open BHIM app -> tap your profile avatar (initials in top-left).") ImageSlot(res = bhimStep1ImageRes) Spacer(Modifier.height(14.dp)) - NumberedStep(2, "Scroll down → tap Settings.") + NumberedStep(2, "Scroll down -> tap Settings.") ImageSlot(res = bhimStep2ImageRes) Spacer(Modifier.height(14.dp)) - NumberedStep(3, "Find \"USSD service (*99#)\" under Account settings → toggle it ON.") + NumberedStep(3, "Find \"USSD service (*99#)\" under Account settings -> toggle it ON.") ImageSlot(res = bhimStep3ImageRes) } } Spacer(Modifier.height(20.dp)) - // Official BHIM guide link val bhimContext = LocalContext.current Box( Modifier @@ -508,7 +495,7 @@ private fun BhimSetupPage(onSkip: () -> Unit) { contentAlignment = Alignment.Center ) { Text( - text = "Official BHIM *99# Setup Guide →", + text = "Official BHIM *99# Setup Guide ->", style = NeoPopType.LabelMedium, color = NeoPopColors.Accent ) @@ -531,70 +518,6 @@ private fun BhimSetupPage(onSkip: () -> Unit) { } } -@Composable -private fun NumberedStep(index: Int, body: String) { - Row(verticalAlignment = Alignment.Top) { - Box( - Modifier - .size(24.dp) - .background(NeoPopColors.Accent), - contentAlignment = Alignment.Center - ) { - Text( - text = index.toString(), - style = NeoPopType.LabelMedium, - color = NeoPopColors.Black - ) - } - Spacer(Modifier.width(12.dp)) - Text( - text = body, - style = NeoPopType.BodyMedium, - color = NeoPopColors.TextSecondary, - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -private fun ImageSlot(res: Int?) { - Spacer(Modifier.height(10.dp)) - if (res != null) { - // Real screenshot: scale-to-fit, capped at 480dp tall so the portrait - // BHIM screenshots don't dominate the screen on small devices. - Image( - painter = painterResource(id = res), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 480.dp) - ) - } else { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .background(NeoPopColors.Surface) - .drawBehind { - drawRect( - color = NeoPopColors.Accent.copy(alpha = 0.4f), - style = Stroke(width = 1f) - ) - }, - contentAlignment = Alignment.Center - ) { - Text( - text = "step image", - style = NeoPopType.LabelMedium, - color = NeoPopColors.TextMuted - ) - } - } -} - -// ── Page 4: Permissions ── - private data class PermissionInfo( val key: String, val icon: ImageVector, @@ -603,7 +526,6 @@ private data class PermissionInfo( val why: String, val granted: Boolean, val onGrant: () -> Unit, - /** When true, the GRANT button shows an inline FAQ-link hint below it. */ val showFaqHint: Boolean = false ) @@ -640,8 +562,6 @@ private fun PermissionsPage(permissions: PermissionStatus) { why = "Android's only public USSD API can't navigate multi-step menus. The accessibility service is what lets OffPay automatically type your amount, VPA and PIN into the carrier's dialog so you don't have to.", granted = permissions.accessibility, onGrant = { openAccessibilitySettings(context) }, - // Android 13+ may grey out the toggle. The FAQ has the workaround - // — surface a tappable hint right next to the GRANT button. showFaqHint = true ), PermissionInfo( @@ -789,7 +709,6 @@ private fun PermissionCard(info: PermissionInfo, onHelp: () -> Unit) { Spacer(Modifier.width(10.dp)) StatusButton(granted = info.granted, onGrant = info.onGrant) } - // Inline "Can't enable?" expandable fix for restricted settings. if (info.showFaqHint && !info.granted) { Spacer(Modifier.height(10.dp)) RestrictedSettingsFix() @@ -841,11 +760,6 @@ private fun StatusButton(granted: Boolean, onGrant: () -> Unit) { } } -/** - * Expandable inline fix for Android 13+ "Restricted Settings" that prevents - * enabling accessibility for sideloaded apps. Shows a tappable "Can't enable?" - * label that expands to reveal step-by-step instructions + guide link. - */ @Composable private fun RestrictedSettingsFix() { val context = LocalContext.current @@ -883,7 +797,6 @@ private fun RestrictedSettingsFix() { .background(NeoPopColors.Surface) .padding(14.dp) ) { - // Explanation Text( text = "Why does this happen?", style = NeoPopType.LabelSmall, @@ -897,16 +810,15 @@ private fun RestrictedSettingsFix() { ) Spacer(Modifier.height(12.dp)) - // Steps Text( text = "FIX:", style = NeoPopType.LabelSmall, color = NeoPopColors.Accent ) Spacer(Modifier.height(8.dp)) - NumberedStep(1, "Go to your phone's Settings → Apps → OffPay") + NumberedStep(1, "Go to your phone's Settings -> Apps -> OffPay") Spacer(Modifier.height(6.dp)) - NumberedStep(2, "Tap the ⋮ three dots in the top right corner") + NumberedStep(2, "Tap the : three dots in the top right corner") Spacer(Modifier.height(6.dp)) NumberedStep(3, "Select \"Allow restricted settings\" and confirm with your PIN/fingerprint") Spacer(Modifier.height(6.dp)) @@ -914,7 +826,6 @@ private fun RestrictedSettingsFix() { Spacer(Modifier.height(14.dp)) - // Guide link NeoPopSecondaryButton( text = "View Guide with Screenshots", onClick = { @@ -931,7 +842,208 @@ private fun RestrictedSettingsFix() { } } -// ── Page 6: Ready ── +@Composable +private fun NumberedStep(index: Int, body: String) { + Row(verticalAlignment = Alignment.Top) { + Box( + Modifier + .size(24.dp) + .background(NeoPopColors.Accent), + contentAlignment = Alignment.Center + ) { + Text( + text = index.toString(), + style = NeoPopType.LabelMedium, + color = NeoPopColors.Black + ) + } + Spacer(Modifier.width(12.dp)) + Text( + text = body, + style = NeoPopType.BodyMedium, + color = NeoPopColors.TextSecondary, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ImageSlot(res: Int?) { + Spacer(Modifier.height(10.dp)) + if (res != null) { + Image( + painter = painterResource(id = res), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 480.dp) + ) + } else { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .background(NeoPopColors.Surface) + .drawBehind { + drawRect( + color = NeoPopColors.Accent.copy(alpha = 0.4f), + style = Stroke(width = 1f) + ) + }, + contentAlignment = Alignment.Center + ) { + Text( + text = "step image", + style = NeoPopType.LabelMedium, + color = NeoPopColors.TextMuted + ) + } + } +} + +@Composable +private fun AppSetupPage( + prefsRepo: PreferencesRepository, + isVisible: Boolean +) { + val scope = rememberCoroutineScope() + val pinLength by prefsRepo.upiPinLength.collectAsState(initial = 6) + val defaultSim by prefsRepo.defaultSimSlot.collectAsState(initial = -1) + + val context = LocalContext.current + val app = context.offPayApp + var detectedSims by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(isVisible) { + if (isVisible) { + // Wait 500ms to ensure permissions are synchronized with the + // telephony system before scanning for SIMs. + delay(500) + detectedSims = app.carrierDetector.getAvailableSims() + } + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 24.dp) + ) { + Text( + text = "APP SETTINGS", + style = NeoPopType.LabelMedium, + color = NeoPopColors.Accent + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Configure your preferences.", + style = NeoPopType.DisplayMedium, + color = NeoPopColors.TextPrimary + ) + Spacer(Modifier.height(24.dp)) + + Text( + text = "UPI PIN LENGTH", + style = NeoPopType.LabelSmall, + color = NeoPopColors.TextSecondary + ) + Spacer(Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SelectionPill( + label = "4 Digits", + selected = pinLength == 4, + onClick = { scope.launch { prefsRepo.setUpiPinLength(4) } }, + modifier = Modifier.weight(1f) + ) + SelectionPill( + label = "6 Digits", + selected = pinLength == 6, + onClick = { scope.launch { prefsRepo.setUpiPinLength(6) } }, + modifier = Modifier.weight(1f) + ) + } + + Spacer(Modifier.height(32.dp)) + + Text( + text = "DEFAULT SIM FOR TRANSACTIONS", + style = NeoPopType.LabelSmall, + color = NeoPopColors.TextSecondary + ) + Spacer(Modifier.height(12.dp)) + + SelectionPill( + label = "Ask every time (Default)", + selected = defaultSim == -1, + onClick = { + scope.launch { + prefsRepo.setDefaultSimSlot(-1) + prefsRepo.setSelectedSimCarrier(null) + } + }, + modifier = Modifier.fillMaxWidth() + ) + + if (detectedSims.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + detectedSims.forEach { sim -> + SelectionPill( + label = "SIM ${sim.slotIndex + 1} (${sim.carrierName ?: "Unknown"})", + selected = defaultSim == sim.slotIndex, + onClick = { + scope.launch { + prefsRepo.setDefaultSimSlot(sim.slotIndex) + prefsRepo.setSelectedSimCarrier(sim.carrierName ?: "Unknown") + } + }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) + ) + } + } else { + Spacer(Modifier.height(8.dp)) + Text( + text = "No SIMs detected. Grant Phone permission in the previous step to see SIM options here.", + style = NeoPopType.BodySmall, + color = NeoPopColors.TextMuted + ) + } + } +} + +@Composable +private fun SelectionPill( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val view = LocalView.current + Box( + modifier + .background(if (selected) NeoPopColors.Accent else NeoPopColors.SurfaceHigh) + .clickable { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + onClick() + } + .padding(vertical = 14.dp, horizontal = 16.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .background(if (selected) NeoPopColors.Black else NeoPopColors.Border) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = label, + style = NeoPopType.LabelMedium, + color = if (selected) NeoPopColors.Black else NeoPopColors.TextPrimary + ) + } + } +} @Composable private fun ReadyPage() { diff --git a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt index f35a53f..1ac3054 100644 --- a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt +++ b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt @@ -3,6 +3,7 @@ package com.offpay.app.presentation.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -71,6 +72,7 @@ fun NeoPopCard( ) { Box( Modifier + .fillMaxWidth() // Ensure inner face matches outer container width .padding(end = depth, bottom = depth) .clip(RoundedCornerShape(cornerRadius)) .background(surfaceColor) diff --git a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt index c1b5f6e..10b1c01 100644 --- a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt +++ b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt @@ -49,6 +49,7 @@ fun NeoPopTextField( modifier: Modifier = Modifier, placeholder: String? = null, leadingIcon: ImageVector? = null, + trailingIcon: @Composable (() -> Unit)? = null, keyboardType: KeyboardType = KeyboardType.Text, visualTransformation: VisualTransformation = VisualTransformation.None, error: String? = null, @@ -126,7 +127,7 @@ fun NeoPopTextField( BasicTextField( value = value, onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), textStyle = textStyle, keyboardOptions = KeyboardOptions(keyboardType = keyboardType), visualTransformation = visualTransformation, @@ -144,6 +145,10 @@ fun NeoPopTextField( inner() } ) + if (trailingIcon != null) { + Spacer(Modifier.size(10.dp)) + trailingIcon() + } } } } diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt index 75ee36f..775e74f 100644 --- a/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt +++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt @@ -23,7 +23,10 @@ class ActionRunnerFailurePropertyTest : FunSpec({ // Fake UssdEnginePort for creating ActionRunner instance val fakeEngine = object : UssdEnginePort { override suspend fun dial(code: String) {} + override fun setPreferredSim(simInfo: SimInfo?) {} override suspend fun sendReply(reply: String): Boolean = true + override suspend fun fillReply(reply: String): Boolean = true + override suspend fun submitFilledReply(): Boolean = true override suspend fun cancel() {} override suspend fun dismissDialog(): Boolean = true override fun getSessionId(): Int = 1 diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt index ca1e3da..d1da4de 100644 --- a/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt +++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt @@ -24,7 +24,10 @@ class ActionRunnerStepMatchPropertyTest : FunSpec({ // tests call matchStep() and fillTemplate() directly. val fakeEngine = object : UssdEnginePort { override suspend fun dial(code: String) {} + override fun setPreferredSim(simInfo: SimInfo?) {} override suspend fun sendReply(reply: String) = true + override suspend fun fillReply(reply: String) = true + override suspend fun submitFilledReply() = true override suspend fun cancel() {} override suspend fun dismissDialog() = true override fun getSessionId() = 1 diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt index 081f30d..c58d0a5 100644 --- a/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt +++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt @@ -23,7 +23,10 @@ class ActionRunnerSuccessPropertyTest : FunSpec({ // Fake UssdEnginePort for creating ActionRunner instance val fakeEngine = object : UssdEnginePort { override suspend fun dial(code: String) {} + override fun setPreferredSim(simInfo: SimInfo?) {} override suspend fun sendReply(reply: String): Boolean = true + override suspend fun fillReply(reply: String): Boolean = true + override suspend fun submitFilledReply(): Boolean = true override suspend fun cancel() {} override suspend fun dismissDialog(): Boolean = true override fun getSessionId(): Int = 0 diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt index 1dd9c76..cdc1ba9 100644 --- a/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt +++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt @@ -27,7 +27,10 @@ class ActionRunnerTerminalPropertyTest : FunSpec({ // Fake UssdEnginePort for creating ActionRunner instance val fakeEngine = object : UssdEnginePort { override suspend fun dial(code: String) {} + override fun setPreferredSim(simInfo: SimInfo?) {} override suspend fun sendReply(reply: String): Boolean = true + override suspend fun fillReply(reply: String): Boolean = true + override suspend fun submitFilledReply(): Boolean = true override suspend fun cancel() {} override suspend fun dismissDialog(): Boolean = true override fun getSessionId(): Int = 1 diff --git a/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt b/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt index 8c933d1..a73fb86 100644 --- a/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt +++ b/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt @@ -44,10 +44,13 @@ class StaleFrameFilteringPropertyTest : FunSpec({ var cancelCalled = false override suspend fun dial(code: String) { dialCalled = true } + override fun setPreferredSim(simInfo: com.offpay.app.domain.SimInfo?) {} override suspend fun sendReply(reply: String): Boolean { repliesSent.add(reply) return true } + override suspend fun fillReply(reply: String): Boolean = true + override suspend fun submitFilledReply(): Boolean = true override suspend fun cancel() { cancelCalled = true } override suspend fun dismissDialog(): Boolean = true override fun getSessionId(): Int = currentSessionId