diff --git a/.gitignore b/.gitignore index 58fcf1bd62..64fbe0761c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,12 +47,12 @@ captures/ .idea/dictionaries .idea/libraries .idea/jarRepositories.xml +.idea/codeStyles/Project.xml # Android Studio 3 in .gitignore file. .idea/caches .idea/modules.xml # Comment next line if keeping position of elements in Navigation Editor is relevant for you .idea/navEditor.xml -!.idea/codeStyles/** !.idea/editor.xml !/.idea/inspectionProfiles diff --git a/ReadMe.md b/ReadMe.md index 26618b02ff..e0bb298381 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -44,6 +44,8 @@ The Reown Kotlin SDK is organized as a modular system with several layers: 5. **Sample Applications**: Example implementations - `sample/wallet/`: Wallet sample application - `sample/dapp/`: dApp sample application + - `sample/pos/`: POS sample application + - `sample/modal/`: Modal sample application ## Installation @@ -66,13 +68,112 @@ dependencies { } ``` - - -## Sample Applications - -The repository includes sample applications to demonstrate SDK usage: - -- **Wallet Sample**: Demonstrates how to build a wallet application using WalletKit -- **dApp Sample**: Shows how to build a dApp that connects to wallets using AppKit - -Check the `sample/` directory for complete implementations. +## Building Sample Applications + +The repository includes several sample applications that demonstrate different use cases of the Reown Kotlin SDK. Follow these instructions to build and run the sample apps: + +### Prerequisites + +- Android Studio Arctic Fox or later +- Android SDK API level 21 or higher +- Gradle 7.0 or later +- JDK 11 or later + +### Setup Steps +1. **Configure Keystore Properties** + Create a `secrets.properties` file in the root directory with empty strings: + ```properties + WC_KEYSTORE_ALIAS="" + WC_KEYSTORE_ALIAS_DEBUG="" + WC_FILENAME_DEBUG="" + WC_STORE_PASSWORD_DEBUG="" + WC_KEY_PASSWORD_DEBUG="" + WC_FILENAME_INTERNAL="" + WC_STORE_PASSWORD_INTERNAL="" + WC_KEY_PASSWORD_INTERNAL="" + WC_FILENAME_UPLOAD="" + WC_STORE_PASSWORD_UPLOAD="" + WC_KEY_PASSWORD_UPLOAD="" + ``` + +2. **Configure Google Services** + Each sample app requires a `google-services.json` file in its `src` directory. The file should contain: + - `mobilesdk_app_id`: Your Firebase project ID + - `package_name`: The sample app's package name + - `api_key`: Your Firebase API key + + **Example `google-services.json` file:** + ```json + { + "project_info": { + "project_number": "1234567890", + "project_id": "dummy-project-id", + "storage_bucket": "dummy-project-id.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1234567890:android:abcdef123456", + "android_client_info": { + "package_name": "com.reown.sample.{sample_name}.debug" + } + }, + "oauth_client": [ + { + "client_id": "1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaDummyKeyForSample123456" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" + } + ``` + + **Note:** Replace `{sample_name}` with the actual sample name: `wallet`, `dapp`, `pos`, or `modal`. You need to create this file for each sample app you want to build. Use the example content if you want to just build samples locally. + +### Available Sample Apps + +#### Wallet Sample (`sample/wallet/`) +- **Purpose**: Demonstrates a complete wallet implementation +- **Package**: `com.reown.sample.wallet` +- **Build Command**: `./gradlew :sample:wallet:assembleDebug` + +#### dApp Sample (`sample/dapp/`) +- **Purpose**: Shows how to build a dApp that connects to wallets +- **Package**: `com.reown.sample.dapp` +- **Build Command**: `./gradlew :sample:dapp:assembleDebug` + +#### POS Sample (`sample/pos/`) +- **Purpose**: Point of Sale application example +- **Package**: `com.reown.sample.pos` +- **Build Command**: `./gradlew :sample:pos:assembleDebug` + +#### Modal Sample (`sample/modal/`) +- **Purpose**: Modal UI integration example +- **Package**: `com.reown.sample.modal` +- **Build Command**: `./gradlew :sample:modal:assembleDebug` + +### Build Commands + +- **Build all samples**: `./gradlew :sample:assembleDebug` +- **Build specific sample**: `./gradlew :sample:{sample_name}:assembleDebug` +- **Install on device**: `./gradlew :sample:{sample_name}:installDebug` +- **Run tests**: `./gradlew :sample:{sample_name}:testDebugUnitTest` + +### Troubleshooting + +- **Build errors**: Ensure all dependencies are synced with `./gradlew build` +- **Keystore issues**: Verify `secrets.properties` contains valid keystore information +- **Google Services**: Ensure `google-services.json` is properly configured for each sample +- **Gradle sync**: Try `./gradlew clean` followed by `./gradlew build` diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts index f40578cae8..b918d5346f 100644 --- a/buildSrc/settings.gradle.kts +++ b/buildSrc/settings.gradle.kts @@ -21,7 +21,6 @@ dependencyResolutionManagement { mavenCentral() maven(url = "https://jitpack.io") maven(url = "https://central.sonatype.com/repository/maven-snapshots/") -// jcenter() // Warning: this repository is going to shut down soon } } diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 2a1f8bebc5..c853e276a6 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -2,8 +2,11 @@ import org.gradle.api.JavaVersion const val KEY_PUBLISH_VERSION = "PUBLISH_VERSION" const val KEY_PUBLISH_ARTIFACT_ID = "PUBLISH_ARTIFACT_ID" +const val KEY_PUBLISH_GROUP = "PUBLISH_GROUP" const val KEY_SDK_NAME = "SDK_NAME" +const val DEFAULT_PUBLISH_GROUP = "com.reown" + //Latest versions const val BOM_VERSION = "1.5.1" const val FOUNDATION_VERSION = "1.5.1" @@ -13,6 +16,7 @@ const val NOTIFY_VERSION = "1.5.1" const val WALLETKIT_VERSION = "1.5.1" const val APPKIT_VERSION = "1.5.1" const val MODAL_CORE_VERSION = "1.5.1" +const val POS_VERSION = "1.0.0" //Artifact ids const val ANDROID_BOM = "android-bom" @@ -23,6 +27,7 @@ const val NOTIFY = "notify" const val WALLETKIT = "walletkit" const val APPKIT = "appkit" const val MODAL_CORE = "modal-core" +const val POS = "pos" val jvmVersion = JavaVersion.VERSION_11 const val MIN_SDK: Int = 23 diff --git a/buildSrc/src/main/kotlin/publish-module-android.gradle.kts b/buildSrc/src/main/kotlin/publish-module-android.gradle.kts index 64ea61ed24..62978d43ab 100644 --- a/buildSrc/src/main/kotlin/publish-module-android.gradle.kts +++ b/buildSrc/src/main/kotlin/publish-module-android.gradle.kts @@ -36,7 +36,7 @@ afterEvaluate { artifact(tasks.getByName("javadocJar")) artifact(tasks.getByName("sourcesJar")) - groupId = "com.reown" + groupId = project.extra.properties[KEY_PUBLISH_GROUP]?.toString() ?: DEFAULT_PUBLISH_GROUP artifactId = requireNotNull(project.extra[KEY_PUBLISH_ARTIFACT_ID]).toString() version = requireNotNull(project.extra[KEY_PUBLISH_VERSION]).toString() diff --git a/buildSrc/src/main/kotlin/publish-module-java.gradle.kts b/buildSrc/src/main/kotlin/publish-module-java.gradle.kts index 452578caca..81f853e867 100644 --- a/buildSrc/src/main/kotlin/publish-module-java.gradle.kts +++ b/buildSrc/src/main/kotlin/publish-module-java.gradle.kts @@ -32,7 +32,7 @@ afterEvaluate { from(components["javaPlatform"]) } - groupId = "com.reown" + groupId = extra.properties[KEY_PUBLISH_GROUP]?.toString() ?: DEFAULT_PUBLISH_GROUP artifactId = requireNotNull(extra.get(KEY_PUBLISH_ARTIFACT_ID)).toString() version = requireNotNull(extra.get(KEY_PUBLISH_VERSION)).toString() diff --git a/buildSrc/src/main/kotlin/release-scripts.gradle.kts b/buildSrc/src/main/kotlin/release-scripts.gradle.kts index 831e738ea5..b1be99697c 100644 --- a/buildSrc/src/main/kotlin/release-scripts.gradle.kts +++ b/buildSrc/src/main/kotlin/release-scripts.gradle.kts @@ -37,7 +37,8 @@ fun compileListOfSDKs(): List> = mutableListOf( Triple("protocol", "sign", "android"), Triple("protocol", "notify", "android"), Triple("product", "walletkit", "android"), - Triple("product", "appkit", "android") + Triple("product", "appkit", "android"), + Triple("product", "pos", "android") ).apply { // The BOM has to be last artifact add(Triple("core", "bom", "jvm")) diff --git a/buildSrc/src/main/kotlin/signing-config.gradle.kts b/buildSrc/src/main/kotlin/signing-config.gradle.kts index 47ff5063bd..f4c60379bc 100644 --- a/buildSrc/src/main/kotlin/signing-config.gradle.kts +++ b/buildSrc/src/main/kotlin/signing-config.gradle.kts @@ -1,5 +1,5 @@ -import com.android.build.gradle.BaseExtension import com.google.firebase.appdistribution.gradle.firebaseAppDistribution +import com.android.build.gradle.BaseExtension import java.util.Properties plugins { diff --git a/core/android/src/main/kotlin/com/reown/android/internal/common/di/AndroidCommonDITags.kt b/core/android/src/main/kotlin/com/reown/android/internal/common/di/AndroidCommonDITags.kt index 4e8b54cf7f..7a8fdee226 100644 --- a/core/android/src/main/kotlin/com/reown/android/internal/common/di/AndroidCommonDITags.kt +++ b/core/android/src/main/kotlin/com/reown/android/internal/common/di/AndroidCommonDITags.kt @@ -44,4 +44,6 @@ enum class AndroidCommonDITags { ENABLE_AUTHENTICATE, TELEMETRY_ENABLED, PACKAGE_NAME, + POC_RETROFIT, + POC_OK_HTTP } \ No newline at end of file diff --git a/core/android/src/main/kotlin/com/reown/android/pairing/engine/domain/PairingEngine.kt b/core/android/src/main/kotlin/com/reown/android/pairing/engine/domain/PairingEngine.kt index 3820b44081..99ed9bb275 100644 --- a/core/android/src/main/kotlin/com/reown/android/pairing/engine/domain/PairingEngine.kt +++ b/core/android/src/main/kotlin/com/reown/android/pairing/engine/domain/PairingEngine.kt @@ -141,8 +141,6 @@ internal class PairingEngine( crypto.removeKeys(pairingTopic.value) pairingRepository.deletePairing(pairingTopic) metadataRepository.deleteMetaData(pairingTopic) - jsonRpcInteractor.unsubscribe(pairingTopic) - logger.error("Pairing - subscribed failure on pairing topic: $pairingTopic, error: $throwable") onFailure(throwable) } catch (e: Exception) { logger.error("Pairing - subscribed failure on pairing topic: $pairingTopic, error: $e") diff --git a/foundation/src/main/kotlin/com/reown/foundation/network/model/RelayDTO.kt b/foundation/src/main/kotlin/com/reown/foundation/network/model/RelayDTO.kt index d499b21a22..f006a60d85 100644 --- a/foundation/src/main/kotlin/com/reown/foundation/network/model/RelayDTO.kt +++ b/foundation/src/main/kotlin/com/reown/foundation/network/model/RelayDTO.kt @@ -1,15 +1,14 @@ package com.reown.foundation.network.model -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass import com.reown.foundation.common.adapters.SubscriptionIdAdapter import com.reown.foundation.common.adapters.TopicAdapter import com.reown.foundation.common.adapters.TtlAdapter import com.reown.foundation.common.model.SubscriptionId import com.reown.foundation.common.model.Topic import com.reown.foundation.common.model.Ttl -import com.reown.foundation.network.model.RelayDTO.Publish.Request.Params import com.reown.util.generateClientToServerId +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass sealed class RelayDTO { abstract val id: Long diff --git a/product/appkit/src/main/kotlin/com/reown/appkit/client/Modal.kt b/product/appkit/src/main/kotlin/com/reown/appkit/client/Modal.kt index cacdf2df63..4afc8ee36f 100644 --- a/product/appkit/src/main/kotlin/com/reown/appkit/client/Modal.kt +++ b/product/appkit/src/main/kotlin/com/reown/appkit/client/Modal.kt @@ -188,7 +188,7 @@ object Modal { data class JsonRpcResult( override val id: Long, - val result: String?, + val result: Any?, ) : JsonRpcResponse() data class JsonRpcError( diff --git a/product/appkit/src/main/kotlin/com/reown/appkit/engine/AppKitEngine.kt b/product/appkit/src/main/kotlin/com/reown/appkit/engine/AppKitEngine.kt index 1034f1acbd..a6b705b55b 100644 --- a/product/appkit/src/main/kotlin/com/reown/appkit/engine/AppKitEngine.kt +++ b/product/appkit/src/main/kotlin/com/reown/appkit/engine/AppKitEngine.kt @@ -324,7 +324,7 @@ internal class AppKitEngine( val siweResponse = Modal.Model.SIWEAuthenticateResponse.Result( id = response.result.id, message = siweRequestIdWithMessage!!.second, - signature = (response.result as Sign.Model.JsonRpcResponse.JsonRpcResult).result ?: "" + signature = ((response.result as Sign.Model.JsonRpcResponse.JsonRpcResult).result ?: "") as? String ?: "" ) siweRequestIdWithMessage = null val account = getAccount() ?: throw IllegalStateException("Account is null") diff --git a/product/pos/.gitignore b/product/pos/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/product/pos/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/product/pos/build.gradle.kts b/product/pos/build.gradle.kts new file mode 100644 index 0000000000..7831997b6e --- /dev/null +++ b/product/pos/build.gradle.kts @@ -0,0 +1,76 @@ +plugins { + id("com.android.library") + id(libs.plugins.kotlin.android.get().pluginId) + alias(libs.plugins.google.ksp) + id("publish-module-android") + id("jacoco-report") +} + +project.apply { + extra[KEY_PUBLISH_ARTIFACT_ID] = POS + extra[KEY_PUBLISH_VERSION] = POS_VERSION + extra[KEY_PUBLISH_GROUP] = "com.walletconnect" + extra[KEY_SDK_NAME] = "pos" +} + +android { + namespace = "com.walletconnect.pos" + compileSdk = COMPILE_SDK + + defaultConfig { + minSdk = 29 + + aarMetadata { + minCompileSdk = 29 + } + + buildConfigField(type = "String", name = "SDK_VERSION", value = "\"${requireNotNull(extra.get(KEY_PUBLISH_VERSION))}\"") + buildConfigField(type = "String", name = "CORE_API_BASE_URL", value = "\"https://pay.walletconnect.org\"") + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + lint { + abortOnError = true + ignoreWarnings = true + warningsAsErrors = false + } + + compileOptions { + sourceCompatibility = jvmVersion + targetCompatibility = jvmVersion + } + + kotlinOptions { + jvmTarget = jvmVersion.toString() + } + + buildFeatures { + buildConfig = true + } +} + +dependencies { + implementation(platform(libs.okhttp.bom)) + implementation(libs.okhttp) + + implementation(libs.moshi.kotlin) + ksp(libs.moshi.ksp) + + implementation(libs.coroutines) + + testImplementation(libs.jUnit) + testImplementation(libs.mockk) + testImplementation(libs.coroutines.test) + + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.coroutines.test) + androidTestUtil(libs.androidx.testOrchestrator) + androidTestImplementation(libs.bundles.androidxAndroidTest) +} \ No newline at end of file diff --git a/product/pos/consumer-rules.pro b/product/pos/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/product/pos/proguard-rules.pro b/product/pos/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/product/pos/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/product/pos/src/main/AndroidManifest.xml b/product/pos/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a5918e68ab --- /dev/null +++ b/product/pos/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/product/pos/src/main/kotlin/com/walletconnect/pos/Pos.kt b/product/pos/src/main/kotlin/com/walletconnect/pos/Pos.kt new file mode 100644 index 0000000000..d1840282b8 --- /dev/null +++ b/product/pos/src/main/kotlin/com/walletconnect/pos/Pos.kt @@ -0,0 +1,37 @@ +package com.walletconnect.pos + +import java.net.URI + +object Pos { + + data class Amount( + val unit: String, + val value: String + ) { + + fun format(): String { + val currency = unit.substringAfter("/", "") + val majorUnits = (value.toLongOrNull() ?: 0L) / 100.0 + return String.format("%.2f %s", majorUnits, currency) + } + } + + sealed interface PaymentEvent { + data class PaymentCreated(val uri: URI, val amount: Amount, val paymentId: String) : PaymentEvent + data object PaymentRequested : PaymentEvent + data object PaymentProcessing : PaymentEvent + data class PaymentSuccess(val paymentId: String) : PaymentEvent + sealed interface PaymentError : PaymentEvent { + data class CreatePaymentFailed(val message: String) : PaymentError + data class PaymentFailed(val message: String) : PaymentError + data class PaymentNotFound(val message: String) : PaymentError + data class PaymentExpired(val message: String) : PaymentError + data class InvalidPaymentRequest(val message: String) : PaymentError + data class Undefined(val message: String) : PaymentError + } + } +} + +interface POSDelegate { + fun onEvent(event: Pos.PaymentEvent) +} diff --git a/product/pos/src/main/kotlin/com/walletconnect/pos/PosClient.kt b/product/pos/src/main/kotlin/com/walletconnect/pos/PosClient.kt new file mode 100644 index 0000000000..8800986074 --- /dev/null +++ b/product/pos/src/main/kotlin/com/walletconnect/pos/PosClient.kt @@ -0,0 +1,120 @@ +package com.walletconnect.pos + +import com.walletconnect.pos.api.ApiClient +import com.walletconnect.pos.api.ApiResult +import com.walletconnect.pos.api.mapErrorCodeToPaymentError +import com.walletconnect.pos.api.mapStatusToPaymentEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * POS (Point of Sale) client for handling payment transactions. + */ +object PosClient { + private var delegate: POSDelegate? = null + private var apiClient: ApiClient? = null + private var scope: CoroutineScope? = null + private var currentPollingJob: Job? = null + + /** + * Initializes the POS client with API credentials. + * + * @param apiKey Your WalletConnect Pay API key + * @param deviceId Unique identifier for this POS device + */ + @Throws(IllegalStateException::class) + fun init(apiKey: String, deviceId: String) { + check(apiKey.isNotBlank()) { "apiKey cannot be blank" } + check(deviceId.isNotBlank()) { "deviceId cannot be blank" } + cleanup() + this.scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + this.apiClient = ApiClient(apiKey, deviceId) + } + + /** + * Sets the delegate for receiving payment events. + * + * @param delegate The delegate to receive events, or null to remove + */ + fun setDelegate(delegate: POSDelegate) { + this.delegate = delegate + } + + /** + * Creates a payment intent and starts the payment flow. + * All payment events are emitted through the delegate. + * + * @param amount The payment amount + * @param referenceId Merchant's reference ID for this payment + * @throws IllegalStateException if SDK is not initialized + */ + @Throws(IllegalStateException::class) + fun createPaymentIntent(amount: Pos.Amount, referenceId: String) { + checkInitialized() + currentPollingJob?.cancel() + currentPollingJob = scope?.launch { + apiClient!!.createPayment(referenceId, amount.unit, amount.value) { event -> + emitEvent(event) + } + } + } + + /** + * Checks the current status of a payment (one-off, no polling). + * + * @param paymentId The payment ID to check + * @return The current payment status as a [Pos.PaymentEvent] + * @throws IllegalStateException if SDK is not initialized + */ + @Throws(IllegalStateException::class) + suspend fun checkPaymentStatus(paymentId: String): Pos.PaymentEvent { + checkInitialized() + + return when (val result = apiClient!!.getPayment(paymentId)) { + is ApiResult.Success -> mapStatusToPaymentEvent(result.data.status, result.data.paymentId) + is ApiResult.Error -> mapErrorCodeToPaymentError(result.code, result.message) + } + } + + /** + * Cancels any ongoing polling and releases resources. + * + * Call this when the payment flow is cancelled by the user + * or when the POS screen is closed. + */ + fun cancelPayment() { + currentPollingJob?.cancel() + currentPollingJob = null + } + + /** + * Releases all resources held by the SDK. + * + * Call this when the SDK is no longer needed. + */ + fun shutdown() { + cleanup() + delegate = null + } + + private fun emitEvent(event: Pos.PaymentEvent) { + delegate?.onEvent(event) + } + + private fun checkInitialized() { + check(apiClient != null) { "ApiClient not initialized, call init() first" } + check(scope != null) { "Scope not initialized, call init() first" } + } + + private fun cleanup() { + currentPollingJob?.cancel() + currentPollingJob = null + scope?.cancel() + scope = null + apiClient = null + } +} \ No newline at end of file diff --git a/product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiClient.kt b/product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiClient.kt new file mode 100644 index 0000000000..9287b66207 --- /dev/null +++ b/product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiClient.kt @@ -0,0 +1,212 @@ +package com.walletconnect.pos.api + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.walletconnect.pos.BuildConfig +import com.walletconnect.pos.Pos +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.net.URI +import java.util.concurrent.TimeUnit + +internal class ApiClient( + private val apiKey: String, + private val deviceId: String, + baseUrl: String = BuildConfig.CORE_API_BASE_URL +) { + private val coreUrl = "$baseUrl/v1/gateway" + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(15, TimeUnit.SECONDS) + .build() + + private val moshi = Moshi.Builder() + .addLast(KotlinJsonAdapterFactory()) + .build() + + private val createPaymentRequestAdapter = moshi.adapter(CreatePaymentRequest::class.java) + private val createPaymentResponseAdapter = moshi.adapter(CreatePaymentResponse::class.java) + private val getPaymentRequestAdapter = moshi.adapter(GetPaymentRequest::class.java) + private val getPaymentResponseAdapter = moshi.adapter(GetPaymentResponse::class.java) + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + suspend fun createPayment( + referenceId: String, + unit: String, + value: String, + onEvent: (Pos.PaymentEvent) -> Unit + ) = withContext(Dispatchers.IO) { + val request = CreatePaymentRequest( + params = CreatePaymentParams( + referenceId = referenceId, + amount = Amount(unit = unit, value = value) + ) + ) + val jsonBody = createPaymentRequestAdapter.toJson(request) + val httpResponse = executeHttpRequest(jsonBody) + + when (httpResponse) { + is HttpResponse.Success -> { + val response = try { + createPaymentResponseAdapter.fromJson(httpResponse.body) + } catch (e: Exception) { + onEvent(Pos.PaymentEvent.PaymentError.Undefined("Failed to parse response: ${e.message}")) + return@withContext + } + + if (response == null) { + onEvent(Pos.PaymentEvent.PaymentError.Undefined("Null response")) + return@withContext + } + + when (response.status) { + "success" -> { + val data = response.data + if (data == null) { + onEvent(Pos.PaymentEvent.PaymentError.Undefined("Missing data in success response")) + return@withContext + } + + val uri = URI(buildPaymentUri(data.paymentId)) + onEvent( + Pos.PaymentEvent.PaymentCreated( + uri = uri, + amount = Pos.Amount(data.amount.unit, data.amount.value), + paymentId = data.paymentId + ) + ) + + startPolling(data.paymentId, data.pollInMs, onEvent) + } + + "error" -> { + onEvent( + mapCreatePaymentError( + response.error?.code ?: "UNKNOWN_ERROR", + response.error?.message ?: "Unknown error" + ) + ) + } + + else -> { + onEvent(Pos.PaymentEvent.PaymentError.Undefined("Unknown status: ${response.status}")) + } + } + } + + is HttpResponse.Error -> { + onEvent(mapCreatePaymentError(httpResponse.code, httpResponse.message)) + } + } + } + + private suspend fun startPolling( + paymentId: String, + initialPollMs: Long, + onEvent: (Pos.PaymentEvent) -> Unit + ) { + var pollDelayMs = initialPollMs + var lastEmittedStatus: String? = null + + while (true) { + delay(pollDelayMs) + + when (val result = getPayment(paymentId)) { + is ApiResult.Success -> { + val data = result.data + pollDelayMs = data.pollInMs + + if (data.status != lastEmittedStatus) { + lastEmittedStatus = data.status + onEvent(mapStatusToPaymentEvent(data.status, data.paymentId)) + } + + if (isTerminalStatus(data.status)) { + break + } + } + + is ApiResult.Error -> { + onEvent(mapErrorCodeToPaymentError(result.code, result.message)) + + if (isTerminalError(result.code) || result.code == "NETWORK_ERROR") { + break + } + } + } + } + } + + suspend fun getPayment(paymentId: String): ApiResult = withContext(Dispatchers.IO) { + val request = GetPaymentRequest(params = GetPaymentParams(paymentId = paymentId)) + val jsonBody = getPaymentRequestAdapter.toJson(request) + val httpResponse = executeHttpRequest(jsonBody) + + when (httpResponse) { + is HttpResponse.Success -> { + val response = try { + getPaymentResponseAdapter.fromJson(httpResponse.body) + } catch (e: Exception) { + return@withContext ApiResult.Error("PARSE_ERROR", "Failed to parse response: ${e.message}") + } + + if (response == null) { + return@withContext ApiResult.Error("PARSE_ERROR", "Null response") + } + + when (response.status) { + "success" -> { + val data = response.data + ?: return@withContext ApiResult.Error("PARSE_ERROR", "Missing data in success response") + ApiResult.Success(data) + } + + "error" -> { + ApiResult.Error( + code = response.error?.code ?: "UNKNOWN_ERROR", + message = response.error?.message ?: "Unknown error" + ) + } + + else -> ApiResult.Error("UNKNOWN_STATUS", "Unknown status: ${response.status}") + } + } + + is HttpResponse.Error -> ApiResult.Error(httpResponse.code, httpResponse.message) + } + } + + private fun executeHttpRequest(jsonBody: String): HttpResponse { + val httpRequest = Request.Builder() + .url(coreUrl) + .addHeader("X-Api-Key", apiKey) + .addHeader("X-Device-Id", deviceId) + .addHeader("X-Sdk-Version", "pos-kotlin-${BuildConfig.SDK_VERSION}") + .addHeader("Content-Type", "application/json") + .post(jsonBody.toRequestBody(jsonMediaType)) + .build() + + return try { + httpClient.newCall(httpRequest).execute().use { response -> + HttpResponse.Success(response.body.string()) + } + } catch (e: IOException) { + HttpResponse.Error("NETWORK_ERROR", e.message ?: "Network error") + } catch (e: Exception) { + HttpResponse.Error("UNKNOWN_ERROR", e.message ?: "Unknown error") + } + } + + private sealed class HttpResponse { + data class Success(val body: String) : HttpResponse() + data class Error(val code: String, val message: String) : HttpResponse() + } +} diff --git a/product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiModels.kt b/product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiModels.kt new file mode 100644 index 0000000000..86666534e0 --- /dev/null +++ b/product/pos/src/main/kotlin/com/walletconnect/pos/api/ApiModels.kt @@ -0,0 +1,89 @@ +package com.walletconnect.pos.api + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class Amount( + @param:Json(name = "unit") val unit: String, + @param:Json(name = "value") val value: String +) + +@JsonClass(generateAdapter = true) +internal data class ApiError( + @param:Json(name = "code") val code: String, + @param:Json(name = "message") val message: String +) + +@JsonClass(generateAdapter = true) +internal data class CreatePaymentRequest( + @param:Json(name = "method") val method: String = "createPayment", + @param:Json(name = "params") val params: CreatePaymentParams +) + +@JsonClass(generateAdapter = true) +internal data class CreatePaymentParams( + @param:Json(name = "referenceId") val referenceId: String, + @param:Json(name = "amount") val amount: Amount +) + +@JsonClass(generateAdapter = true) +internal data class CreatePaymentResponse( + @param:Json(name = "status") val status: String, + @param:Json(name = "data") val data: CreatePaymentData? = null, + @param:Json(name = "error") val error: ApiError? = null +) + +@JsonClass(generateAdapter = true) +internal data class CreatePaymentData( + @param:Json(name = "paymentId") val paymentId: String, + @param:Json(name = "status") val status: String, + @param:Json(name = "amount") val amount: Amount, + @param:Json(name = "expiresAt") val expiresAt: Long, + @param:Json(name = "pollInMs") val pollInMs: Long +) + +@JsonClass(generateAdapter = true) +internal data class GetPaymentRequest( + @param:Json(name = "method") val method: String = "getPayment", + @param:Json(name = "params") val params: GetPaymentParams +) + +@JsonClass(generateAdapter = true) +internal data class GetPaymentParams( + @param:Json(name = "paymentId") val paymentId: String +) + +@JsonClass(generateAdapter = true) +internal data class GetPaymentResponse( + @param:Json(name = "status") val status: String, + @param:Json(name = "data") val data: GetPaymentData? = null, + @param:Json(name = "error") val error: ApiError? = null +) + +@JsonClass(generateAdapter = true) +internal data class GetPaymentData( + @param:Json(name = "paymentId") val paymentId: String, + @param:Json(name = "status") val status: String, + @param:Json(name = "pollInMs") val pollInMs: Long +) + +internal sealed class ApiResult { + data class Success(val data: T) : ApiResult() + data class Error(val code: String, val message: String) : ApiResult() +} + +internal object PaymentStatus { + const val REQUIRES_ACTION = "requires_action" + const val PROCESSING = "processing" + const val SUCCEEDED = "succeeded" + const val EXPIRED = "expired" + const val FAILED = "failed" +} + +internal object ErrorCodes { + const val PAYMENT_NOT_FOUND = "PAYMENT_NOT_FOUND" + const val PAYMENT_EXPIRED = "PAYMENT_EXPIRED" + const val INVALID_REQUEST = "INVALID_REQUEST" + const val COMPLIANCE_FAILED = "COMPLIANCE_FAILED" +} diff --git a/product/pos/src/main/kotlin/com/walletconnect/pos/api/Mapping.kt b/product/pos/src/main/kotlin/com/walletconnect/pos/api/Mapping.kt new file mode 100644 index 0000000000..5c52007052 --- /dev/null +++ b/product/pos/src/main/kotlin/com/walletconnect/pos/api/Mapping.kt @@ -0,0 +1,51 @@ +package com.walletconnect.pos.api + +import com.walletconnect.pos.Pos + +internal fun mapErrorCodeToPaymentError(code: String, message: String): Pos.PaymentEvent.PaymentError { + return when (code) { + ErrorCodes.PAYMENT_NOT_FOUND -> Pos.PaymentEvent.PaymentError.PaymentNotFound(message) + ErrorCodes.PAYMENT_EXPIRED -> Pos.PaymentEvent.PaymentError.PaymentExpired(message) + ErrorCodes.INVALID_REQUEST -> Pos.PaymentEvent.PaymentError.InvalidPaymentRequest(message) + else -> Pos.PaymentEvent.PaymentError.Undefined(message) + } +} + +internal fun mapCreatePaymentError(code: String, message: String): Pos.PaymentEvent.PaymentError { + return when (code) { + ErrorCodes.INVALID_REQUEST -> Pos.PaymentEvent.PaymentError.InvalidPaymentRequest(message) + else -> Pos.PaymentEvent.PaymentError.CreatePaymentFailed(message) + } +} + +internal fun mapStatusToPaymentEvent(status: String, paymentId: String): Pos.PaymentEvent { + return when (status) { + PaymentStatus.REQUIRES_ACTION -> Pos.PaymentEvent.PaymentRequested + PaymentStatus.PROCESSING -> Pos.PaymentEvent.PaymentProcessing + PaymentStatus.SUCCEEDED -> Pos.PaymentEvent.PaymentSuccess(paymentId) + PaymentStatus.EXPIRED -> Pos.PaymentEvent.PaymentError.PaymentExpired("Payment has expired") + PaymentStatus.FAILED -> Pos.PaymentEvent.PaymentError.PaymentFailed("Payment failed") //TODO: add error message? + else -> Pos.PaymentEvent.PaymentError.Undefined("Unknown payment status: $status") + } +} + +internal fun isTerminalStatus(status: String): Boolean { + return status in listOf( + PaymentStatus.SUCCEEDED, + PaymentStatus.EXPIRED, + PaymentStatus.FAILED, + ) +} + +internal fun isTerminalError(code: String): Boolean { + return code in listOf( + ErrorCodes.PAYMENT_NOT_FOUND, + ErrorCodes.PAYMENT_EXPIRED, + ErrorCodes.INVALID_REQUEST, + ErrorCodes.COMPLIANCE_FAILED + ) +} + +internal fun buildPaymentUri(paymentId: String): String { + return "https://walletconnect.com/pay/$paymentId" +} diff --git a/product/pos/src/test/kotlin/com/walletconnect/pos/MappingTest.kt b/product/pos/src/test/kotlin/com/walletconnect/pos/MappingTest.kt new file mode 100644 index 0000000000..3b8d8b873b --- /dev/null +++ b/product/pos/src/test/kotlin/com/walletconnect/pos/MappingTest.kt @@ -0,0 +1,139 @@ +package com.walletconnect.pos + +import com.walletconnect.pos.api.ErrorCodes +import com.walletconnect.pos.api.PaymentStatus +import com.walletconnect.pos.api.buildPaymentUri +import com.walletconnect.pos.api.isTerminalError +import com.walletconnect.pos.api.isTerminalStatus +import com.walletconnect.pos.api.mapErrorCodeToPaymentError +import com.walletconnect.pos.api.mapStatusToPaymentEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MappingTest { + + @Test + fun `mapStatusToPaymentEvent - requires_action returns PaymentRequested`() { + val result = mapStatusToPaymentEvent(PaymentStatus.REQUIRES_ACTION, "pay_123") + assertEquals(Pos.PaymentEvent.PaymentRequested, result) + } + + @Test + fun `mapStatusToPaymentEvent - processing returns PaymentProcessing`() { + val result = mapStatusToPaymentEvent(PaymentStatus.PROCESSING, "pay_123") + assertEquals(Pos.PaymentEvent.PaymentProcessing, result) + } + + @Test + fun `mapStatusToPaymentEvent - succeeded returns PaymentSuccess with paymentId`() { + val result = mapStatusToPaymentEvent(PaymentStatus.SUCCEEDED, "pay_123") + assertTrue(result is Pos.PaymentEvent.PaymentSuccess) + assertEquals("pay_123", (result as Pos.PaymentEvent.PaymentSuccess).paymentId) + } + + @Test + fun `mapStatusToPaymentEvent - expired returns PaymentError PaymentExpired`() { + val result = mapStatusToPaymentEvent(PaymentStatus.EXPIRED, "pay_123") + assertTrue(result is Pos.PaymentEvent.PaymentError.PaymentExpired) + } + + @Test + fun `mapStatusToPaymentEvent - failed returns PaymentError PaymentFailed`() { + val result = mapStatusToPaymentEvent(PaymentStatus.FAILED, "pay_123") + assertTrue(result is Pos.PaymentEvent.PaymentError.PaymentFailed) + } + + @Test + fun `mapStatusToPaymentEvent - unknown status returns PaymentError Undefined`() { + val result = mapStatusToPaymentEvent("unknown_status", "pay_123") + assertTrue(result is Pos.PaymentEvent.PaymentError.Undefined) + } + + @Test + fun `mapErrorCodeToPaymentError - PAYMENT_NOT_FOUND returns PaymentNotFound`() { + val result = mapErrorCodeToPaymentError(ErrorCodes.PAYMENT_NOT_FOUND, "Not found") + assertTrue(result is Pos.PaymentEvent.PaymentError.PaymentNotFound) + assertEquals("Not found", (result as Pos.PaymentEvent.PaymentError.PaymentNotFound).message) + } + + @Test + fun `mapErrorCodeToPaymentError - PAYMENT_EXPIRED returns PaymentExpired`() { + val result = mapErrorCodeToPaymentError(ErrorCodes.PAYMENT_EXPIRED, "Expired") + assertTrue(result is Pos.PaymentEvent.PaymentError.PaymentExpired) + assertEquals("Expired", (result as Pos.PaymentEvent.PaymentError.PaymentExpired).message) + } + + @Test + fun `mapErrorCodeToPaymentError - INVALID_REQUEST returns InvalidPaymentRequest`() { + val result = mapErrorCodeToPaymentError(ErrorCodes.INVALID_REQUEST, "Invalid") + assertTrue(result is Pos.PaymentEvent.PaymentError.InvalidPaymentRequest) + assertEquals("Invalid", (result as Pos.PaymentEvent.PaymentError.InvalidPaymentRequest).message) + } + + @Test + fun `mapErrorCodeToPaymentError - unknown code returns Undefined`() { + val result = mapErrorCodeToPaymentError("UNKNOWN_CODE", "Unknown error") + assertTrue(result is Pos.PaymentEvent.PaymentError.Undefined) + assertEquals("Unknown error", (result as Pos.PaymentEvent.PaymentError.Undefined).message) + } + + @Test + fun `isTerminalStatus - succeeded is terminal`() { + assertTrue(isTerminalStatus(PaymentStatus.SUCCEEDED)) + } + + @Test + fun `isTerminalStatus - expired is terminal`() { + assertTrue(isTerminalStatus(PaymentStatus.EXPIRED)) + } + + @Test + fun `isTerminalStatus - failed is terminal`() { + assertTrue(isTerminalStatus(PaymentStatus.FAILED)) + } + + @Test + fun `isTerminalStatus - requires_action is not terminal`() { + assertFalse(isTerminalStatus(PaymentStatus.REQUIRES_ACTION)) + } + + @Test + fun `isTerminalStatus - processing is not terminal`() { + assertFalse(isTerminalStatus(PaymentStatus.PROCESSING)) + } + + @Test + fun `isTerminalError - PAYMENT_NOT_FOUND is terminal`() { + assertTrue(isTerminalError(ErrorCodes.PAYMENT_NOT_FOUND)) + } + + @Test + fun `isTerminalError - PAYMENT_EXPIRED is terminal`() { + assertTrue(isTerminalError(ErrorCodes.PAYMENT_EXPIRED)) + } + + @Test + fun `isTerminalError - INVALID_REQUEST is terminal`() { + assertTrue(isTerminalError(ErrorCodes.INVALID_REQUEST)) + } + + @Test + fun `buildPaymentUri - builds correct URI`() { + val result = buildPaymentUri("pay_abc123") + assertEquals("https://walletconnect.com/pay/pay_abc123", result) + } + + @Test + fun `Amount format - USD formats correctly`() { + val amount = Pos.Amount("iso4217/USD", "1000") + assertEquals("10.00 USD", amount.format()) + } + + @Test + fun `Amount format - EUR formats correctly`() { + val amount = Pos.Amount("iso4217/EUR", "1500") + assertEquals("15.00 EUR", amount.format()) + } +} diff --git a/product/pos/src/test/kotlin/com/walletconnect/pos/POSClientTest.kt b/product/pos/src/test/kotlin/com/walletconnect/pos/POSClientTest.kt new file mode 100644 index 0000000000..4e9203bc36 --- /dev/null +++ b/product/pos/src/test/kotlin/com/walletconnect/pos/POSClientTest.kt @@ -0,0 +1,120 @@ +package com.walletconnect.pos + +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class POSClientTest { + + @After + fun tearDown() { + PosClient.shutdown() + } + + @Test + fun `init - succeeds with valid parameters`() { + PosClient.init(apiKey = "test-api-key", deviceId = "test-device") + // No exception means success + } + + @Test + fun `init - throws on blank apiKey`() { + val exception = assertThrows(IllegalStateException::class.java) { + PosClient.init(apiKey = "", deviceId = "test-device") + } + assertTrue(exception.message?.contains("apiKey") == true) + } + + @Test + fun `init - throws on blank deviceId`() { + val exception = assertThrows(IllegalStateException::class.java) { + PosClient.init(apiKey = "test-api-key", deviceId = "") + } + assertTrue(exception.message?.contains("deviceId") == true) + } + + @Test + fun `createPaymentIntent - throws when not initialized`() { + val exception = assertThrows(IllegalStateException::class.java) { + PosClient.createPaymentIntent( + amount = Pos.Amount(unit = "iso4217/USD", value = "1000"), + referenceId = "ORDER-123" + ) + } + assertTrue(exception.message?.contains("not initialized") == true) + } + + @Test + fun `createPaymentIntent - succeeds when initialized`() { + PosClient.init(apiKey = "test-api-key", deviceId = "test-device") + + // This will attempt to make a real HTTP call which will fail, + // but we're just testing that it doesn't throw IllegalStateException + var eventReceived = false + PosClient.setDelegate(object : POSDelegate { + override fun onEvent(event: Pos.PaymentEvent) { + eventReceived = true + } + }) + + PosClient.createPaymentIntent( + amount = Pos.Amount(unit = "iso4217/USD", value = "1000"), + referenceId = "ORDER-123" + ) + + // Give it a moment for the coroutine to start + Thread.sleep(100) + + // Cancel the payment to prevent ongoing network calls + PosClient.cancelPayment() + } + + @Test + fun `checkPaymentStatus - throws when not initialized`() { + assertThrows(IllegalStateException::class.java) { + kotlinx.coroutines.runBlocking { + PosClient.checkPaymentStatus("pay_123") + } + } + } + + @Test + fun `setDelegate - can set delegate before init`() { + PosClient.setDelegate(object : POSDelegate { + override fun onEvent(event: Pos.PaymentEvent) { + // no-op + } + }) + // No exception means success + } + + @Test + fun `cancelPayment - safe to call when not polling`() { + PosClient.init(apiKey = "test-api-key", deviceId = "test-device") + PosClient.cancelPayment() + // No exception means success + } + + @Test + fun `shutdown - safe to call multiple times`() { + PosClient.init(apiKey = "test-api-key", deviceId = "test-device") + PosClient.shutdown() + PosClient.shutdown() + // No exception means success + } + + @Test + fun `shutdown - requires reinit after`() { + PosClient.init(apiKey = "test-api-key", deviceId = "test-device") + PosClient.shutdown() + + val exception = assertThrows(IllegalStateException::class.java) { + PosClient.createPaymentIntent( + amount = Pos.Amount(unit = "iso4217/USD", value = "1000"), + referenceId = "ORDER-123" + ) + } + assertTrue(exception.message?.contains("not initialized") == true) + } +} diff --git a/protocol/sign/build.gradle.kts b/protocol/sign/build.gradle.kts index 03b894b550..baccc829d2 100644 --- a/protocol/sign/build.gradle.kts +++ b/protocol/sign/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id(libs.plugins.android.library.get().pluginId) + id("com.android.library") id(libs.plugins.kotlin.android.get().pluginId) alias(libs.plugins.sqlDelight) alias(libs.plugins.google.ksp) diff --git a/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt b/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt index 0246183e7c..031f24324c 100644 --- a/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt +++ b/protocol/sign/src/main/kotlin/com/reown/sign/client/Sign.kt @@ -195,7 +195,7 @@ object Sign { data class JsonRpcResult( override val id: Long, - val result: String?, + val result: Any?, ) : JsonRpcResponse() data class JsonRpcError( diff --git a/sample/dapp/src/main/kotlin/com/reown/sample/dapp/ui/routes/composable_routes/account/AccountViewModel.kt b/sample/dapp/src/main/kotlin/com/reown/sample/dapp/ui/routes/composable_routes/account/AccountViewModel.kt index 010a5cfb4f..2c3da66a29 100644 --- a/sample/dapp/src/main/kotlin/com/reown/sample/dapp/ui/routes/composable_routes/account/AccountViewModel.kt +++ b/sample/dapp/src/main/kotlin/com/reown/sample/dapp/ui/routes/composable_routes/account/AccountViewModel.kt @@ -55,7 +55,7 @@ class AccountViewModel( is Modal.Model.JsonRpcResponse.JsonRpcResult -> { _awaitResponse.value = false val successResult = (walletEvent.result as Modal.Model.JsonRpcResponse.JsonRpcResult) - DappSampleEvents.RequestSuccess(successResult.result ?: "No result") + DappSampleEvents.RequestSuccess((successResult.result ?: "No result") as String) } is Modal.Model.JsonRpcResponse.JsonRpcError -> { diff --git a/sample/modal/src/main/kotlin/com/reown/sample/modal/ui/LabScreen.kt b/sample/modal/src/main/kotlin/com/reown/sample/modal/ui/LabScreen.kt index 2fe95482d1..7485ffc51e 100644 --- a/sample/modal/src/main/kotlin/com/reown/sample/modal/ui/LabScreen.kt +++ b/sample/modal/src/main/kotlin/com/reown/sample/modal/ui/LabScreen.kt @@ -50,9 +50,9 @@ fun LabScreen( is Modal.Model.SessionRequestResponse -> { when (event.result) { is Modal.Model.JsonRpcResponse.JsonRpcResult -> { - val resultString = (event.result as Modal.Model.JsonRpcResponse.JsonRpcResult).result + val resultString = (event.result as Modal.Model.JsonRpcResponse.JsonRpcResult).result as String val toastText = when { - resultString.isNullOrBlank() -> "Success" + resultString.isBlank() -> "Success" resultString.length > 200 -> resultString.take(200) + "…" else -> resultString } diff --git a/sample/pos/.gitignore b/sample/pos/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/sample/pos/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample/pos/build.gradle.kts b/sample/pos/build.gradle.kts new file mode 100644 index 0000000000..fbf279ab7a --- /dev/null +++ b/sample/pos/build.gradle.kts @@ -0,0 +1,103 @@ +plugins { + id(libs.plugins.android.application.get().pluginId) + id(libs.plugins.kotlin.android.get().pluginId) + id(libs.plugins.kotlin.parcelize.get().pluginId) + id(libs.plugins.kotlin.kapt.get().pluginId) + id("signing-config") + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.reown.sample.pos" + compileSdk = COMPILE_SDK + + defaultConfig { + applicationId = "com.reown.sample.pos" + minSdk = 29 + targetSdk = TARGET_SDK + versionName = SAMPLE_VERSION_NAME + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + buildConfigField("String", "PROJECT_ID", "\"${System.getenv("WC_CLOUD_PROJECT_ID") ?: ""}\"") + buildConfigField("String", "BOM_VERSION", "\"${BOM_VERSION}\"") + } + + buildTypes { + getByName("release") { + manifestPlaceholders["pathPrefix"] = "/dapp_release" + buildConfigField("String", "DAPP_APP_LINK", "\"https://appkit-lab.reown.com/dapp_release\"") + } + + getByName("internal") { + manifestPlaceholders["pathPrefix"] = "/dapp_internal" + buildConfigField("String", "DAPP_APP_LINK", "\"https://appkit-lab.reown.com/dapp_internal\"") + + } + + getByName("debug") { + manifestPlaceholders["pathPrefix"] = "/dapp_debug" + buildConfigField("String", "DAPP_APP_LINK", "\"https://appkit-lab.reown.com/dapp_debug\"") + } + } + + lint { + abortOnError = true + ignoreWarnings = true + warningsAsErrors = false + } + + compileOptions { + sourceCompatibility = jvmVersion + targetCompatibility = jvmVersion + } + kotlinOptions { + jvmTarget = jvmVersion.toString() + freeCompilerArgs = listOf("-Xcontext-receivers") + } + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } +} + +dependencies { + implementation(project(":sample:common")) + + debugImplementation(project(":core:android")) + debugImplementation(project(":product:pos")) + + internalImplementation(project(":core:android")) + internalImplementation(project(":product:pos")) + + releaseImplementation(platform("com.reown:android-bom:$BOM_VERSION")) + releaseImplementation("com.reown:android-core") + releaseImplementation("com.reown:pos") + + implementation(libs.bundles.accompanist) + + implementation(libs.qrCodeGenerator) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.navigation) + implementation(libs.androidx.compose.lifecycle) + + implementation("androidx.compose.material3:material3:1.3.2") + implementation("io.coil-kt.coil3:coil-compose:3.3.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0") + implementation("com.google.zxing:core:3.5.3") + + implementation(libs.androidx.core) + implementation(libs.androidx.appCompat) + implementation(libs.androidx.material) + + // Logging + implementation("com.jakewharton.timber:timber:5.0.1") + testImplementation(libs.jUnit) +} \ No newline at end of file diff --git a/sample/pos/proguard-rules.pro b/sample/pos/proguard-rules.pro new file mode 100644 index 0000000000..3abc39c8e2 --- /dev/null +++ b/sample/pos/proguard-rules.pro @@ -0,0 +1,2 @@ +-keepnames class com.fasterxml.jackson.** { *; } +-dontwarn com.fasterxml.jackson.databind.** \ No newline at end of file diff --git a/sample/pos/src/main/AndroidManifest.xml b/sample/pos/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..469eda7d6d --- /dev/null +++ b/sample/pos/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/POSActivity.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSActivity.kt new file mode 100644 index 0000000000..168d0eeb4f --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSActivity.kt @@ -0,0 +1,16 @@ +package com.reown.sample.pos + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity + +class POSActivity : AppCompatActivity() { + private val viewModel: POSViewModel = POSViewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { POSSampleHost(viewModel) } + } +} \ No newline at end of file diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/POSApplication.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSApplication.kt new file mode 100644 index 0000000000..0708aac4eb --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSApplication.kt @@ -0,0 +1,31 @@ +package com.reown.sample.pos + +import android.app.Application +import com.walletconnect.pos.PosClient +import timber.log.Timber + +class POSApplication : Application() { + + override fun onCreate() { + super.onCreate() + + // Initialize Timber for logging (if available) + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + + // Initialize the lightweight POS SDK + val apiKey = BuildConfig.PROJECT_ID + val deviceId = "sample_pos_device_${android.os.Build.MODEL}_${android.os.Build.SERIAL}" + + PosClient.init( + apiKey = apiKey, + deviceId = deviceId + ) + + // Set the delegate to receive payment events + PosClient.setDelegate(PosSampleDelegate) + + Timber.d("POSClient initialized successfully") + } +} diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/POSSampleHost.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSSampleHost.kt new file mode 100644 index 0000000000..9ecf803681 --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSSampleHost.kt @@ -0,0 +1,132 @@ +package com.reown.sample.pos + +import android.net.Uri +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.material.ScaffoldState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.reown.sample.pos.screens.AmountScreen +import com.reown.sample.pos.screens.ErrorScreen +import com.reown.sample.pos.screens.PaymentScreen +import com.reown.sample.pos.screens.StartPaymentScreen + +sealed class Screen(val route: String, val label: String) { + object StartPaymentScreen : Screen("start", "Home") + object AmountScreen : Screen("amount", "Amount") + object PaymentScreen : Screen("payment?qrUrl={qrUrl}", "Payment") { + fun routeWith(qrUrl: String) = "payment?qrUrl=${Uri.encode(qrUrl)}" + const val arg = "qrUrl" + } + object ErrorScreen : Screen("error?message={message}", "Error") { + fun routeWith(message: String) = "error?message=$message" + const val arg = "message" + } +} + +@Composable +fun POSSampleHost(viewModel: POSViewModel, navController: NavHostController = rememberNavController()) { + val scaffoldState: ScaffoldState = rememberScaffoldState() + + Scaffold(scaffoldState = scaffoldState) { paddings -> + NavHost( + navController = navController, + startDestination = Screen.StartPaymentScreen.route, + modifier = Modifier.padding(paddings) + ) { + composable(Screen.StartPaymentScreen.route) { + StartPaymentScreen(viewModel) + } + + composable(Screen.AmountScreen.route) { + AmountScreen(viewModel) + } + + composable( + route = Screen.PaymentScreen.route, + arguments = listOf(navArgument(Screen.PaymentScreen.arg) { + type = NavType.StringType + nullable = true + defaultValue = null + }) + ) { backStackEntry -> + val qrUrl = backStackEntry.arguments?.getString(Screen.PaymentScreen.arg) + PaymentScreen( + viewModel = viewModel, + qrUrl = qrUrl.orEmpty(), + onReturnToStart = { + viewModel.resetForNewPayment() + navController.navigate(Screen.StartPaymentScreen.route) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + }, + navigateToErrorScreen = { error -> + navController.navigate("error?message=${error}") { launchSingleTop = true } + } + ) + } + + composable( + route = Screen.ErrorScreen.route, + arguments = listOf(navArgument(Screen.ErrorScreen.arg) { + type = NavType.StringType + nullable = true + defaultValue = null + }) + ) { backStackEntry -> + val message = backStackEntry.arguments?.getString(Screen.ErrorScreen.arg) + ErrorScreen(message = message.orEmpty()) { + viewModel.resetForNewPayment() + navController.navigate(Screen.StartPaymentScreen.route) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + } + } + } + } + + LaunchedEffect(Unit) { + viewModel.posNavEventsFlow.collect { event -> + when (event) { + PosNavEvent.ToStart -> navController.navigate(Screen.StartPaymentScreen.route) { + launchSingleTop = true + popUpTo(Screen.StartPaymentScreen.route) { inclusive = false } + } + + PosNavEvent.ToAmount -> navController.navigate(Screen.AmountScreen.route) { + launchSingleTop = true + } + + is PosNavEvent.QrReady -> navController.navigate("payment?qrUrl=${Uri.encode(event.uri.toString())}") { + launchSingleTop = true + } + + PosNavEvent.FlowFinished -> { + viewModel.resetForNewPayment() + navController.navigate(Screen.StartPaymentScreen.route) { + popUpTo(navController.graph.startDestinationId) { inclusive = true } + launchSingleTop = true + } + } + + is PosNavEvent.PaymentSuccessScreen -> { + // Stay on payment screen, success is shown there + } + + is PosNavEvent.ToErrorScreen -> navController.navigate("error?message=${event.error}") { + launchSingleTop = true + } + } + } + } +} diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/POSViewModel.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSViewModel.kt new file mode 100644 index 0000000000..86421f11a8 --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/POSViewModel.kt @@ -0,0 +1,148 @@ +package com.reown.sample.pos + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.walletconnect.pos.Pos +import com.walletconnect.pos.PosClient +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.net.URI + +sealed interface PosNavEvent { + data object ToStart : PosNavEvent + data object ToAmount : PosNavEvent + data object FlowFinished : PosNavEvent + data class QrReady(val uri: URI, val amount: Pos.Amount, val paymentId: String) : PosNavEvent + data class ToErrorScreen(val error: String) : PosNavEvent + data class PaymentSuccessScreen(val paymentId: String) : PosNavEvent +} + +sealed interface PosEvent { + data object PaymentRequested : PosEvent + data object PaymentProcessing : PosEvent + data class PaymentSuccess(val paymentId: String) : PosEvent + data class PaymentError(val error: String) : PosEvent +} + +class POSViewModel : ViewModel() { + + private val _posNavEventsFlow: MutableSharedFlow = MutableSharedFlow() + val posNavEventsFlow = _posNavEventsFlow.asSharedFlow() + + private val _posEventsFlow: MutableSharedFlow = MutableSharedFlow() + val posEventsFlow = _posEventsFlow.asSharedFlow() + + // Current payment info + private var currentAmount: Pos.Amount? = null + private var currentPaymentId: String? = null + + // Loading state for "Start Payment" button + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + init { + viewModelScope.launch { + PosSampleDelegate.paymentEventFlow.collect { paymentEvent -> + handlePaymentEvent(paymentEvent) + } + } + } + + private suspend fun handlePaymentEvent(paymentEvent: Pos.PaymentEvent) { + when (paymentEvent) { + is Pos.PaymentEvent.PaymentCreated -> { + _isLoading.value = false + currentAmount = paymentEvent.amount + currentPaymentId = paymentEvent.paymentId + _posNavEventsFlow.emit( + PosNavEvent.QrReady( + uri = paymentEvent.uri, + amount = paymentEvent.amount, + paymentId = paymentEvent.paymentId + ) + ) + } + + is Pos.PaymentEvent.PaymentRequested -> { + _posEventsFlow.emit(PosEvent.PaymentRequested) + } + + is Pos.PaymentEvent.PaymentProcessing -> { + _posEventsFlow.emit(PosEvent.PaymentProcessing) + } + + is Pos.PaymentEvent.PaymentSuccess -> { + _posEventsFlow.emit(PosEvent.PaymentSuccess(paymentEvent.paymentId)) + _posNavEventsFlow.emit(PosNavEvent.PaymentSuccessScreen(paymentEvent.paymentId)) + } + + is Pos.PaymentEvent.PaymentError -> { + _isLoading.value = false + val errorMessage = when (val error: Pos.PaymentEvent.PaymentError = paymentEvent) { + is Pos.PaymentEvent.PaymentError.CreatePaymentFailed -> "Failed to create payment, try again: ${error.message}" + is Pos.PaymentEvent.PaymentError.PaymentFailed -> "Payment failed, try again: ${error.message}" + is Pos.PaymentEvent.PaymentError.PaymentNotFound -> "Payment not found, try again: ${error.message}" + is Pos.PaymentEvent.PaymentError.PaymentExpired -> "Payment expired, try again: ${error.message}" + is Pos.PaymentEvent.PaymentError.InvalidPaymentRequest -> "Invalid request, try again: ${error.message}" + is Pos.PaymentEvent.PaymentError.Undefined -> "Undefined Error, try again: ${error.message}" + } + _posEventsFlow.emit(PosEvent.PaymentError(errorMessage)) + _posNavEventsFlow.emit(PosNavEvent.ToErrorScreen(error = errorMessage)) + } + } + } + + fun navigateToAmountScreen() { + viewModelScope.launch { _posNavEventsFlow.emit(PosNavEvent.ToAmount) } + } + + /** + * Creates a payment intent with the specified amount. + * + * @param amountValue Amount in minor units (cents for USD) + * @param currency Currency code (e.g., "USD", "EUR") + */ + fun createPayment(amountValue: String, currency: String = "USD") { + try { + val referenceId = "ORDER-${System.currentTimeMillis()}" + _isLoading.value = true + + PosClient.createPaymentIntent( + amount = Pos.Amount( + unit = "iso4217/$currency", + value = amountValue + ), + referenceId = referenceId + ) + } catch (e: Exception) { + _isLoading.value = false + viewModelScope.launch { + _posNavEventsFlow.emit( + PosNavEvent.ToErrorScreen(error = e.message ?: "Create payment error") + ) + } + } + } + + fun cancelPayment() { + PosClient.cancelPayment() + _isLoading.value = false + } + + fun resetForNewPayment() { + currentAmount = null + currentPaymentId = null + _isLoading.value = false + } + + fun getDisplayAmount(): String { + val amount = currentAmount ?: return "" + val valueInCents = amount.value.toLongOrNull() ?: 0L + val dollars = valueInCents / 100.0 + val currency = amount.unit.substringAfter("/", "USD") + return String.format("$%.2f %s", dollars, currency) + } +} diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/PosSampleDelegate.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/PosSampleDelegate.kt new file mode 100644 index 0000000000..a498b77d2a --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/PosSampleDelegate.kt @@ -0,0 +1,23 @@ +package com.reown.sample.pos + +import com.walletconnect.pos.Pos +import com.walletconnect.pos.POSDelegate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +object PosSampleDelegate : POSDelegate { + private val posScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val _paymentEventFlow: MutableSharedFlow = MutableSharedFlow() + val paymentEventFlow = _paymentEventFlow.asSharedFlow() + + override fun onEvent(event: Pos.PaymentEvent) { + posScope.launch { + _paymentEventFlow.emit(event) + } + } +} \ No newline at end of file diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/AmountScreen.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/AmountScreen.kt new file mode 100644 index 0000000000..7ee0f2306b --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/AmountScreen.kt @@ -0,0 +1,215 @@ +package com.reown.sample.pos.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.text.input.KeyboardType +import com.reown.sample.pos.POSViewModel + +@Composable +fun AmountScreen( + viewModel: POSViewModel, + modifier: Modifier = Modifier +) { + val brandGreen = Color(0xFF0A8F5B) + var amountDisplay by rememberSaveable { mutableStateOf("") } + val isLoading by viewModel.isLoading.collectAsState() + + // Convert display amount (dollars) to minor units (cents) + fun getAmountInCents(): String { + val dollars = amountDisplay.toDoubleOrNull() ?: 0.0 + return (dollars * 100).toLong().toString() + } + + Column( + modifier = modifier + .fillMaxSize() + .imePadding() + ) { + // Header + Column( + modifier = Modifier + .fillMaxWidth() + .background(brandGreen) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "WalletConnect Pay", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.ExtraBold + ) + Text( + "POS Sample App", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.95f) + ) + } + + // Content + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(24.dp)) + + Text( + "Enter Amount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(6.dp)) + Text( + "Enter the payment amount in USD", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(24.dp)) + + // Amount input card + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextField( + value = amountDisplay, + onValueChange = { new -> + // Filter to allow only numbers and one decimal point + val filtered = new + .replace(Regex("[^0-9.]"), "") + .let { s -> + val firstDot = s.indexOf('.') + if (firstDot == -1) s else + s.substring(0, firstDot + 1) + s.substring(firstDot + 1).replace(".", "") + } + // Limit decimal places to 2 + val parts = filtered.split(".") + amountDisplay = if (parts.size == 2 && parts[1].length > 2) { + "${parts[0]}.${parts[1].take(2)}" + } else { + filtered + } + }, + singleLine = true, + textStyle = TextStyle( + fontSize = 48.sp, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center + ), + leadingIcon = { + Text( + "$", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold + ) + }, + placeholder = { + Text( + "0.00", + fontSize = 48.sp, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "USD", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(Modifier.weight(1f)) + + // Start Payment button + Button( + onClick = { + val amountInCents = getAmountInCents() + if (amountInCents.toLongOrNull() ?: 0L > 0) { + viewModel.createPayment(amountInCents, "USD") + } + }, + enabled = amountDisplay.isNotBlank() && + (amountDisplay.toDoubleOrNull() ?: 0.0) > 0 && + !isLoading, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = brandGreen) + ) { + if (isLoading) { + CircularProgressIndicator( + color = Color.White, + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + } else { + Text( + "Start Payment", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(Modifier.height(16.dp)) + } + + // Footer + Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Powered by WalletConnect", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/ErrorScreen.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/ErrorScreen.kt new file mode 100644 index 0000000000..9df0113213 --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/ErrorScreen.kt @@ -0,0 +1,69 @@ +package com.reown.sample.pos.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorScreen( + message: String, + onReturnToStart: () -> Unit, +) { + val errorRed = Color(0xFFD32F2F) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Error icon + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Error", + tint = errorRed, + modifier = Modifier.size(80.dp) + ) + + Spacer(Modifier.height(20.dp)) + + // Error message + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = errorRed, + textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold + ) + + Spacer(Modifier.height(32.dp)) + + // Try Again button pinned bottom + Button( + onClick = onReturnToStart, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = errorRed) + ) { + Text( + text = "Try Again", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + } +} \ No newline at end of file diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/PaymentScreen.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/PaymentScreen.kt new file mode 100644 index 0000000000..caf71622c6 --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/PaymentScreen.kt @@ -0,0 +1,413 @@ +package com.reown.sample.pos.screens + +import android.content.res.Resources +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.reown.sample.pos.POSViewModel +import com.reown.sample.pos.PosEvent +import kotlinx.coroutines.flow.collectLatest +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import com.reown.sample.pos.R + +private enum class StepState { Inactive, InProgress, Done } + +private sealed interface PaymentUiState { + data object WaitingForScan : PaymentUiState + data class Success(val paymentId: String) : PaymentUiState +} + +@Composable +fun PaymentScreen( + viewModel: POSViewModel, + qrUrl: String, + onReturnToStart: () -> Unit, + navigateToErrorScreen: (error: String) -> Unit, + modifier: Modifier = Modifier +) { + val brandGreen = Color(0xFF0A8F5B) + + var uiState by remember { mutableStateOf(PaymentUiState.WaitingForScan) } + + // Timeline step states + var scanStep by remember { mutableStateOf(StepState.InProgress) } + var processingStep by remember { mutableStateOf(StepState.Inactive) } + var confirmingStep by remember { mutableStateOf(StepState.Inactive) } + + LaunchedEffect(Unit) { + viewModel.posEventsFlow.collectLatest { event -> + when (event) { + PosEvent.PaymentRequested -> { + scanStep = StepState.Done + processingStep = StepState.InProgress + } + + PosEvent.PaymentProcessing -> { + processingStep = StepState.Done + confirmingStep = StepState.InProgress + } + + is PosEvent.PaymentSuccess -> { + confirmingStep = StepState.Done + uiState = PaymentUiState.Success(paymentId = event.paymentId) + } + + is PosEvent.PaymentError -> { + navigateToErrorScreen(event.error) + } + } + } + } + + Column( + modifier = modifier + .fillMaxSize() + ) { + // Header + Column( + modifier = Modifier + .fillMaxWidth() + .background(brandGreen) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "WalletConnect Pay", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.ExtraBold + ) + Text( + "POS Sample App", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.95f) + ) + } + + // Content + Box( + Modifier + .weight(1f) + .fillMaxWidth() + ) { + when (val state = uiState) { + PaymentUiState.WaitingForScan -> ScanToPayContent( + qrUrl = qrUrl, + displayAmount = viewModel.getDisplayAmount(), + scanState = scanStep, + processingState = processingStep, + confirmingState = confirmingStep + ) + + is PaymentUiState.Success -> SuccessContent( + paymentId = state.paymentId, + onReturnToStart = onReturnToStart + ) + } + } + + // Footer + Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (uiState is PaymentUiState.WaitingForScan) { + Button( + onClick = { + viewModel.cancelPayment() + onReturnToStart() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ), + modifier = Modifier.height(44.dp) + ) { + Text("Cancel Payment") + } + Spacer(Modifier.height(8.dp)) + } + Text( + "Powered by WalletConnect", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ScanToPayContent( + qrUrl: String, + displayAmount: String, + scanState: StepState, + processingState: StepState, + confirmingState: StepState +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(24.dp)) + + Text( + "Scan to Pay", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(6.dp)) + Text( + "Customer scans QR code with wallet", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(20.dp)) + + // QR Code + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 1.dp + ) { + Box( + modifier = Modifier + .sizeIn(minWidth = 220.dp, minHeight = 220.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + QrImage(data = qrUrl, size = 220.dp) + } + } + + Spacer(Modifier.height(20.dp)) + + // Amount display + if (displayAmount.isNotBlank()) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + displayAmount, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 12.dp) + ) + } + Spacer(Modifier.height(20.dp)) + } + + // Timeline + StatusRow( + state = scanState, + inProgressText = "Waiting for wallet scan…", + doneText = "Payment initiated" + ) + Spacer(Modifier.height(8.dp)) + StatusRow( + state = processingState, + inProgressText = "Processing payment…", + doneText = "Payment processing" + ) + Spacer(Modifier.height(8.dp)) + StatusRow( + state = confirmingState, + inProgressText = "Confirming transaction…", + doneText = "Payment confirmed" + ) + + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun StatusRow( + state: StepState, + inProgressText: String, + doneText: String +) { + val green = Color(0xFF0A8F5B) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + when (state) { + StepState.Inactive -> { + Box( + modifier = Modifier + .size(18.dp) + .background(MaterialTheme.colorScheme.outlineVariant, shape = CircleShape) + ) + Spacer(Modifier.width(8.dp)) + Text( + inProgressText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + + StepState.InProgress -> { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(18.dp), + color = green + ) + Spacer(Modifier.width(8.dp)) + Text( + inProgressText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + StepState.Done -> { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + tint = green, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + doneText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun SuccessContent( + paymentId: String, + onReturnToStart: () -> Unit +) { + val brandGreen = Color(0xFF0A8F5B) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = brandGreen, + modifier = Modifier.size(80.dp) + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Payment Successful!", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + color = brandGreen + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Payment ID", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + paymentId, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(Modifier.height(32.dp)) + + Button( + onClick = onReturnToStart, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = brandGreen) + ) { + Text( + "New Payment", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +fun QrImage( + data: String, + size: Dp = 220.dp +) { + val bmp by remember(data) { + mutableStateOf( + generateQrBitmap( + data, + sizePx = (size.value * Resources.getSystem().displayMetrics.density).toInt() + ) + ) + } + if (bmp != null) { + Image( + bitmap = bmp!!.asImageBitmap(), + contentDescription = "QR Code", + modifier = Modifier.size(size) + ) + } +} + +private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? { + return try { + val hints = mapOf(EncodeHintType.MARGIN to 1) + val bitMatrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx, hints) + val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) + for (x in 0 until sizePx) { + for (y in 0 until sizePx) { + bmp.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()) + } + } + bmp + } catch (_: Throwable) { + null + } +} diff --git a/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/StartPaymentScreen.kt b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/StartPaymentScreen.kt new file mode 100644 index 0000000000..896a035858 --- /dev/null +++ b/sample/pos/src/main/kotlin/com/reown/sample/pos/screens/StartPaymentScreen.kt @@ -0,0 +1,140 @@ +package com.reown.sample.pos.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.reown.sample.pos.POSViewModel + +@Composable +fun StartPaymentScreen( + viewModel: POSViewModel, + merchantName: String = "Sample POS Terminal", + modifier: Modifier = Modifier +) { + val brandGreen = Color(0xFF0A8F5B) + + Column( + modifier = modifier + .fillMaxSize() + ) { + // Header + Column( + modifier = Modifier + .fillMaxWidth() + .background(brandGreen) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "WalletConnect Pay", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.ExtraBold + ) + Text( + "POS Sample App", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.95f) + ) + } + + // Content + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(48.dp)) + + Text( + "Welcome", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(10.dp)) + Text( + "Accept crypto payments easily", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(32.dp)) + + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + merchantName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + "Ready to accept payments", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + Spacer(Modifier.weight(1f)) + + Button( + onClick = { viewModel.navigateToAmountScreen() }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = brandGreen) + ) { + Text( + "New Payment", + style = MaterialTheme.typography.titleMedium, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(Modifier.height(16.dp)) + } + + // Footer + Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + "Powered by WalletConnect", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/sample/pos/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/pos/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/sample/pos/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/pos/src/main/res/drawable/ic_check.xml b/sample/pos/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000000..1ae7e3e79b --- /dev/null +++ b/sample/pos/src/main/res/drawable/ic_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/sample/pos/src/main/res/drawable/ic_launcher_background.xml b/sample/pos/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/sample/pos/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/pos/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/pos/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/sample/pos/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/pos/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/pos/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/sample/pos/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/pos/src/main/res/mipmap-hdpi/ic_launcher.webp b/sample/pos/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/sample/pos/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/sample/pos/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample/pos/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/sample/pos/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/sample/pos/src/main/res/mipmap-mdpi/ic_launcher.webp b/sample/pos/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/sample/pos/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/sample/pos/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sample/pos/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/sample/pos/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/sample/pos/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample/pos/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/sample/pos/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/sample/pos/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample/pos/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/sample/pos/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/sample/pos/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample/pos/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/sample/pos/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/sample/pos/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sample/pos/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/sample/pos/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/sample/pos/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sample/pos/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/sample/pos/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/sample/pos/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sample/pos/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/sample/pos/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/sample/pos/src/main/res/values-night/themes.xml b/sample/pos/src/main/res/values-night/themes.xml new file mode 100644 index 0000000000..09a57c1892 --- /dev/null +++ b/sample/pos/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/sample/pos/src/main/res/values/colors.xml b/sample/pos/src/main/res/values/colors.xml new file mode 100644 index 0000000000..c8524cd961 --- /dev/null +++ b/sample/pos/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/sample/pos/src/main/res/values/strings.xml b/sample/pos/src/main/res/values/strings.xml new file mode 100644 index 0000000000..7e48a449cd --- /dev/null +++ b/sample/pos/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + POSApplication + \ No newline at end of file diff --git a/sample/pos/src/main/res/values/themes.xml b/sample/pos/src/main/res/values/themes.xml new file mode 100644 index 0000000000..e85091b79e --- /dev/null +++ b/sample/pos/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +