diff --git a/.github/workflows/pub_dev_publish.yml b/.github/workflows/pub_dev_publish.yml new file mode 100644 index 0000000..57c876d --- /dev/null +++ b/.github/workflows/pub_dev_publish.yml @@ -0,0 +1,55 @@ +# DO NOT RUN THIS MANUALLY - THIS GETS RUN AS PART OF A DIFFERENT GH ACTIONS SCRIPT + +name: Publish Release to Pub.dev +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+*' + +env: + FLUTTER_VERSION: 3.16.1 + +jobs: + Release-It-To-The-People: + runs-on: macos-13 + environment: live_pub_dev + permissions: + id-token: write + contents: read + steps: + - name: "Test Release Check" + id: test-release-check + run: | + if [[ ${{ github.repository_owner }} == *"ololabs-playground"* ]] + then + echo "is-test=true" >> "$GITHUB_OUTPUT" + else + echo "is-test=false" >> "$GITHUB_OUTPUT" + fi + + - name: "Checkout Project" + uses: actions/checkout@v3 + + - name: Setup Flutter + uses: flutter-actions/setup-flutter@v2.3 + with: + channel: stable + version: ${{ env.FLUTTER_VERSION }} + + - name: "Install Flutter Dependencies" + run: | + flutter pub get + + - name: "Publish Dry Run" + if: ${{ steps.test-release-check.outputs.is-test == 'true' }} + run: | + flutter pub publish --dry-run + + - name: "Pub.dev Token Authentication" + if: ${{ steps.test-release-check.outputs.is-test == 'false' }} + uses: dart-lang/setup-dart@v1 + + - name: "Publish Release" + if: ${{ steps.test-release-check.outputs.is-test == 'false' }} + run: flutter pub publish --force + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42e415c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,113 @@ +# Olo Pay Flutter SDK Changelog + +## v1.2.0 (Aug 20, 2024) + +### Updates +- `CardDetailsSingleLineTextField`: Added ability to set alignment of the built in error message with `errorAlignment` +- `CardDetailsSingleLineTextField`: Changed default `maxHeight` to `45` +- `CardDetailsSingleLineTextField`: Changed default `errorMarginTop` to `8.0` +- Deprecated `ErrorCodes.missingParameter`: All previous usages have changed to `ErrorCodes.InvalidParameter` +- Introduce `CvvTextField` widget for CVV tokenization. + +### Dependency Updates +- Native SDKs + - Updated to use [Olo Pay Android SDK v3.1.1](https://github.com/ololabs/olo-pay-android-sdk-releases/releases/tag/3.1.1) + - Updated to use [Olo Pay iOS SDK v4.0.2](https://github.com/ololabs/olo-pay-ios-sdk-releases/releases/tag/4.0.2) + +- Android Project + - Updated to Gradle v8.2 + - Updated to Java v17 + - Updated to `com.android.tools.build:gradle:8.2.2` + - Updated to `androidx.core:core-ktx:1.13.1` + - Updated to `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1` + - Updated to `com.google.android.material:material:1.12.0` + +### Bug Fixes +- `CardDetailsSingleLineTextField`: Fix bug causing slight difference between the default input text error state color and the default error message color. +- `CardDetailsSingleLineTextField`: Fixed constraints so that the text input widget height properly expands/shrinks to match the height specified by the constructor parameters + +## v1.1.0 (Mar 26, 2024) - First Public Release + +### Breaking Changes +- `OloPaySetupParameters`: Removed `freshSetup` parameter + +### Updates +- `CardDetailsSingleLineTextField`: Added built-in error message label to automatically display error messages +- `CardDetailsSingleLineTextField`: Added `errorStyles` property to style the built-in error message label +- `CardDetailsSingleLineTextField`: Added `displayErrorMessages` property to control visibility of the error message label +- `CardDetailsSingleLineTextField`: Added custom error message support via the `customErrorMessages` property +- `CardDetailsSingleLineTextField`: Added support for custom fonts via `TextStyles` +- `CardDetailsSingleLineTextField`: Fixed issue on iOS sometimes causing duplicate error messages to display +- `OloPaySdk`: Added `getFontNames()` for help with debugging custom font issues on iOS +- `OloPaySdk`: Changed `isDigitalWalletReady()` on iOS so it returns false if the SDK isn't initialized to align with Android behavior +- `TextStyles`: Added `fontAsset` and `iOSFontName` properties + +## v1.0.1 (Feb 7, 2024) + +### Bug Fixes +- `CardDetailsSingleLineTextField`: Fix small edge case preventing `textStyles` and `paddingStyles` from respecting theme values +- `CardType`: Fix typo causing Mastercard cards to map to `CardType.unknown` + +### Updates +- `TextStyles`: Fixed incorrect documentation for `merge()` +- `TextStyles`: Added `defaultCursorColor` property + +## v1.0.0 (Jan 26, 2024) + +### Updates +- ReadMe updates + +## v0.3.0 (Jan 17, 2024) + +### Breaking Changes +- Consolidate import statements so only one is required +- Renamed classes and types for clarity + - `PaymentCardDetailsSingleLineWidget` --> `CardDetailsSingleLineTextField` + - `PaymentCardDetailsSingleLineWidgetController` --> `CardDetailsSingleLineTextFieldController` + - `PaymentCardDetailsSingleLineWidgetControllerCreated` --> `CardDetailsSingleLineTextFieldControllerCreated` + - `PaymentCardDetailsSingleLineWidgetOnErrorMessageChanged` --> `CardDetailsErrorMessageChanged` + - `PaymentCardDetailsSingleLineWidgetOnInputChanged` --> `CardDetailsInputChanged` + - `PaymentCardDetailsSingleLineWidgetOnValidStateChanged` --> `CardDetailsValidStateChanged` + - `PaymentCardDetailsSingleLineWidgetOnFocusChanged` --> `CardDetailsFocusChanged` +- `PaymentMethod.cardType`: Changed type from `String` to `CardType` +- Updated all data classes to use `final` properties and `const` constructors + - `OloPaySetupParameters` + - `GooglePaySetupParameters` + - `ApplePaySetupParameters` + - `DigitalWalletPaymentParameters` + - `GooglePayVendorParameters` + - `PaymentMethod` + - `CardFieldState` + - `Hints` + - `TextStyles` + - `BackgroundStyles` + - `PaddingStyles` + +### Updates +- Change minimum iOS version to iOS 13 +- `CardDetailsSingleLineTextField`: Add support for light/dark themes +- `CardDetailsSingleLineTextField`: Add explicit default styles +- `CardDetailsSingleLineTextField`: Add support for updating styles based on state changes + + +## v0.2.0 (Dec 21, 2023) + +### Breaking Changes +- `OloPaySdk.initializeOloPay()`: Changed from positional to named parameters +- `PaymentCardDetailsSingleLineWidgetControllerCreated`: Moved typedef to `data_types.dart` +- `PaymentCardDetailsSingleLineWidget`: Changed `onControllerCreated` to a required parameter + +### Updates +- `PaymentCardDetailsSingleLineWidget`: Background and text styling support +- `PaymentCardDetailsSingleLineWidget`: Added event handlers for error message changes, valid state changes, and input changes +- `PaymentCardDetailsSingleLineWigetController`: Additional methods for controlling/interacting with the widget +- Digital Wallet Support (Apple Pay & Google Pay) + + +## v0.1.0 (Dec 11, 2023) + +### Initial Release +- Use `PaymentCardDetailsSingleLineWidget` to display a single line card input widget +- Use `PaymentCardDetailsSingleLineWidgetController.createPaymentMethod` to create a payment method based on user-entered card details +- Uses [Olo Pay Android SDK v3.0.0](https://github.com/ololabs/olo-pay-android-sdk-releases/releases/tag/v3.0.0-full) +- Uses [Olo Pay iOS SDK v4.0.0](https://github.com/ololabs/olo-pay-ios-sdk-releases/releases/tag/v4.0.0) \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a7d9fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,10 @@ +**Olo Pay Software Development Kit License Agreement** + +Copyright © 2022 Olo Inc. All rights reserved. + +Subject to the terms and conditions of the license, you are hereby granted a non-exclusive, worldwide, royalty-free license to (a) copy and modify the software in source code or binary form for your use in connection with the software services and interfaces provided by Olo, and (b) redistribute unmodified copies of the software to third parties. The above copyright notice and this license shall be included in or with all copies or substantial portions of the software. + +Your use of this software is subject to the Olo APIs Terms of Use, available at https://www.olo.com/api-usage-terms. This license does not grant you permission to use the trade names, trademarks, service marks, or product names of Olo, except as required for reasonable and customary use in describing the origin of the software and reproducing the content of this license. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..abf732a --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# Olo Pay Flutter SDK + +## Table of Contents + +- [About Olo Pay](#about-olo-pay) +- [About the Flutter SDK](#about-the-flutter-sdk) +- [Setup](#setup) + - [Android-Specific Setup Steps](#android-specific-setup-steps) + - [iOS-Specific Setup Steps](#ios-specific-setup-steps) +- [Getting Started](#getting-started) +- [Handling Exceptions](#handling-exceptions) +- [Native View Widgets](#native-view-widgets) + - [Differences from Standard Flutter Widgets](#differences-from-standard-flutter-widgets) + - [Available Widgets](#available-widgets) + +## About Olo Pay + +[Olo Pay](https://www.olo.com/solutions/pay/) is an E-commerce payment solution designed to help restaurants grow, +protect, and support their digital ordering and delivery business. Olo Pay is specifically designed for digital +restaurant ordering to address the challenges and concerns that weʼve heard from thousands of merchants. + +## About the Flutter SDK + +The Olo Pay Flutter SDK allows partners to easily add PCI-compliant credit card input widgets and digital wallets +(Apple Pay & Google Pay) to their checkout flow and seamlessly integrate with the Olo Ordering API. + +Use of the plugin is subject to the terms of the [Olo Pay SDK License](LICENSE.md). + +This SDK documentation provides information on how to use the Flutter SDK in a Flutter app. For more information about +integrating Olo Pay into your payment solutions, including information about setup, testing, and certification, refer to our +[Olo Pay Dev Portal Documentation](https://developer.olo.com/docs/load/olopay) _(Note: requires an Olo Developer account)_. + +## Setup + +### Android-Specific Setup Steps + +#### Supported Versions + +The minimum supported version is [Android API 23](https://developer.android.com/tools/releases/platforms#6.0). The Android app's minimum API version must be set to 23 or higher. + +#### Activity Setup + +By default, when generating a new app, Flutter creates an activity (usually named `MainActivity`) that inherits from [FlutterActivity](https://api.flutter.dev/javadoc/io/flutter/app/FlutterActivity.html). + +But certain aspects of the Olo Pay SDK (listed below) require the main activity of the app to inherit from [FlutterFragmentActivity](https://api.flutter.dev/javadoc/io/flutter/app/FlutterFragmentActivity.html). + +To switch the base activity type, find the application's `MainActivity` class and change it to inherit from `FlutterFragmentActivity` +```kotlin +class MainActivity: FlutterFragmentActivity() { +} +``` +##### CardDetailsSingleLineTextView + +Attempting to use the [CardDetailsSingleLineTextView](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CardDetailsSingleLineTextField-class.html) widget when `FlutterFragmentActivity` is not used will cause a placeholder to be displayed with a message to switch to `FlutterFragmentActivity`. A message will also be logged to the debug console. + +##### Google Pay + +Attempting to [initialize Google Pay](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/OloPaySdk/initializeOloPay.html) when `FlutterFragmentActivity` is not used will result in an [invalidGooglePaySetup](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/ErrorCodes/invalidGooglePaySetup-constant.html) error code. A message will also be logged to the debug console. + +#### Theme Setup +The Android app's themes needs to use one of the `Theme.AppCompat` or `Theme.MaterialComponents` themes (directly +or indirectly) in order to use [widgets](#native-view-widgets) provided by the SDK. + +To find the name of the theme in your theme, open the Android app's `AndroidManifest.xml` file and look for +the `android:theme` attribute. This could be specified in the app's `` or `` tags (or both). + +After finding the name of the theme, locate the file where it is defined. This is typically in `res/values/styles.xml`. +Note that if your app supports multiple configurations there may be multiple definitions of the theme in different +files (such as `res/values-night/styles.xml`). + +**IMPORTANT:** _All definitions of the theme must be updated to use an approved +theme._ + +**NOTE:** If you open the project in Android Studio and use the Android Project view, all versions of the styles.xml file will be grouped under a logical +`res/values/styles` folder, making it easier to locate all versions of the `styles.xml` file. + +**Example:** +```xml + + +``` + +### iOS-Specific Setup Steps + +#### Supported Versions + +The minimum supported version is [iOS 13](https://support.apple.com/en-us/HT210393#13). The iOS app's +settings must be set to target iOS 13 or newer. + +#### CocoaPods Setup + +Add the following lines at the top of your app's Podfile: + +```ruby +source 'https://github.com/CocoaPods/Specs.git' +source 'https://github.com/ololabs/podspecs.git' +``` + +Open a terminal, navigate to your app's iOS folder (usually `/ios`), and run the following command: + +```bash +pod install +``` + +## Getting Started + +Here is a high-level overview on how to integrate the SDK into an app: + +### Payment Methods (new cards & digital wallets) + +This approach is used for cards that have not previously been saved on file with Olo. This includes new credit cards and digital wallets. With this approach both card input widgets and digital wallets return a [PaymentMethod](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/PaymentMethod-class.html) instance that is then used to submit a basket with Olo's Ordering API. Specific details can be found below. + +1. Import the SDK + ```dart + import 'package:olo_pay_sdk/olo_pay_sdk.dart'; + ``` +1. Initialize Olo Pay (see [OloPaySdk.initializeOloPay()](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/OloPaySdk/initializeOloPay.html)) +1. Create the [PaymentMethod](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/PaymentMethod-class.html) + - **Credit Card Widget** + 1. Add a credit card details [widget](#credit-card-details-widgets) to your app + 1. Use the widget's `onControllerCreated()` callback to get a controller instance + 1. Create a [PaymentMethod](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/PaymentMethod-class.html) by calling `createPaymentMethod()` on the controller + - **Digital Wallets** _(Apple Pay & Google Pay)_ + 1. Set [OloPaySdk.onDigitalWalletReady](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/OloPaySdk/onDigitalWalletReady.html) and wait for the callback to indicate digital wallets can be used + 1. Add [Apple Pay](https://developer.apple.com/design/human-interface-guidelines/apple-pay/#Button-types) and [Google Pay](https://developers.google.com/pay/api/web/guides/brand-guidelines) buttons to your app following brand guidelines + 1. Create a payment method via [OloPaySdk.createDigitalWalletPaymentMethod()](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/OloPaySdk/createDigitalWalletPaymentMethod.html) +1. Submit the order to [Olo's Ordering API](https://developer.olo.com/docs/load/olopay#section/Submitting-a-Basket-via-the-Ordering-API) using the [PaymentMethod](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/PaymentMethod-class.html) details. + +### CVV Tokens (previously saved cards) + +This approach is used for cards that have previously been saved on file with Olo, and you want to reverify the CVV of the saved card prior to submitting a basket and processing a payment. The [CvvTextField](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvTextField-class.html) widget will provide a [CvvUpdateToken](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvUpdateToken-class.html) instance that is then used to submit a basket with Olo's Ordering API. Specific details can be found below. + +1. Import the SDK + ```dart + import 'package:olo_pay_sdk/olo_pay_sdk.dart'; + ``` +1. Initialize Olo Pay (see [OloPaySdk.initializeOloPay()](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/OloPaySdk/initializeOloPay.html)) +1. Create the [CvvUpdateToken](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvUpdateToken-class.html) + 1. Add the [CvvTextField](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvTextField-class.html) widget to your app + 1. Use the widget's `onControllerCreated()` callback to get a controller instance + 1. Create a [CvvUpdateToken](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvUpdateToken-class.html) by calling `createCvvUpdateToken()` on the controller +1. Submit the order to [Olo's Ordering API](https://developer.olo.com/docs/load/olopay#section/Submitting-a-Basket-via-the-Ordering-API) using the [CvvUpdateToken](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvUpdateToken-class.html) details. + +## Handling Exceptions + +When calling functions in the SDK, there is a chance that the call will fail. When this happens the returned error object will be a [PlatformException](https://api.flutter.dev/flutter/services/PlatformException-class.html) and will contain `code` and `message` properties indicating why the method call failed. + +The `code` property will always map to a value from [ErrorCodes](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/ErrorCodes-class.html). + +Refer to the documentation for each method for information on possible error codes that will be returned if there is an error. + +### Example + +```dart +try { + const paymentMethodData = await oloPaySdk.createDigitalWalletPaymentMethod(); + //Handle payment method data +} on PlatformException catch (e) { + if (e.code == ErrorCodes.generalError) { + // Handle exception + } +} +``` + +## Native View Widgets + +Widgets in the Olo Pay SDK are used to display credit card input fields in an app, and card details are not accessible by the developer to help reduce the effort needed to maintain PCI compliance. + +### Differences from Standard Flutter Widgets + +Widgets in the Olo Pay SDK host native Android and iOS views, which behave differently than standard Flutter widgets. Details of these differences can be found below. + +#### Sizing Differences + +One of the biggest differences is that native widgets need to have a specific height defined. Internally, widgets in the Olo Pay SDK are wrapped with a [ConstrainedBox](https://api.flutter.dev/flutter/widgets/ConstrainedBox-class.html) with a default height that works in most scenarios. It is possible to pass in constraints if the default values need to be changed. + +Widgets in the Olo Pay SDK will resize their views to fit the bounds specified. Please refer to documentation for each widget for information regarding recommended heights and approaches to sizing. + +**Note:** Prior to `v1.2.0` [CardDetailsSingleLineTextField](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CardDetailsSingleLineTextField-class.html) behaved differently on Android and iOS. Refer to [CardDetailsSingleLineTextField](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CardDetailsSingleLineTextField-class.html) documentation for details. + +### Available Widgets + + +#### Credit Card Details Widgets + +- **[CardDetailsSingleLineTextField](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CardDetailsSingleLineTextField-class.html)** - This widget displays all credit card details in a single input field and is the most compressed way to display a credit card input view. + +- **CardDetailsFormTextField** - _This widget will be available in a future release of the SDK._ + +#### CVV Details Widget + +- **[CvvTextField](https://pub.dev/documentation/olo_pay_sdk/latest/olo_pay_sdk/CvvTextField-class.html)** - This widget displays a single input field that can be used to tokenize a card's CVV code for revalidation of saved cards. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..204c374 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,117 @@ +group 'com.olo.flutter.olo_pay_sdk' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.22' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.2.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.olo.flutter.olo_pay_sdk' + } + + compileSdkVersion 34 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + testOptions { + managedDevices { + localDevices { + pixel2api27 { + device = "Pixel 2" + apiLevel = 27 + systemImageSource = "aosp" + } + pixel2api30 { + device = "Pixel 2" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + pixel2api34 { + device = "Pixel 2" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + groups { + allDevices { + targetDevices.add(devices.pixel2api27) + targetDevices.add(devices.pixel2api30) + targetDevices.add(devices.pixel2api34) + } + } + } + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' + } + + defaultConfig { + minSdkVersion 23 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + // The following argument makes the Android Test Orchestrator run its + // "pm clear" command after each test invocation. This command ensures + // that the app's state is completely cleared between tests. + testInstrumentationRunnerArguments clearPackageData: 'true' + } + + dependencies { + implementation 'com.olo.olopay:olo-pay-android-sdk:3.1.1' + implementation "androidx.constraintlayout:constraintlayout:2.1.4" + implementation "androidx.core:core-ktx:1.13.1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" + implementation 'com.google.android.material:material:1.12.0' + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.mockito:mockito-core:5.10.0' + testImplementation "org.mockito.kotlin:mockito-kotlin:5.0.0" + testImplementation "junit:junit:4.13.2" + androidTestImplementation "androidx.test.ext:junit:1.2.1" + androidTestImplementation "androidx.test.espresso:espresso-core:3.6.1" + androidTestImplementation 'org.mockito:mockito-android:5.10.0' + androidTestImplementation 'androidx.test:runner:1.6.1' + androidTestUtil 'androidx.test:orchestrator:1.5.0' + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + + execution 'ANDROIDX_TEST_ORCHESTRATOR' + } + } +} \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..15de902 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..dc4fd26 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'olo_pay_sdk' diff --git a/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/MethodCallExtensionTests.kt b/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/MethodCallExtensionTests.kt new file mode 100644 index 0000000..11e3387 --- /dev/null +++ b/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/MethodCallExtensionTests.kt @@ -0,0 +1,630 @@ +package com.olo.flutter.olo_pay_sdk + +import com.olo.flutter.olo_pay_sdk.data.EmptyValueException +import com.olo.flutter.olo_pay_sdk.data.MissingKeyException +import com.olo.flutter.olo_pay_sdk.data.NullValueException +import com.olo.flutter.olo_pay_sdk.data.UnexpectedTypeException +import com.olo.flutter.olo_pay_sdk.extensions.getArgOrErrorResult +import com.olo.flutter.olo_pay_sdk.extensions.getArgOrThrow +import com.olo.flutter.olo_pay_sdk.extensions.getStringArgOrErrorResult +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.Assert +import org.junit.Test +import org.mockito.Mockito + +class MethodCallExtensionTests { + @Test + fun getArgOrThrow_keyMissing_throwsMissingKeyException() { + val call = MethodCall("someRandomMethod", mapOf("foo" to "1.2.3")) + + try { + call.getArgOrThrow("bar") + Assert.fail("Exception should be thrown") + } catch (e: MissingKeyException) { + Assert.assertEquals("Missing key 'bar'", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type MissingKeyException") + } + } + + @Test + fun getArgOrThrow_keyExists_valueIsNull_throwsNullValueException() { + val call = MethodCall("someRandomMethod", mapOf("foo" to null)) + + try { + call.getArgOrThrow("foo") + Assert.fail("Exception should be thrown") + } catch (e: NullValueException) { + Assert.assertEquals("Value for 'foo' is null", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type NullValueException") + } + } + + @Test + fun getArgOrThrow_keyExists_valueIsIncorrectType_throwsUnexpectedTypeException() { + val call = MethodCall("someRandomMethod", mapOf("foo" to 2.0)) + + try { + call.getArgOrThrow("foo") + Assert.fail("Exception should be thrown") + } catch (e: UnexpectedTypeException) { + Assert.assertEquals("Value for 'foo' is not of type String", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type UnexpectedTypeException") + } + } + + @Test + fun getArgOrThrow_keyExists_valueIsCorrectType_returnsArgValue() { + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getArgOrThrow("foo") + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Assert.assertEquals("bar", argValue) + } + + @Test + fun getArgOrThrow_withDefaultValue_keyMissing_returnsDefaultValue() { + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getArgOrThrow("bar", "default") + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Assert.assertEquals("default", argValue) + } + + @Test + fun getArgOrThrow_withDefaultValue_keyExists_valueIsNull_returnsDefaultValue() { + val call = MethodCall("someRandomMethod", mapOf("foo" to null)) + + val argValue = try { + call.getArgOrThrow("foo", "default") + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Assert.assertEquals("default", argValue) + } + + @Test + fun getArgOrThrow_withDefaultValue_keyExists_valueIsIncorrectType_throwsUnexpectedTypeException() { + val call = MethodCall("someRandomMethod", mapOf("foo" to 2.0)) + + try { + call.getArgOrThrow("foo", "default") + Assert.fail("Exception should be thrown") + } catch (e: UnexpectedTypeException) { + Assert.assertEquals("Value for 'foo' is not of type String", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type UnexpectedTypeException") + } + } + + @Test + fun getArgOrThrow_withDefaultValue_keyExists_valueIsCorrectType_returnsArgValue() { + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getArgOrThrow("foo", "default") + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Assert.assertEquals("bar", argValue) + } + + @Test + fun getArgOrErrorResult_argsMissingKey_returnsMissingParameter_throwsMissingKeyException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "1.2.3")) + + try { + call.getArgOrErrorResult( + "bar", + "Test", + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: MissingKeyException) { + Mockito.verify(mockResult).error( + "MissingParameter", + "Test: Missing parameter 'bar'", + null + ) + + Assert.assertEquals("Test: Missing parameter 'bar'", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type MissingKeyException") + } + } + + @Test + fun getArgOrErrorResult_argValueIsNull_returnsMissingParameter_throwsNullValueException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to null)) + + try { + call.getArgOrErrorResult( + "foo", + "Test", + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: NullValueException) { + Mockito.verify(mockResult).error( + "MissingParameter", + "Test: Missing parameter 'foo'", + null + ) + + Assert.assertEquals("Test: Missing parameter 'foo'", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type NullValueException") + } + } + + @Test + fun getArgOrErrorResult_argValueIncorrectType_returnsUnexpectedParameterType_throwsUnexpectedTypeException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to true)) + + try { + call.getArgOrErrorResult( + "foo", + "Test", + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: UnexpectedTypeException) { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "Test: Value for 'foo' is not of type String", + null + ) + + Assert.assertEquals("Test: Value for 'foo' is not of type String", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type UnexpectedTypeException") + } + } + + @Test + fun getArgOrErrorResult_argValueIsCorrectType_returnsArgValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getArgOrErrorResult( + "foo", + "Test", + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("bar", argValue) + } + + @Test + fun getArgOrErrorResult_withDefaultValue_argsMissingKey_returnsDefaultValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "1.2.3")) + + try { + val argValue = call.getArgOrErrorResult( + "bar", + "default", + "Test", + mockResult + ) + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("default", argValue) + } catch(e: Exception) { + Assert.fail("Exception should not be thrown") + } + } + + @Test + fun getArgOrErrorResult_withDefaultValue_argsValueIsNull_returnsDefaultValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to null)) + + try { + val argValue = call.getArgOrErrorResult( + "foo", + "default", + "Test", + mockResult + ) + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("default", argValue) + } catch(e: Exception) { + Assert.fail("Exception should not be thrown") + } + } + + @Test + fun getArgOrErrorResult_withDefaultValue_argValueIncorrectType_returnsUnexpectedParameterType_throwsUnexpectedTypeError() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to true)) + + try { + call.getArgOrErrorResult( + "foo", + "default", + "Test", + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: UnexpectedTypeException) { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "Test: Value for 'foo' is not of type String", + null + ) + + Assert.assertEquals("Test: Value for 'foo' is not of type String", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type UnexpectedTypeException") + } + } + + @Test + fun getArgOrErrorResult_withDefaultValue_argValueIsCorrectType_returnsArgValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getArgOrErrorResult( + "foo", + "default", + "Test", + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("bar", argValue) + } + + @Test + fun getStringArgOrErrorResult_argsMissingKey_returnsMissingParameter_throwsMissingKeyException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "1.2.3")) + + try { + call.getStringArgOrErrorResult( + "bar", + "Test", + true, + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: MissingKeyException) { + Mockito.verify(mockResult).error( + "MissingParameter", + "Test: Missing parameter 'bar'", + null + ) + + Assert.assertEquals("Test: Missing parameter 'bar'", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type MissingKeyException") + } + } + + @Test + fun getStringArgOrErrorResult_argValueIsNull_returnsMissingParameter_throwsNullValueException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to null)) + + try { + call.getStringArgOrErrorResult( + "foo", + "Test", + true, + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: NullValueException) { + Mockito.verify(mockResult).error( + "MissingParameter", + "Test: Missing parameter 'foo'", + null + ) + + Assert.assertEquals("Test: Missing parameter 'foo'", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type NullValueException") + } + } + + @Test + fun getStringArgOrErrorResult_argValueNotString_returnsUnexpectedParameterType_throwsUnexpectedTypeException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to true)) + + try { + call.getStringArgOrErrorResult( + "foo", + "Test", + true, + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: UnexpectedTypeException) { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "Test: Value for 'foo' is not of type String", + null + ) + + Assert.assertEquals("Test: Value for 'foo' is not of type String", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type UnexpectedTypeException") + } + } + + @Test + fun getStringArgOrErrorResult_emptyValueNotAccepted_argValueIsEmpty_returnsInvalidParameter_throwsEmptyValueException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "")) + + try { + call.getStringArgOrErrorResult( + "foo", + "Test", + false, + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: EmptyValueException) { + Mockito.verify(mockResult).error( + "InvalidParameter", + "Test: Value for 'foo' cannot be empty", + null + ) + + Assert.assertEquals("Test: Value for 'foo' cannot be empty", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type EmptyValueException") + } + } + + @Test + fun getStringArgOrErrorResult_emptyValueNotAccepted_argValueNotEmpty_returnsArgValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "Test", + false, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("bar", argValue) + } + + @Test + fun getStringArgOrErrorResult_emptyValueAccepted_argValueIsEmpty_returnsEmptyString() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "Test", + true, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("", argValue) + } + + @Test + fun getStringArgOrErrorResult_emptyValueAccepted_argValueNotEmpty_returnsArg() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "Test", + true, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("bar", argValue) + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_argsMissingKey_returnsDefaultValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "1.2.3")) + + val argValue = try { + call.getStringArgOrErrorResult( + "bar", + "default", + "Test", + true, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("default", argValue) + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_argValueIsNull_returnsDefaultValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to null)) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "default", + "Test", + true, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("default", argValue) + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_argValueNotString_returnsUnexpectedParameterType_throwsUnexpectedTypeException() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to true)) + + try { + call.getStringArgOrErrorResult( + "foo", + "default", + "Test", + true, + mockResult + ) + + Assert.fail("Exception should be thrown") + } catch (e: UnexpectedTypeException) { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "Test: Value for 'foo' is not of type String", + null + ) + + Assert.assertEquals("Test: Value for 'foo' is not of type String", e.message) + } catch (e: Exception) { + Assert.fail("Exception should be of type UnexpectedTypeException") + } + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_emptyValueNotAccepted_argValueIsEmpty_returnsDefaultValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "default", + "Test", + false, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("default", argValue) + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_emptyValueNotAccepted_argValueNotEmpty_returnsArgValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "default", + "Test", + false, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("bar", argValue) + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_emptyValueAccepted_argValueIsEmpty_returnsEmptyString() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "default", + "Test", + true, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("", argValue) + } + + @Test + fun getStringArgOrErrorResult_withDefaultValue_emptyValueAccepted_argValueNotEmpty_returnsArgValue() { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("someRandomMethod", mapOf("foo" to "bar")) + + val argValue = try { + call.getStringArgOrErrorResult( + "foo", + "default", + "Test", + true, + mockResult + ) + } catch (e: Exception) { + Assert.fail("Exception should not be thrown") + } + + Mockito.verifyNoInteractions(mockResult) + Assert.assertEquals("bar", argValue) + } +} \ No newline at end of file diff --git a/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/OloPaySdkPluginTest.kt b/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/OloPaySdkPluginTest.kt new file mode 100644 index 0000000..75e2a53 --- /dev/null +++ b/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/OloPaySdkPluginTest.kt @@ -0,0 +1,1372 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.olo.flutter.olo_pay_sdk.utils.MethodFinishedCallback +import com.olo.olopay.api.IOloPayApiInitializer +import com.olo.olopay.data.OloPayEnvironment +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito + +class OloPaySdkPluginTest { + private val testApplication: Application + get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application + + private val initializeGooglePayBaseError = "Unable to initialize Google Pay" + private val initializeMetaDataError = "Unable to initialize metadata" + private val changeGooglePayVendorBaseError = "Unable to change Google Pay Country" + private val getDigitalWalletPaymentMethodBaseError = "Unable to create payment method" + private var _plugin: OloPaySdkPlugin? = null + + private val plugin: OloPaySdkPlugin + get() = _plugin!! + + @Before + fun setup() { + _plugin = OloPaySdkPlugin() + plugin.appContext = testApplication + } + + @After + fun teardown() { + _plugin = null + IOloPayApiInitializer.sdkWrapperInfo = null + } + + @Test + fun initializeOloPay_withoutEnvironmentArg_sdkInitialized_environmentDefaultsToProduction() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertTrue(plugin._sdkInitialized) + assertEquals(OloPayEnvironment.Production, plugin.environment) + expectation.fulfill() + } + + val call = MethodCall("initialize", null) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeOloPay_withFalseEnvironmentArg_sdkInitialized_environmentSetToTest() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertTrue(plugin._sdkInitialized) + assertEquals(OloPayEnvironment.Test, plugin.environment) + expectation.fulfill() + } + + val call = MethodCall("initialize", mapOf("productionEnvironment" to false)) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeOloPay_withTrueEnvironmentArg_sdkInitialized_environmentSetToProduction() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertTrue(plugin._sdkInitialized) + assertEquals(OloPayEnvironment.Production, plugin.environment) + expectation.fulfill() + } + + val call = MethodCall("initialize", mapOf("productionEnvironment" to true)) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeOloPay_environmentArgNotBoolean_returnsUnexpectedParameterTypeError() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "Unable to initialize OloPaySdk: Value for 'productionEnvironment' is not of type Boolean", + null + ) + assertFalse(plugin._sdkInitialized) + expectation.fulfill() + } + + val call = MethodCall("initialize", mapOf("productionEnvironment" to "true")) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeOloPay_environmentArgNull_sdkInitialized_environmentSetToProduction() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertTrue(plugin._sdkInitialized) + assertEquals(OloPayEnvironment.Production, plugin.environment) + expectation.fulfill() + } + + val call = MethodCall("initialize", mapOf("productionEnvironment" to null)) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_metadataContainsCorrectValues_wrapperInfoSetCorrectly_returnsSuccess() = runBlocking { + val map = mapOf( + "version" to "3.2.1", // non default value + "buildType" to "public" // non default value + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("3.2.1", IOloPayApiInitializer.sdkWrapperInfo?.version) + assertEquals("public", IOloPayApiInitializer.sdkWrapperInfo?.buildType) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_emptyHybridVersion_setsDefaultHybridVersion_returnsSuccess() = runBlocking { + val map = mapOf( + "version" to "", + "buildType" to "internal" + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("0.0.0", IOloPayApiInitializer.sdkWrapperInfo?.version) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_nullHybridVersion_setsDefaultHybridVersion_returnsSuccess() = runBlocking { + val map = mapOf( + "version" to null, + "buildType" to "internal" + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("0.0.0", IOloPayApiInitializer.sdkWrapperInfo?.version) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_missingHybridVersion_setsDefaultHybridVersion_returnsSuccess() = runBlocking { + val map = mapOf( + // "version" to "" intentionally removed from args map + "buildType" to "internal" + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("0.0.0", IOloPayApiInitializer.sdkWrapperInfo?.version) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_hybridVersionNotString_returnsUnexpectedParameterTypeError() = runBlocking { + val map = mapOf( + "version" to 1.23, + "buildType" to "internal" + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeMetaDataError}: Value for 'version' is not of type String", + null + ) + assertNull(IOloPayApiInitializer.sdkWrapperInfo) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_emptyHybridBuildType_setsDefaultHybridBuildType_returnsSuccess() = runBlocking { + val map = mapOf( + "version" to "1.2.3", + "buildType" to "" + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("internal", IOloPayApiInitializer.sdkWrapperInfo?.buildType) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_nullHybridBuildType_setsDefaultHybridBuildType_returnsSuccess() = runBlocking { + val map = mapOf( + "version" to "1.2.3", + "buildType" to null + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("internal", IOloPayApiInitializer.sdkWrapperInfo?.buildType) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_missingHybridBuildType_setsDefaultHybridBuildType_returnsSuccess() = runBlocking { + val map = mapOf( + "version" to "1.2.3", + // "buildType" to "" intentionally removed from args map + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertEquals("internal", IOloPayApiInitializer.sdkWrapperInfo?.buildType) + assertEquals("flutter", IOloPayApiInitializer.sdkWrapperInfo?.platform) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeMetadata_hybridBuildTypeNotString_returnsUnexpectedParameterTypeError() = runBlocking { + val map = mapOf( + "version" to "1.2.3", + "buildType" to true + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeMetaDataError}: Value for 'buildType' is not of type String", + null + ) + assertNull(IOloPayApiInitializer.sdkWrapperInfo) + expectation.fulfill() + } + + val call = MethodCall("initializeMetadata", map) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun isSdkInitialized_sdkInitialized_returnsTrue() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + waitForInitialization() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(true) + assertTrue(plugin._sdkInitialized) + expectation.fulfill() + } + + val call = MethodCall("isInitialized", null) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun isSdkInitialized_sdkNotInitialized_returnsFalse() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(false) + assertFalse(plugin._sdkInitialized) + expectation.fulfill() + } + + val call = MethodCall("isInitialized", null) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkNotInitialized_returnsSdkUninitializedError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "SdkUninitialized", + "${initializeGooglePayBaseError}: Olo Pay SDK has not been initialized", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_emptyCountryCode_returnsInvalidParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "InvalidParameter", + "${initializeGooglePayBaseError}: Value for 'countryCode' cannot be empty", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_nullCountryCode_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "countryCode" to null, + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${initializeGooglePayBaseError}: Missing parameter 'countryCode'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_missingCountryCode_returnsMissingParameterError() = runBlocking { + val args = mapOf( + // "countryCode" to "" intentionally removed from args map + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${initializeGooglePayBaseError}: Missing parameter 'countryCode'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_countryCodeNotString_returnsUnexpectedParameterTypeError() = runBlocking { + val args = mapOf( + "countryCode" to true, + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'countryCode' is not of type String", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_emptyMerchantName_returnsInvalidParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to "", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "InvalidParameter", + "${initializeGooglePayBaseError}: Value for 'merchantName' cannot be empty", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_nullMerchantName_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to null, + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${initializeGooglePayBaseError}: Missing parameter 'merchantName'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_missingMerchantName_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + // "merchantName" to "" intentionally removed from args map + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${initializeGooglePayBaseError}: Missing parameter 'merchantName'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_merchantNameNotString_returnsUnexpectedParameterTypeError() = runBlocking{ + val args = mapOf( + "countryCode" to "US", + "merchantName" to 123, + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'merchantName' is not of type String", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_googlePayProductionEnvironmentNotBoolean_returnsUnexpectedParameterTypeError() = runBlocking{ + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to "true", + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'googlePayProductionEnvironment' is not of type Boolean", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_fullAddressFormatNotBoolean_returnsUnexpectedParameterTypeError() = runBlocking{ + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to "false", + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'fullAddressFormat' is not of type Boolean", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_existingPaymentMethodRequiredNotBoolean_returnsUnexpectedParameterTypeError() = runBlocking{ + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to "false", + "emailRequired" to false, + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'existingPaymentMethodRequired' is not of type Boolean", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_emailRequiredNotBoolean_returnsUnexpectedParameterTypeError() = runBlocking{ + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to "false", + "phoneNumberRequired" to false, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'emailRequired' is not of type Boolean", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_phoneNumberRequiredNotBoolean_returnsUnexpectedParameterTypeError() = runBlocking{ + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "googlePayProductionEnvironment" to false, + "fullAddressFormat" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to "false", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${initializeGooglePayBaseError}: Value for 'phoneNumberRequired' is not of type Boolean", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun initializeGooglePay_sdkInitialized_withValidGooglePayParams_returnsInvalidGooglePaySetupError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + "productionEnvironment" to false, + "existingPaymentMethodRequired" to false, + "emailRequired" to false, + "phoneNumberRequired" to false, + "addressFormat" to false + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "InvalidGooglePaySetup", + "${initializeGooglePayBaseError}: AndroidManifest missing com.google.android.gms.wallet.api.enabled entry", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("initializeGooglePay", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun isGooglePayReady_googlePayNotInitialized_returnsFalse() = runBlocking { + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(false) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("isDigitalWalletReady", null) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkNotInitialized_returnsSdkUninitializedError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "SdkUninitialized", + "${changeGooglePayVendorBaseError}: Olo Pay SDK has not been initialized", + null) + assertFalse(plugin._sdkInitialized) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_emptyCountryCode_returnsInvalidParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "", + "merchantName" to "Test Merchant", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "InvalidParameter", + "${changeGooglePayVendorBaseError}: Value for 'countryCode' cannot be empty", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_nullCountryCode_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "countryCode" to null, + "merchantName" to "Test Merchant", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${changeGooglePayVendorBaseError}: Missing parameter 'countryCode'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_missingCountryCode_returnsMissingParameterError() = runBlocking { + val args = mapOf( + // "countryCode" to "" intentionally removed from args map + "merchantName" to "Test Merchant", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${changeGooglePayVendorBaseError}: Missing parameter 'countryCode'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_countryCodeNotString_returnsUnexpectedParameterTypeError() = runBlocking { + val args = mapOf( + "countryCode" to true, + "merchantName" to "Test Merchant", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${changeGooglePayVendorBaseError}: Value for 'countryCode' is not of type String", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_emptyMerchantName_returnsInvalidParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to "", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "InvalidParameter", + "${changeGooglePayVendorBaseError}: Value for 'merchantName' cannot be empty", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_nullMerchantName_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to null, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${changeGooglePayVendorBaseError}: Missing parameter 'merchantName'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_missingMerchantName_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + // "merchantName" to "" intentionally removed from args map + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${changeGooglePayVendorBaseError}: Missing parameter 'merchantName'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_merchantNameNotString_returnsUnexpectedParameterTypeError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to true, + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${changeGooglePayVendorBaseError}: Value for 'merchantName' is not of type String", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun changeGooglePayVendor_sdkInitialized_googlePayNotInitialized_returnGooglePayUninitializedError() = runBlocking { + val args = mapOf( + "countryCode" to "US", + "merchantName" to "Test Merchant", + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "GooglePayUninitialized", + "${changeGooglePayVendorBaseError}: Google Pay not initialized", + null + ) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("changeGooglePayVendor", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkNotInitialized_googlePayNotInitialized_returnsSdkUninitializedError() = runBlocking { + val args = mapOf( + "amount" to 1.23, + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "SdkUninitialized", + "${getDigitalWalletPaymentMethodBaseError}: Olo Pay SDK has not been initialized", + null) + assertFalse(plugin._sdkInitialized) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_amountNull_returnsMissingParameterError() = runBlocking { + val args = mapOf( + "amount" to null, + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${getDigitalWalletPaymentMethodBaseError}: Missing parameter 'amount'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_amountMissing_returnsMissingParameterError() = runBlocking { + val args = mapOf( + // "amount" to 1.23 intentionally removed from args map + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "MissingParameter", + "${getDigitalWalletPaymentMethodBaseError}: Missing parameter 'amount'", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_amountNotDouble_returnsUnexpectedParameterTypeError() = runBlocking { + val args = mapOf( + "amount" to "1.23", + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${getDigitalWalletPaymentMethodBaseError}: Value for 'amount' is not of type Double", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_amountNegative_returnsInvalidParameterError() = runBlocking { + val args = mapOf( + "amount" to -1.23, + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "InvalidParameter", + "${getDigitalWalletPaymentMethodBaseError}: Value for 'amount' cannot be negative", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_amountPositive_returnsGooglePayNotReadyError() = runBlocking { + val args = mapOf( + "amount" to 1.23, + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "GooglePayNotReady", + "${getDigitalWalletPaymentMethodBaseError}: Google Pay isn't ready yet", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_amountZero_returnsGooglePayNotReadyError() = runBlocking { + val args = mapOf( + "amount" to 0.0, + "currencyCode" to "USD", + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "GooglePayNotReady", + "${getDigitalWalletPaymentMethodBaseError}: Google Pay isn't ready yet", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_currencyCodeNotString_returnsUnexpectedParameterTypeError() = runBlocking { + val args = mapOf( + "amount" to 1.23, + "currencyCode" to 123, + "currencyMultiplier" to 100 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${getDigitalWalletPaymentMethodBaseError}: Value for 'currencyCode' is not of type String", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + @Test + fun getDigitalWalletPaymentMethod_sdkInitialized_googlePayNotInitialized_currencyMultiplierNotInt_returnsUnexpectedParameterTypeError() = runBlocking { + val args = mapOf( + "amount" to 1.23, + "currencyCode" to "US", + "currencyMultiplier" to 1.0 + ) + + waitForInitialization() + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation() + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).error( + "UnexpectedParameterType", + "${getDigitalWalletPaymentMethodBaseError}: Value for 'currencyMultiplier' is not of type Integer", + null) + assertFalse(plugin._googlePayReady) + expectation.fulfill() + } + + val call = MethodCall("createDigitalWalletPaymentMethod", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } + + private fun waitForInitialization(isProductionEnvironment: Boolean = false) = runBlocking { + val args = mapOf( + "productionEnvironment" to isProductionEnvironment, + ) + + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val expectation = TestExpectation(failMessage = "Initialize expectation not fulfilled") + + plugin.onMethodCallFinished = MethodFinishedCallback { + Mockito.verify(mockResult).success(null) + assertTrue(plugin._sdkInitialized) + expectation.fulfill() + } + + val call = MethodCall("initialize", args) + plugin.onMethodCall(call, mockResult) + expectation.wait() + } +} + + diff --git a/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/TestExpectation.kt b/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/TestExpectation.kt new file mode 100644 index 0000000..315e7bb --- /dev/null +++ b/android/src/androidTest/java/com/olo/flutter/olo_pay_sdk/TestExpectation.kt @@ -0,0 +1,37 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import kotlinx.coroutines.delay +import org.junit.Assert + +class TestExpectation( + private val intervalMs: Long = 100, + private val timeoutMs: Long = 5000, //This extra-long timeout seems to be needed for tests in Github Actions to succeed + private val failMessage: String = "Expectation not fulfilled" +) { + private var finished = false + private var elapsedTimeMs: Long = 0 + + suspend fun wait() { + while (!finished && elapsedTimeMs <= timeoutMs) { + delay(intervalMs) + elapsedTimeMs += intervalMs + } + + if (!finished) { + Assert.fail(failMessage) + } + + return + } + + fun fulfill() { + finished = true + } + + fun reset() { + elapsedTimeMs = 0 + finished = false + } +} \ No newline at end of file diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d40304e --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/OloPaySdkPlugin.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/OloPaySdkPlugin.kt new file mode 100644 index 0000000..4ee0a9c --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/OloPaySdkPlugin.kt @@ -0,0 +1,688 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import PaymentCardCvvViewFactory +import android.content.Context +import android.content.pm.PackageManager +import androidx.annotation.VisibleForTesting +import androidx.fragment.app.FragmentManager +import com.olo.flutter.olo_pay_sdk.controls.singleline.PaymentCardDetailsSingleLineViewFactory +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.flutter.olo_pay_sdk.data.ErrorCodes +import com.olo.flutter.olo_pay_sdk.extensions.getArgOrErrorResult +import com.olo.flutter.olo_pay_sdk.extensions.getStringArgOrErrorResult +import com.olo.flutter.olo_pay_sdk.extensions.oloError +import com.olo.flutter.olo_pay_sdk.extensions.safeRelease +import com.olo.flutter.olo_pay_sdk.extensions.toMap +import com.olo.flutter.olo_pay_sdk.googlepay.FlutterGooglePayResultCallback +import com.olo.flutter.olo_pay_sdk.googlepay.GooglePayFragment +import com.olo.flutter.olo_pay_sdk.utils.MethodFinishedCallback +import com.olo.flutter.olo_pay_sdk.utils.OloPayLog +import com.olo.flutter.olo_pay_sdk.utils.backgroundOperation +import com.olo.flutter.olo_pay_sdk.utils.uiOperation +import com.olo.olopay.api.IOloPayApiInitializer +import com.olo.olopay.api.OloPayApiInitializer +import com.olo.olopay.data.OloPayEnvironment +import com.olo.olopay.data.SdkWrapperInfo +import com.olo.olopay.data.SdkWrapperPlatform +import com.olo.olopay.data.SdkBuildType +import com.olo.olopay.data.SetupParameters +import com.olo.olopay.googlepay.Config +import com.olo.olopay.googlepay.Environment +import com.olo.olopay.googlepay.ReadyCallback +import com.olo.olopay.googlepay.Result +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.CoroutineScope +import io.flutter.plugin.common.MethodChannel.Result as FlutterResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import java.lang.Exception + +/** OloPaySdkPlugin */ +class OloPaySdkPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { + private lateinit var channel : MethodChannel + private var activityBinding: ActivityPluginBinding? = null + + @VisibleForTesting + internal lateinit var appContext: Context + @VisibleForTesting + internal var onMethodCallFinished: MethodFinishedCallback? = null + @VisibleForTesting + internal val environment + get() = IOloPayApiInitializer.environment + + private val fragmentManager: FragmentManager? + get() { + (activityBinding?.activity as? FlutterFragmentActivity)?.let { + return it.supportFragmentManager + } + + return null + } + + private var _sdkInitializingSemaphore = Semaphore(1) + private var _sdkInitializedSemaphore = Semaphore(1) + private var _googlePaySemaphore = Semaphore(1) + private var _googlePayReadySemaphore = Semaphore(1) + private var _googlePayInitializedSemaphore = Semaphore(1) + private var _metadataInitialized = false + + //WARNING: DO NOT ACCESS OR MODIFY THESE DIRECTLY... USE THREAD-SAFE GETTERS/SETTERS + @VisibleForTesting + internal var _sdkInitialized = false + @VisibleForTesting + internal var _googlePayReady = false + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, DataKeys.OloPaySdkMethodChannelKey) + channel.setMethodCallHandler(this) + appContext = flutterPluginBinding.applicationContext + + flutterPluginBinding + .platformViewRegistry + .registerViewFactory( + DataKeys.PaymentCardDetailsSingleLineViewKey, + PaymentCardDetailsSingleLineViewFactory(flutterPluginBinding.binaryMessenger) + ) + + flutterPluginBinding + .platformViewRegistry + .registerViewFactory( + DataKeys.PaymentCardCvvViewKey, + PaymentCardCvvViewFactory(flutterPluginBinding.binaryMessenger) + ) + } + + override fun onMethodCall(call: MethodCall, result: FlutterResult) { + when (call.method) { + DataKeys.InitializeOloPayMethodKey -> initializeOloPay(call, result) + DataKeys.InitializeMetadataMethodKey -> initializeMetadata(call, result) + DataKeys.IsInitializedMethodKey -> isSdkInitialized(result) + DataKeys.IsDigitalWalletReadyMethodKey -> isGooglePayReady(result) + DataKeys.InitializeGooglePayMethodKey -> initializeGooglePay(call, result) + DataKeys.ChangeGooglePayVendorMethodKey -> changeGooglePayVendor(call, result) + DataKeys.CreateDigitalWalletPaymentMethod -> getDigitalWalletPaymentMethod(call, result) + else -> result.notImplemented() + } + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activityBinding = binding + verifyActivity() + } + + override fun onDetachedFromActivityForConfigChanges() { + activityBinding = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activityBinding = binding + verifyActivity() + } + + override fun onDetachedFromActivity() { + activityBinding = null + } + + private fun verifyActivity(): Boolean { + if (activityBinding?.activity as? FlutterFragmentActivity == null) { + OloPayLog.w("Not using FlutterFragmentActivity. Some aspects of the Olo Pay SDK will not be available.") + return false; + } + + return true; + } + + private fun initializeOloPay(call: MethodCall, result: FlutterResult) = backgroundOperation { + _sdkInitializingSemaphore.withPermit { + setSdkInitialized(false) + + val productionEnv = try { + call.getArgOrErrorResult( + DataKeys.ProductionEnvironmentKey, + DefaultProductionEnvironment, + "Unable to initialize OloPaySdk", + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@backgroundOperation + } + + val params = SetupParameters( + if (productionEnv) OloPayEnvironment.Production else OloPayEnvironment.Test + ) + + val initializer = OloPayApiInitializer() + initializer.setup(appContext, params) + setSdkInitialized(true) + + withContext(Dispatchers.Main) { + result.success(null) + onMethodCallFinished?.invoke() + } + } + } + + private fun initializeMetadata(call: MethodCall, result: FlutterResult) { + // First call happens while the plugin is getting initialized... Subsequent calls should be ignored + if (_metadataInitialized) { + result.success(null) + onMethodCallFinished?.invoke() + return + } + + val baseError = "Unable to initialize metadata" + + val hybridVersion = try { + call.getStringArgOrErrorResult( + DataKeys.HybridSdkVersionKey, + DefaultVersionString, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return + } + + val hybridBuildType = try { + call.getStringArgOrErrorResult( + DataKeys.HybridBuildTypeKey, + DataKeys.HybridBuildTypeInternalValue, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return + } + + setSdkWrapperInfo(hybridVersion, hybridBuildType) + _metadataInitialized = true + result.success(null) + onMethodCallFinished?.invoke() + } + + private fun initializeGooglePay(call: MethodCall, result: MethodChannel.Result) = googlePayLockingOperation { + val baseError = "Unable to initialize Google Pay" + + if (!isSdkInitialized()) { + result.oloError( + ErrorCodes.UninitializedSdk, + "${baseError}: Olo Pay SDK has not been initialized" + ) + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val countryCode = try { + call.getStringArgOrErrorResult( + DataKeys.DigitalWalletCountryCodeParameterKey, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val merchantName = try { + call.getStringArgOrErrorResult( + DataKeys.GPayMerchantNameKey, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val productionEnv = try { + call.getArgOrErrorResult( + DataKeys.GPayProductionEnvironmentKey, + DefaultGooglePayProductionEnvironment, + baseError, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val fullAddressFormat = try { + call.getArgOrErrorResult( + DataKeys.GPayFullAddressFormatKey, + DefaultFullAddressFormat, + baseError, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val existingPaymentMethodRequired = try { + call.getArgOrErrorResult( + DataKeys.GPayExistingPaymentMethodRequiredKey, + DefaultExistingPaymentMethodRequired, + baseError, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val emailRequired = try { + call.getArgOrErrorResult( + DataKeys.GPayEmailRequiredKey, + DefaultEmailRequired, + baseError, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val phoneNumberRequired = try { + call.getArgOrErrorResult( + DataKeys.GPayPhoneNumberRequiredKey, + DefaultPhoneNumberRequired, + baseError, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val appMetadata = appContext.packageManager.getApplicationInfo( + appContext.packageName, + PackageManager.GET_META_DATA + ).metaData + + if (!appMetadata.containsKey("com.google.android.gms.wallet.api.enabled")) { + result.oloError( + ErrorCodes.InvalidGooglePaySetup, + "${baseError}: AndroidManifest missing com.google.android.gms.wallet.api.enabled entry" + ) + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + // Moved here for testing purposes - We cannot test anything beyond this point due to testing limitations + if (!verifyActivity()) { + val message = "${baseError}: App must use FlutterFragmentActivity - https://tinyurl.com/yfwr5raa" + result.oloError(ErrorCodes.InvalidGooglePaySetup, message) + + return@googlePayLockingOperation + } + + // Clean up in case digital wallets have been initialized multiple times + initializeGooglePay(null) + removeGooglePayFragment() + + val googlePayConfig = Config( + if (productionEnv) Environment.Production else Environment.Test, + merchantName, + countryCode, + existingPaymentMethodRequired, + emailRequired, + phoneNumberRequired, + if (fullAddressFormat) Config.AddressFormat.Full else Config.AddressFormat.Min + ) + + initializeGooglePay(googlePayConfig) + changeDigitalWalletCountry(countryCode, merchantName) + result.success(null) + } + + private fun changeGooglePayVendor(call: MethodCall, result: MethodChannel.Result) = googlePayLockingOperation { + val baseError = "Unable to change Google Pay Country" + + if (!isSdkInitialized()) { + result.oloError( + ErrorCodes.UninitializedSdk, + "${baseError}: Olo Pay SDK has not been initialized" + ) + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val countryCode = try { + call.getStringArgOrErrorResult( + DataKeys.DigitalWalletCountryCodeParameterKey, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + val merchantName = try { + call.getStringArgOrErrorResult( + DataKeys.GPayMerchantNameKey, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + // We cannot test anything beyond this point due to testing limitations + if (getGooglePayFragment() == null) { + result.oloError( + ErrorCodes.GooglePayUninitialized, + "${baseError}: Google Pay not initialized" + ) + onMethodCallFinished?.invoke() + return@googlePayLockingOperation + } + + changeDigitalWalletCountry(countryCode, merchantName) + result.success(null) + } + + private fun getDigitalWalletPaymentMethod(call: MethodCall, result: MethodChannel.Result) = uiOperation { + // Unable to use googlePayLockingOperation because this call waits for a callback method... we need to unlock + // the semaphore manually + val baseError = "Unable to create payment method" + + if (!isSdkInitialized()) { + result.oloError( + ErrorCodes.UninitializedSdk, + "${baseError}: Olo Pay SDK has not been initialized" + ) + onMethodCallFinished?.invoke() + return@uiOperation + } + + val amount = try { + call.getArgOrErrorResult( + DataKeys.DigitalWalletAmountParameterKey, + baseError, + result + ) + } catch(_: Exception) { + onMethodCallFinished?.invoke() + return@uiOperation + } + + if (amount < 0){ + result.oloError( + ErrorCodes.InvalidParameter, + "${baseError}: Value for '${DataKeys.DigitalWalletAmountParameterKey}' cannot be negative" + ) + onMethodCallFinished?.invoke() + return@uiOperation + } + + val currencyCode = try { + call.getStringArgOrErrorResult( + DataKeys.GPayCurrencyCodeKey, + DefaultCurrencyCode, + baseError, + false, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@uiOperation + } + + val currencyMultiplier = try { + call.getArgOrErrorResult( + DataKeys.GPayCurrencyMultiplierKey, + DefaultCurrencyMultiplier, + baseError, + result + ) + } catch (_: Exception) { + onMethodCallFinished?.invoke() + return@uiOperation + } + + val amountInSmallestCurrencyUnit = (amount * currencyMultiplier).toInt() + + // Moved here for testing purposes - We cannot test anything beyond this point due to testing limitations + if (!isGooglePayReady()) { + result.oloError( + ErrorCodes.GooglePayNotReady, + "${baseError}: Google Pay isn't ready yet" + ) + onMethodCallFinished?.invoke() + return@uiOperation + } + + _googlePaySemaphore.acquire() + + val fragment = getGooglePayFragment() + if (fragment == null) { + result.oloError( + ErrorCodes.GooglePayUninitialized, + "${baseError}: Google Pay not initialized" + ) + _googlePaySemaphore.safeRelease() + return@uiOperation + } + + if (!fragment.isReady) { + val reason = if (fragment.countryCode.isNullOrBlank()) { + "Google Pay not initialized - country code not set" + } else if (fragment.merchantName.isNullOrBlank()) { + "Google Pay not initialized - merchant name not set" + } else { + "Google Pay isn't ready yet" + } + + result.oloError( + ErrorCodes.GooglePayNotReady, + "${baseError}: $reason" + ) + _googlePaySemaphore.safeRelease() + return@uiOperation + } + + fragment.resultCallback = + FlutterGooglePayResultCallback{ result, promise -> onGooglePayResult(result, promise) } + + fragment.present(currencyCode, amountInSmallestCurrencyUnit, result) + } + + private fun isSdkInitialized(result: FlutterResult) = uiOperation { + result.success(isSdkInitialized()) + onMethodCallFinished?.invoke() + } + + private fun isGooglePayReady(result: FlutterResult) = uiOperation { + result.success(isGooglePayReady()) + onMethodCallFinished?.invoke() + } + + private suspend fun isSdkInitialized(): Boolean { + _sdkInitializedSemaphore.withPermit { + return _sdkInitialized + } + } + + private suspend fun setSdkInitialized(initialized: Boolean) { + _sdkInitializedSemaphore.withPermit { + _sdkInitialized = initialized + } + } + + private suspend fun isGooglePayInitialized(): Boolean { + _googlePayInitializedSemaphore.withPermit { + return IOloPayApiInitializer.googlePayConfig != null + } + } + + private suspend fun initializeGooglePay(config: Config?) { + _googlePayInitializedSemaphore.withPermit { + IOloPayApiInitializer.googlePayConfig = config + } + } + + private suspend fun isGooglePayReady() : Boolean { + _googlePayReadySemaphore.withPermit { + return _googlePayReady + } + } + + private suspend fun setGooglePayReady(ready: Boolean) { + _googlePayReadySemaphore.withPermit { + _googlePayReady = ready + } + } + + private suspend fun changeDigitalWalletCountry(countryCode: String, merchantName: String) { + getGooglePayFragment(countryCode, merchantName, true) + } + + private suspend fun getGooglePayFragment(countryCode: String? = null, merchantName: String? = null, createIfNeeded: Boolean = false): GooglePayFragment? { + if (!isGooglePayInitialized()) { + return null + } + + var fragment = fragmentManager?.findFragmentByTag(GooglePayFragment.Tag) as GooglePayFragment? + + //If the fragment isn't null, determine if we need a new instance + var forceCreation = false + if (fragment != null) { + val invalidCountryCode = !countryCode.isNullOrEmpty() && fragment.countryCode != countryCode + val invalidMerchantName = !merchantName.isNullOrEmpty() && fragment.merchantName != merchantName + + if (invalidCountryCode || invalidMerchantName) { + removeGooglePayFragment(fragment) + fragment = null + forceCreation = true + } + } + + if (fragment == null && (createIfNeeded || forceCreation)) { + fragment = GooglePayFragment() + fragment.countryCode = countryCode + fragment.merchantName = merchantName + fragmentManager?.beginTransaction()?.add(fragment, GooglePayFragment.Tag)?.commit() + } + + fragment?.also { + it.readyCallback = ReadyCallback { isReady -> onGooglePayReady(isReady) } + } + + return fragment + } + + private suspend fun removeGooglePayFragment() { + getGooglePayFragment(createIfNeeded = false)?.let { it -> + removeGooglePayFragment(it) + } + } + + private fun removeGooglePayFragment(fragment: GooglePayFragment) { + fragmentManager?.beginTransaction()?.remove(fragment)?.commitAllowingStateLoss() + emitDigitalWalletReadyEvent(false) + } + + private fun onGooglePayReady(isReady: Boolean) { + emitDigitalWalletReadyEvent(isReady) + } + + private fun onGooglePayResult(googlePayResult: Result, promise: MethodChannel.Result) { + _googlePaySemaphore.safeRelease() + + when (googlePayResult) { + is Result.Completed -> { + promise.success(googlePayResult.paymentMethod.toMap()) + } + is Result.Canceled -> { + promise.success(null) + } + is Result.Failed -> { + promise.success(googlePayResult.error.toMap()) + } + } + } + + private fun emitDigitalWalletReadyEvent(isReady: Boolean) = backgroundOperation { + if (isGooglePayReady() == isReady) { + return@backgroundOperation + } + + setGooglePayReady(isReady) + withContext(Dispatchers.Main) { + val args = mapOf(DataKeys.DigitalWalletReadyParameterKey to isReady) + channel.invokeMethod(DataKeys.DigitalWalletReadyEventHandlerKey, args) + } + } + + private fun googlePayLockingOperation(operation: suspend() -> Unit) { + CoroutineScope(Dispatchers.Main).launch { + _googlePaySemaphore.withPermit { + operation() + } + } + } + + private fun setSdkWrapperInfo(version: String, buildType: String) { + val versionStrings = version.split(".") + + val wrapperInfo = SdkWrapperInfo( + (versionStrings.getOrElse(MajorVersionIndex) { "0" }).toIntOrNull() ?: 0, + (versionStrings.getOrElse(MinorVersionIndex) { "0" }).toIntOrNull() ?: 0, + (versionStrings.getOrElse(BuildVersionIndex) { "0" }).toIntOrNull() ?: 0, + when (buildType) { + DataKeys.HybridBuildTypePublicValue -> SdkBuildType.Public + else -> SdkBuildType.Internal + }, + SdkWrapperPlatform.Flutter + ) + + IOloPayApiInitializer.sdkWrapperInfo = wrapperInfo + } + + companion object { + // Default Initialization Options + const val DefaultProductionEnvironment = true + + // Default Google Pay Initialization Options + const val DefaultGooglePayProductionEnvironment = true + const val DefaultExistingPaymentMethodRequired = true + const val DefaultEmailRequired = false + const val DefaultPhoneNumberRequired = false + const val DefaultFullAddressFormat = false + + // Default Digital Wallet Payment Method Request Options + const val DefaultCurrencyMultiplier = 100 + const val DefaultCurrencyCode = "USD" + + const val MajorVersionIndex = 0 + const val MinorVersionIndex = 1 + const val BuildVersionIndex = 2 + const val DefaultVersionString = "0.0.0" + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/PlaceholderView.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/PlaceholderView.kt new file mode 100644 index 0000000..0baa3c0 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/PlaceholderView.kt @@ -0,0 +1,42 @@ +package com.olo.flutter.olo_pay_sdk.controls + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.view.View +import android.widget.TextView +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import io.flutter.plugin.platform.PlatformView + +internal class PlaceholderView( + context: Context +) : PlatformView { + val textView: TextView + + init { + textView = TextView(context) + textView.background = MaterialShapeDrawable( + ShapeAppearanceModel() + .toBuilder() + .setAllCorners(CornerFamily.ROUNDED, 10.0f) + .build() + ).also {shape -> + shape.strokeWidth = 2.0f + shape.strokeColor = ColorStateList.valueOf(Color.GRAY) + shape.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) + shape.setPadding(10, 5, 5, 5) + } + } + + override fun getView(): View { + return textView + } + + override fun dispose() {} + + fun setText(text: String) { + textView.text = text + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/cvv/PaymentCardCvvView.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/cvv/PaymentCardCvvView.kt new file mode 100644 index 0000000..7075ccb --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/cvv/PaymentCardCvvView.kt @@ -0,0 +1,359 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.controls.cvv + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Typeface +import android.os.Build +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import com.olo.flutter.olo_pay_sdk.R +import com.olo.flutter.olo_pay_sdk.data.CustomErrorMessages +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.flutter.olo_pay_sdk.data.ErrorCodes +import com.olo.flutter.olo_pay_sdk.extensions.oloError +import com.olo.flutter.olo_pay_sdk.extensions.toMap +import com.olo.olopay.api.OloPayAPI +import com.olo.flutter.olo_pay_sdk.data.GlobalConstants +import com.olo.flutter.olo_pay_sdk.extensions.getArgOrErrorResult +import com.olo.flutter.olo_pay_sdk.utils.OloPayLog +import com.olo.olopay.controls.PaymentCardCvvView as OloPayCvvView +import com.olo.olopay.controls.callbacks.CvvInputListener +import com.olo.olopay.data.CardField +import com.olo.olopay.data.ICardFieldState +import com.olo.olopay.exceptions.OloPayException +import io.flutter.FlutterInjector +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.platform.PlatformView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("InflateParams") +internal class PaymentCardCvvView( + context: Context, + messenger: BinaryMessenger, + id: Int, + args: Any? +) : PlatformView, MethodChannel.MethodCallHandler, CvvInputListener { + private var cvvInputView: OloPayCvvView + private val methodChannel: MethodChannel + private val defaultFont: Typeface + private var _customErrorMessages: CustomErrorMessages? = null + + init { + cvvInputView = LayoutInflater.from(context).inflate( + R.layout.flutter_olopay_cvv_view, + null + ) as OloPayCvvView + + cvvInputView.displayErrors = false + cvvInputView.cvvInputListener = this + + defaultFont = cvvInputView.getFont() + loadCustomArgs(args) + + methodChannel = MethodChannel(messenger, DataKeys.CvvBaseMethodChannelKey + id) + methodChannel.setMethodCallHandler(this) + } + + override fun getView(): View { + return cvvInputView + } + + override fun dispose() {} + + override fun onMethodCall(methodCall: MethodCall, result: MethodChannel.Result) { + when(methodCall.method) { + DataKeys.CreateCvvUpdateTokenKey -> createCvvUpdateToken(result) + DataKeys.GetStateMethodKey -> getState(result) + DataKeys.IsValidMethodKey -> isValid(result) + DataKeys.SetEnabledMethodKey -> setEnabled(methodCall, result) + DataKeys.IsEnabledMethodKey -> isEnabled(result) + DataKeys.HasErrorMessageMethodKey -> hasErrorMessage(methodCall, result) + DataKeys.GetErrorMessageMethodKey -> getErrorMessage(methodCall, result) + DataKeys.ClearFieldsMethodKey -> clear(result) + DataKeys.RequestFocusMethodKey -> requestFocus(result) + DataKeys.ClearFocusMethodKey -> clearFocus(result) + DataKeys.RefreshUiMethod -> refreshUI(methodCall, result) + else -> result.notImplemented() + } + } + + override fun onInputChanged(state: ICardFieldState) { + val args = mapOf( + DataKeys.FieldStatesParameterKey to state.toMap() + ) + + methodChannel.invokeMethod(DataKeys.OnInputChangedEventHandlerKey, args) + emitErrorMessage() + } + + override fun onValidStateChanged(state: ICardFieldState) { + val args = mapOf( + DataKeys.FieldStatesParameterKey to state.toMap() + ) + + methodChannel.invokeMethod(DataKeys.OnValidStateChangedEventHandlerKey, args) + emitErrorMessage() + } + + override fun onFocusChange(state: ICardFieldState) { + val args = mapOf( + DataKeys.FieldStatesParameterKey to state.toMap() + ) + + methodChannel.invokeMethod(DataKeys.OnFocusChangedEventHandlerKey, args) + emitErrorMessage() + } + + private fun emitErrorMessage() { + methodChannel.invokeMethod(DataKeys.OnErrorMessageChangedEventHandlerKey, getErrorMessage(true)) + } + + private fun createCvvUpdateToken(result: MethodChannel.Result) { + if (!cvvInputView.isValid) { + val errorCode = ErrorCodes.InvalidCvv + val errorMessage = getErrorMessage(false) + result.oloError(errorCode, errorMessage) + return + } + + CoroutineScope(Dispatchers.IO).launch { + val params = cvvInputView.cvvTokenParams!! + + try { + val cvvUpdateToken = OloPayAPI().createCvvUpdateToken(cvvInputView.context, params) + result.success(cvvUpdateToken.toMap()) + } catch(e: OloPayException) { + result.oloError(e) + } + } + } + + private fun getState(result: MethodChannel.Result) { + result.success(cvvInputView.fieldState.toMap()) + } + + private fun isValid(result: MethodChannel.Result) { + result.success(cvvInputView.isValid) + } + + private fun setEnabled(call: MethodCall, result: MethodChannel.Result) { + cvvInputView.isEnabled = try { + call.getArgOrErrorResult( + DataKeys.EnabledParameterKey, + "Unable to set enabled state", + result + ) + } catch (_: Exception) { + return + } + + result.success(null) + } + + private fun isEnabled(result: MethodChannel.Result) { + result.success(cvvInputView.isEnabled) + } + + private fun hasErrorMessage(call: MethodCall, result: MethodChannel.Result) { + val ignoreUneditedFields = try { + call.getArgOrErrorResult( + DataKeys.IgnoreUneditedFieldsParameterKey, + "Unable to check for error message", + result + ) + } catch (_: Exception) { + return + } + + result.success(cvvInputView.hasErrorMessage(ignoreUneditedFields)) + } + + private fun getErrorMessage(call: MethodCall, result: MethodChannel.Result) { + val ignoreUneditedFields = try { + call.getArgOrErrorResult( + DataKeys.IgnoreUneditedFieldsParameterKey, + "Unable to get error message", + result + ) + } catch (_: Exception) { + return + } + + result.success(getErrorMessage(ignoreUneditedFields)) + } + + private fun getErrorMessage(ignoreUneditedFields: Boolean): String { + if(cvvInputView.isValid || !cvvInputView.hasErrorMessage(ignoreUneditedFields)) { + return "" + } + + val defaultErrorMessage = cvvInputView.getErrorMessage(ignoreUneditedFields) + return _customErrorMessages?.getCustomErrorMessage( + ignoreUneditedFields, + mapOf(CardField.Cvv to cvvInputView.fieldState), + null + ) ?: defaultErrorMessage + } + + private fun clear(result: MethodChannel.Result) { + cvvInputView.clear() + result.success(null) + } + + private fun requestFocus(result: MethodChannel.Result) { + cvvInputView.requestFocus(true) + result.success(null) + } + + private fun clearFocus(result: MethodChannel.Result) { + cvvInputView.clearFocus() + result.success(null) + } + + private fun refreshUI(call: MethodCall, result: MethodChannel.Result) { + call.argument(DataKeys.CreationParameters)?.let { params -> + loadCustomArgs(params) + } + result.success(null) + } + + private fun loadCustomArgs(args: Any?) { + val widgetArgs = args as? Map<*, *> ?: return + + val hints = widgetArgs[DataKeys.HintsArgumentKey] as? Map<*, *> + if (hints != null && hints.isNotEmpty()) { + (hints[CardField.Cvv.toString()] as? String)?.also { cvvHint -> + cvvInputView.setHintText(cvvHint) + } + } + + val textStyles = widgetArgs[DataKeys.TextStylesArgumentsKey] as? Map<*, *> + if (textStyles != null && textStyles.isNotEmpty()) { + loadTextStyles(textStyles) + } + + val backgroundStyles = widgetArgs[DataKeys.BackgroundStylesArgumentsKey] as? Map<*, *> + if (backgroundStyles != null && backgroundStyles.isNotEmpty()) { + loadBackgroundStyles(backgroundStyles) + } + + val paddingStyles = widgetArgs[DataKeys.PaddingStylesArgumentsKey] as? Map<*, *> + if (paddingStyles != null && paddingStyles.isNotEmpty()) { + loadPaddingStyles(paddingStyles) + } + + val customErrorMessages = widgetArgs[DataKeys.CustomErrorMessagesArgumentsKey] as? Map<*, *> + _customErrorMessages = if (!customErrorMessages.isNullOrEmpty()) { + CustomErrorMessages(customErrorMessages) + } else { + null + } + + (widgetArgs[DataKeys.TextAlignmentKey] as? String?).let { + val position = when(it) { + DataKeys.GravityCenterKey -> Gravity.CENTER + DataKeys.GravityRightKey -> Gravity.RIGHT or Gravity.CENTER_VERTICAL + else -> Gravity.LEFT or Gravity.CENTER_VERTICAL + } + + cvvInputView.setGravity(position) + } + } + + private fun loadTextStyles(textStyles: Map<*, *>) { + if(Build.VERSION.SDK_INT >= GlobalConstants.ApiOreo) { + val textColor = textStyles[DataKeys.TextColorKey] as? String + if (!textColor.isNullOrBlank()) { + cvvInputView.setTextColor(textColor) + } + + val errorTextColor = textStyles[DataKeys.ErrorTextColorKey] as? String + if (!errorTextColor.isNullOrBlank()) { + cvvInputView.setErrorTextColor(errorTextColor) + } + + val hintTextColor = textStyles[DataKeys.HintTextColorKey] as? String + if (!hintTextColor.isNullOrBlank()) { + cvvInputView.setHintTextColor(hintTextColor) + } + } + + val cursorColor = textStyles[DataKeys.CursorColorKey] as? String + if (!cursorColor.isNullOrBlank() && Build.VERSION.SDK_INT >= GlobalConstants.ApiQuinceTart) { + cvvInputView.setCursorColor(cursorColor) + } + + (textStyles[DataKeys.TextSizeKey] as? Double)?.let { + cvvInputView.setTextSize(it.toFloat()) + } + + val fontAsset = textStyles[DataKeys.FontAssetKey] as? String + if(!fontAsset.isNullOrBlank()) { + try{ + val assetKey = FlutterInjector.instance().flutterLoader().getLookupKeyForAsset(fontAsset) + val font = Typeface.createFromAsset(cvvInputView.context.assets, assetKey) + cvvInputView.setFont(font) + } catch (error: Exception) { + OloPayLog.e(error.toString()) + cvvInputView.setFont(defaultFont) + } + } else if(cvvInputView.getFont() != defaultFont){ + cvvInputView.setFont(defaultFont) + } + } + + private fun loadBackgroundStyles(backgroundStyles: Map<*, *>) { + val displayMetrics = cvvInputView.context.resources.displayMetrics + + //Need to convert from dp values to px values + val borderWidthPx = (backgroundStyles[DataKeys.BorderWidthKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics) + } + + val borderRadiusPx = (backgroundStyles[DataKeys.BorderRadiusKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics) + } + + if (Build.VERSION.SDK_INT >= GlobalConstants.ApiOreo) { + cvvInputView.setCvvBackgroundStyle( + backgroundColorHex = backgroundStyles[DataKeys.BackgroundColorKey] as? String, + borderColorHex = backgroundStyles[DataKeys.BorderColorKey] as? String, + borderWidthPx = borderWidthPx, + borderRadiusPx = borderRadiusPx) + } + } + + private fun loadPaddingStyles(paddingStyles: Map<*, *>) { + val displayMetrics = cvvInputView.context.resources.displayMetrics + + val startPadding = (paddingStyles[DataKeys.StartPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + + val endPadding = (paddingStyles[DataKeys.EndPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + val topPadding = (paddingStyles[DataKeys.TopPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + + val bottomPadding = (paddingStyles[DataKeys.BottomPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + + cvvInputView.setCvvPadding( + startPx = startPadding, + endPx = endPadding, + topPx = topPadding, + bottomPx = bottomPadding + ) + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/cvv/PaymentCardCvvViewFactory.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/cvv/PaymentCardCvvViewFactory.kt new file mode 100644 index 0000000..546da50 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/cvv/PaymentCardCvvViewFactory.kt @@ -0,0 +1,17 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import android.content.Context +import com.olo.flutter.olo_pay_sdk.controls.cvv.PaymentCardCvvView +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +class PaymentCardCvvViewFactory(private val messenger: BinaryMessenger) : PlatformViewFactory( + StandardMessageCodec.INSTANCE +) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + val creationParams = args as Map<*, *>? + return PaymentCardCvvView(context, messenger, viewId, creationParams) + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/singleline/PaymentCardDetailsSingleLineView.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/singleline/PaymentCardDetailsSingleLineView.kt new file mode 100644 index 0000000..ce7ad25 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/singleline/PaymentCardDetailsSingleLineView.kt @@ -0,0 +1,474 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.controls.singleline + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.graphics.Typeface +import android.os.Build +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import com.olo.flutter.olo_pay_sdk.R +import com.olo.flutter.olo_pay_sdk.data.CustomErrorMessages +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.flutter.olo_pay_sdk.data.ErrorCodes +import com.olo.flutter.olo_pay_sdk.data.GlobalConstants +import com.olo.flutter.olo_pay_sdk.extensions.getArgOrErrorResult +import com.olo.flutter.olo_pay_sdk.extensions.toMap +import com.olo.flutter.olo_pay_sdk.utils.OloPayLog +import com.olo.olopay.api.OloPayAPI +import com.olo.olopay.controls.callbacks.CardInputListener +import com.olo.olopay.controls.callbacks.ConfigurationChangeListener +import com.olo.olopay.data.CardErrorType +import com.olo.olopay.data.CardField +import com.olo.olopay.data.ICardFieldState +import com.olo.olopay.exceptions.ApiConnectionException +import com.olo.olopay.exceptions.ApiException +import com.olo.olopay.exceptions.CardException +import com.olo.olopay.exceptions.InvalidRequestException +import com.olo.olopay.exceptions.OloPayException +import com.olo.olopay.exceptions.RateLimitException +import com.stripe.android.core.exception.AuthenticationException +import io.flutter.FlutterInjector +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.platform.PlatformView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.olo.olopay.controls.PaymentCardDetailsSingleLineView as OloPaySingleLineView + + +@SuppressLint("InflateParams") +internal class PaymentCardDetailsSingleLineView( + context: Context, + messenger: BinaryMessenger, + id: Int, + args: Any? +) : PlatformView, MethodChannel.MethodCallHandler, CardInputListener, ConfigurationChangeListener { + private var cardInputView: OloPaySingleLineView + private val methodChannel: MethodChannel + private val defaultFont: Typeface + private var _customErrorMessages: CustomErrorMessages? = null + + init { + cardInputView = LayoutInflater.from(context).inflate( + R.layout.flutter_olopay_single_line_view, + null + ) as OloPaySingleLineView + + cardInputView.configurationChangeListener = this + cardInputView.displayErrors = false + cardInputView.cardInputListener = this + + defaultFont = cardInputView.getFont() + + loadCustomArgs(args) + + methodChannel = MethodChannel(messenger, DataKeys.SingleLineBaseMethodChannelKey + id) + methodChannel.setMethodCallHandler(this) + } + + override fun getView(): View { + return cardInputView + } + + override fun dispose(){} + + // Device orientation change apparently does not follow the same flow as in native Android, which + // results in the layout not getting updated properly for new widths. The size gets updated, but + // each individual field size is not, resulting in overlapping fields or inaccessible fields that + // are outside the bounds of the view. This little hack fixes that. By forcing focus on specific + // fields, it triggers a recalculation of Stripe's individual fields, and fixes this issue. + // As of right now, this appears to be the only way to fix this issue, as traditional methods + // involving invalidate(), requestLayout(), and forceLayout() do not work. + override fun onConfigurationChanged(newConfig: Configuration?) { + val initialFocused: CardField? = getFocusedField(cardInputView.fieldStates) + + // Switching focus to these fields guarantees a recalculation of field size on Stripe's + // control will occur. + val newFocusedField = when (initialFocused) { + CardField.CardNumber -> CardField.Expiration + CardField.Expiration -> CardField.CardNumber + CardField.Cvv -> CardField.CardNumber + CardField.PostalCode -> CardField.CardNumber + null -> CardField.Expiration + } + + // If there wasn't a focused field, setting the focus on the CardNumber field makes the most + // sense since it's the first field users interact with, and this second focus is the key + // to getting the control to display properly in the new configuration. + // + // Attempts to clear focus after this, in the case that a field didn't have focus to begin + // with, appear to cause too much work on the UI thread, and focus doesn't consistently get + // cleared. Rather than have inconsistent results, we just don't attempt to clear the focus. + val restoredFocusField = initialFocused ?: CardField.CardNumber + + // Important: This should not be in a post call + cardInputView.requestFocus(newFocusedField, false) + + // This delay is important, as it allows the view time to finish the previous focus call + // before attempting this one. The native Android SDK has logic where, in certain scenarios, + // It can take up to 300ms to request focus, so this delay should never be less than that. + cardInputView.postDelayed({ + cardInputView.requestFocus(restoredFocusField, false) + }, FocusDelayMs) + } + + override fun onMethodCall(methodCall: MethodCall, result: MethodChannel.Result) { + when(methodCall.method) { + DataKeys.CreatePaymentMethodKey -> createPaymentMethod(result) + DataKeys.GetStateMethodKey -> getState(result) + DataKeys.IsValidMethodKey -> isValid(result) + DataKeys.GetCardTypeMethodKey -> getCardType(result) + DataKeys.SetEnabledMethodKey -> setEnabled(methodCall, result) + DataKeys.IsEnabledMethodKey -> isEnabled(result) + DataKeys.HasErrorMessageMethodKey -> hasErrorMessage(methodCall, result) + DataKeys.GetErrorMessageMethodKey -> getErrorMessage(methodCall, result) + DataKeys.ClearFieldsMethodKey -> clearFields(result) + DataKeys.RequestFocusMethodKey -> requestFocus(result) + DataKeys.ClearFocusMethodKey -> clearFocus(result) + DataKeys.RefreshUiMethod -> refreshUI(methodCall, result) + else -> result.notImplemented() + } + } + + private fun createPaymentMethod(result: MethodChannel.Result) { + if (!cardInputView.isValid) { + val errorCode = getErrorCode() + val errorMessage = getErrorMessage(false) + result.error(errorCode, errorMessage, null) + return + } + + CoroutineScope(Dispatchers.IO).launch { + val params = cardInputView.paymentMethodParams!! + + try { + val paymentMethod = OloPayAPI().createPaymentMethod(cardInputView.context, params) + result.success(paymentMethod.toMap()) + } catch (e: ApiException) { + result.error(ErrorCodes.ApiError, e.message, null) + } catch(e: InvalidRequestException) { + result.error(ErrorCodes.InvalidRequest, e.message, null) + } catch(e: ApiConnectionException) { + result.error(ErrorCodes.Connection, e.message, null) + } catch(e: RateLimitException) { + result.error(ErrorCodes.RateLimit, e.message, null) + } catch(e: AuthenticationException) { + result.error(ErrorCodes.Authentication, e.message, null) + } catch(e: CardException) { + when(e.type) { + CardErrorType.InvalidNumber -> result.error(ErrorCodes.InvalidNumber, e.message, null) + CardErrorType.InvalidExpMonth -> result.error(ErrorCodes.InvalidExpiration, e.message, null) + CardErrorType.InvalidExpYear -> result.error(ErrorCodes.InvalidExpiration, e.message, null) + CardErrorType.InvalidCVV -> result.error(ErrorCodes.InvalidCvv, e.message, null) + CardErrorType.InvalidZip -> result.error(ErrorCodes.InvalidPostalCode, e.message, null) + CardErrorType.ExpiredCard -> result.error(ErrorCodes.ExpiredCard, e.message, null) + CardErrorType.CardDeclined -> result.error(ErrorCodes.CardDeclined, e.message, null) + CardErrorType.ProcessingError -> result.error(ErrorCodes.ProcessingError, e.message, null) + CardErrorType.UnknownCardError -> result.error(ErrorCodes.UnknownCardError, e.message, null) + } + } catch(e: OloPayException) { + result.error(ErrorCodes.GeneralError, e.message, null) + } + } + } + + private fun getState(result: MethodChannel.Result) { + result.success(cardInputView.fieldStates.toMap()) + } + + private fun isValid(result: MethodChannel.Result) { + result.success(cardInputView.isValid) + } + + private fun getCardType(result: MethodChannel.Result) { + result.success(cardInputView.cardBrand.description) + } + + private fun setEnabled(call: MethodCall, result: MethodChannel.Result) { + cardInputView.isEnabled = try { + call.getArgOrErrorResult( + DataKeys.EnabledParameterKey, + "Unable to set enabled state", + result + ) + } catch (_: Exception) { + return + } + + result.success(null) + } + + private fun isEnabled(result: MethodChannel.Result) { + result.success(cardInputView.isEnabled) + } + + private fun hasErrorMessage(call: MethodCall, result: MethodChannel.Result) { + val ignoreUneditedFields = try { + call.getArgOrErrorResult( + DataKeys.IgnoreUneditedFieldsParameterKey, + "Unable to check for error message", + result + ) + } catch (_: Exception) { + return + } + + result.success(cardInputView.hasErrorMessage(ignoreUneditedFields)) + } + + private fun getErrorMessage(call: MethodCall, result: MethodChannel.Result) { + val ignoreUneditedFields = try { + call.getArgOrErrorResult( + DataKeys.IgnoreUneditedFieldsParameterKey, + "Unable to get error message", + result + ) + } catch (_: Exception) { + return + } + + result.success(getErrorMessage(ignoreUneditedFields)) + } + + private fun getErrorMessage(ignoreUneditedFields: Boolean): String { + if(cardInputView.isValid || !cardInputView.hasErrorMessage(ignoreUneditedFields)) { + return "" + } + + val defaultErrorMessage = cardInputView.getErrorMessage(ignoreUneditedFields) + return (_customErrorMessages?.getCustomErrorMessage(ignoreUneditedFields, cardInputView.fieldStates, cardInputView.cardBrand) ?: defaultErrorMessage) + } + + private fun clearFields(result: MethodChannel.Result) { + cardInputView.clearFields() + result.success(null) + } + + private fun requestFocus(result: MethodChannel.Result) { + cardInputView.requestFocus(CardField.CardNumber, true) + result.success(null) + } + + private fun clearFocus(result: MethodChannel.Result) { + cardInputView.clearFocus() + result.success(null) + } + + private fun refreshUI(call: MethodCall, result: MethodChannel.Result) { + call.argument(DataKeys.CreationParameters)?.let { params -> + loadCustomArgs(params) + } + result.success(null) + } + + private fun getErrorCode(): String { + if(!cardInputView.fieldStates[CardField.CardNumber]!!.isValid) { + return ErrorCodes.InvalidNumber + } else if (!cardInputView.fieldStates[CardField.Expiration]!!.isValid) { + return ErrorCodes.InvalidExpiration + } else if(!cardInputView.fieldStates[CardField.Cvv]!!.isValid) { + return ErrorCodes.InvalidCvv + } else if(!cardInputView.fieldStates[CardField.PostalCode]!!.isValid) { + return ErrorCodes.InvalidPostalCode + } + + return ErrorCodes.InvalidCardDetails + } + + override fun onFocusChange(field: CardField?, fieldStates: Map) { + val args = mapOf( + DataKeys.IsValidKey to cardInputView.isValid, + DataKeys.FocusedFieldParameterKey to (field?.toString() ?: ""), + DataKeys.FieldStatesParameterKey to fieldStates.toMap() + ) + + methodChannel.invokeMethod(DataKeys.OnFocusChangedEventHandlerKey, args) + emitErrorMessage() + } + + override fun onValidStateChanged(isValid: Boolean, fieldStates: Map) { + val args = mapOf( + DataKeys.IsValidKey to isValid, + DataKeys.FieldStatesParameterKey to fieldStates.toMap() + ) + + methodChannel.invokeMethod(DataKeys.OnValidStateChangedEventHandlerKey, args) + emitErrorMessage() + } + + override fun onInputChanged(isValid: Boolean, fieldStates: Map) { + val args = mapOf( + DataKeys.IsValidKey to isValid, + DataKeys.FieldStatesParameterKey to fieldStates.toMap() + ) + + methodChannel.invokeMethod(DataKeys.OnInputChangedEventHandlerKey, args) + emitErrorMessage() + } + + private fun emitErrorMessage() { + methodChannel.invokeMethod(DataKeys.OnErrorMessageChangedEventHandlerKey, getErrorMessage(true)) + } + + private fun loadCustomArgs(args: Any?) { + val widgetArgs = args as? Map<*, *> ?: return + + val hints = widgetArgs[DataKeys.HintsArgumentKey] as? Map<*, *> + if (hints != null && hints.isNotEmpty()) { + loadHints(hints) + } + + val textStyles = widgetArgs[DataKeys.TextStylesArgumentsKey] as? Map<*, *> + if (textStyles != null && textStyles.isNotEmpty()) { + loadTextStyles(textStyles) + } + + val backgroundStyles = widgetArgs[DataKeys.BackgroundStylesArgumentsKey] as? Map<*, *> + if (backgroundStyles != null && backgroundStyles.isNotEmpty()) { + loadBackgroundStyles(backgroundStyles) + } + + val paddingStyles = widgetArgs[DataKeys.PaddingStylesArgumentsKey] as? Map<*, *> + if (paddingStyles != null && paddingStyles.isNotEmpty()) { + loadPaddingStyles(paddingStyles) + } + + val customErrorMessages = widgetArgs[DataKeys.CustomErrorMessagesArgumentsKey] as? Map<*, *> + _customErrorMessages = if (!customErrorMessages.isNullOrEmpty()) { + CustomErrorMessages(customErrorMessages) + } else { + null + } + } + + private fun loadHints(hints: Map<*, *>) { + val numberHint = hints[CardField.CardNumber.toString()] as? String + if (numberHint != null) { + cardInputView.setHintText(CardField.CardNumber, numberHint) + } + + val expirationHint = hints[CardField.Expiration.toString()] as? String + if (expirationHint != null) { + cardInputView.setHintText(CardField.Expiration, expirationHint) + } + + val cvvHint = hints[CardField.Cvv.toString()] as? String + if (cvvHint != null) { + cardInputView.setHintText(CardField.Cvv, cvvHint) + } + + val postalCodeHint = hints[CardField.PostalCode.toString()] as? String + if (postalCodeHint != null) { + cardInputView.setHintText(CardField.PostalCode, postalCodeHint) + } + } + + private fun loadTextStyles(textStyles: Map<*, *>) { + if(Build.VERSION.SDK_INT >= GlobalConstants.ApiOreo) { + val textColor = textStyles[DataKeys.TextColorKey] as? String + if (!textColor.isNullOrBlank()) { + cardInputView.setTextColor(textColor) + } + + val errorTextColor = textStyles[DataKeys.ErrorTextColorKey] as? String + if (!errorTextColor.isNullOrBlank()) { + cardInputView.setErrorTextColor(errorTextColor) + } + + val hintTextColor = textStyles[DataKeys.HintTextColorKey] as? String + if (!hintTextColor.isNullOrBlank()) { + cardInputView.setHintTextColor(hintTextColor) + } + } + + val cursorColor = textStyles[DataKeys.CursorColorKey] as? String + if (!cursorColor.isNullOrBlank() && Build.VERSION.SDK_INT >= GlobalConstants.ApiQuinceTart) { + cardInputView.setCursorColor(cursorColor) + } + + (textStyles[DataKeys.TextSizeKey] as? Double)?.let { + cardInputView.setTextSize(it.toFloat()) + } + + val fontAsset = textStyles[DataKeys.FontAssetKey] as? String + if(!fontAsset.isNullOrBlank()) { + try{ + val assetKey = FlutterInjector.instance().flutterLoader().getLookupKeyForAsset(fontAsset) + val font = Typeface.createFromAsset(cardInputView.context.assets, assetKey) + cardInputView.setFont(font) + } catch (error: Exception) { + OloPayLog.e(OloPayLog.getStackTrace(error)) + cardInputView.setFont(defaultFont) + } + } else if(cardInputView.getFont() != defaultFont){ + cardInputView.setFont(defaultFont) + } + } + + private fun loadBackgroundStyles(backgroundStyles: Map<*, *>) { + val displayMetrics = cardInputView.context.resources.displayMetrics + + //Need to convert from dp values to px values + val borderWidthPx = (backgroundStyles[DataKeys.BorderWidthKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics) + } + + val borderRadiusPx = (backgroundStyles[DataKeys.BorderRadiusKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics) + } + + if (Build.VERSION.SDK_INT >= GlobalConstants.ApiOreo) { + cardInputView.setCardBackgroundStyle( + backgroundColorHex = backgroundStyles[DataKeys.BackgroundColorKey] as? String, + borderColorHex = backgroundStyles[DataKeys.BorderColorKey] as? String, + borderWidthPx = borderWidthPx, + borderRadiusPx = borderRadiusPx) + } + } + + private fun loadPaddingStyles(paddingStyles: Map<*, *>) { + val displayMetrics = cardInputView.context.resources.displayMetrics + + val startPadding = (paddingStyles[DataKeys.StartPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + + val endPadding = (paddingStyles[DataKeys.EndPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + val topPadding = (paddingStyles[DataKeys.TopPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + + val bottomPadding = (paddingStyles[DataKeys.BottomPaddingKey] as? Double)?.let { + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, it.toFloat(), displayMetrics).toInt() + } + + cardInputView.setCardPadding( + startPx = startPadding, + endPx = endPadding, + topPx = topPadding, + bottomPx = bottomPadding + ) + } + + private fun getFocusedField(state: Map) : CardField? { + state.forEach { + if (it.value.isFocused) { + return it.key + } + } + + return null + } + + companion object { + const val FocusDelayMs = 350L + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/singleline/PaymentCardDetailsSingleLineViewFactory.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/singleline/PaymentCardDetailsSingleLineViewFactory.kt new file mode 100644 index 0000000..988d5b9 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/controls/singleline/PaymentCardDetailsSingleLineViewFactory.kt @@ -0,0 +1,33 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.controls.singleline + +import android.content.Context +import com.olo.flutter.olo_pay_sdk.controls.PlaceholderView +import com.olo.flutter.olo_pay_sdk.extensions.getActivity +import com.olo.flutter.olo_pay_sdk.utils.OloPayLog +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +class PaymentCardDetailsSingleLineViewFactory(private val messenger: BinaryMessenger) : PlatformViewFactory( + StandardMessageCodec.INSTANCE +) { + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + if (context.getActivity() as? FlutterFragmentActivity == null) { + val message = "CardDetailsSingleLineTextField must be used within a FlutterFragmentActivity: https://tinyurl.com/yfwr5raa" + OloPayLog.e(message) + + val placeholder = PlaceholderView(context).also { + it.setText(message) + } + + return placeholder + } + + val creationParams = args as Map<*, *>? + return PaymentCardDetailsSingleLineView(context, messenger, viewId, creationParams) + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/CustomErrorMessages.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/CustomErrorMessages.kt new file mode 100644 index 0000000..0499240 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/CustomErrorMessages.kt @@ -0,0 +1,84 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.data + +import com.olo.olopay.data.CardBrand +import com.olo.olopay.data.CardField +import com.olo.olopay.data.ICardFieldState + +data class CustomErrorMessages constructor( + val invalidCardNumber: String?, + val emptyCardNumber: String?, + val unsupportedCardNumber: String?, + val invalidExpiration: String?, + val emptyExpiration: String?, + val invalidCvv: String?, + val emptyCvv: String?, + val invalidPostalCode: String?, + val emptyPostalCode: String? +){ + constructor(customErrorMessagesMap: Map<*, *>): this( + (customErrorMessagesMap[CardField.CardNumber.toString()] as? Map<*, *>)?.get(DataKeys.InvalidErrorKey) as? String, + (customErrorMessagesMap[CardField.CardNumber.toString()] as? Map<*, *>)?.get(DataKeys.EmptyErrorKey) as? String, + customErrorMessagesMap[DataKeys.UnsupportedCardErrorKey] as? String, + (customErrorMessagesMap[CardField.Expiration.toString()] as? Map<*, *>)?.get(DataKeys.InvalidErrorKey) as? String, + (customErrorMessagesMap[CardField.Expiration.toString()] as? Map<*, *>)?.get(DataKeys.EmptyErrorKey) as? String, + (customErrorMessagesMap[CardField.Cvv.toString()] as? Map<*, *>)?.get(DataKeys.InvalidErrorKey) as? String, + (customErrorMessagesMap[CardField.Cvv.toString()] as? Map<*, *>)?.get(DataKeys.EmptyErrorKey) as? String, + (customErrorMessagesMap[CardField.PostalCode.toString()] as? Map<*, *>)?.get(DataKeys.InvalidErrorKey) as? String, + (customErrorMessagesMap[CardField.PostalCode.toString()] as? Map<*, *>)?.get(DataKeys.EmptyErrorKey) as? String + ) + + constructor(invalidCvvError: String?, emptyCvvError: String?): this( + null, + null, + null, + null, + null, + invalidCvvError, + emptyCvvError, + null, + null + ) + + fun getCustomErrorMessage(ignoreUneditedFieldErrors: Boolean, cardFields: Map, cardBrand: CardBrand? = null): String? { + val errorFields = getErrorFields(ignoreUneditedFieldErrors, cardFields) + return when { + errorFields.containsKey(CardField.CardNumber) -> { + val state = errorFields[CardField.CardNumber]!! + if(state.isEmpty) { + emptyCardNumber + } else if (cardBrand == CardBrand.Unsupported) { + unsupportedCardNumber + } else { + invalidCardNumber + } + } + errorFields.containsKey(CardField.Expiration) -> { + if(errorFields[CardField.Expiration]!!.isEmpty) emptyExpiration else invalidExpiration + } + errorFields.containsKey(CardField.Cvv) -> { + if (errorFields[CardField.Cvv]!!.isEmpty) emptyCvv else invalidCvv + } + errorFields.containsKey(CardField.PostalCode) -> { + if (errorFields[CardField.PostalCode]!!.isEmpty) emptyPostalCode else invalidPostalCode + } + else -> { + null + } + } + } + + fun getErrorFields(ignoreUneditedFieldErrors: Boolean, cardFields: Map): Map { + // Return all error fields regardless of state + if (!ignoreUneditedFieldErrors) + return cardFields.filterKeys { + !cardFields[it]!!.isValid + } + + return cardFields.filterKeys { + val state = cardFields[it]!! + !state.isValid && state.wasEdited && state.wasFocused + } + } +} diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/DataKeys.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/DataKeys.kt new file mode 100644 index 0000000..bf1edb6 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/DataKeys.kt @@ -0,0 +1,142 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.data + +class DataKeys { + companion object { + // Prefix Keys + private const val BridgePrefix = "com.olo.flutter.olo_pay_sdk" + private const val SingleLineViewType = "PaymentCardDetailsSingleLineView" + private const val CvvViewType = "PaymentCardCvvView" + + // Method Channel Keys + const val OloPaySdkMethodChannelKey = "$BridgePrefix/sdk" + const val SingleLineBaseMethodChannelKey = "$BridgePrefix/$SingleLineViewType:" + const val CvvBaseMethodChannelKey = "$BridgePrefix/$CvvViewType:" + + // View Registration Keys + const val PaymentCardDetailsSingleLineViewKey = "$BridgePrefix/$SingleLineViewType" + const val PaymentCardCvvViewKey = "$BridgePrefix/$CvvViewType" + + // Method Call Keys + const val InitializeOloPayMethodKey = "initialize" + const val InitializeMetadataMethodKey = "initializeMetadata" + const val InitializeGooglePayMethodKey = "initializeGooglePay" + const val ChangeGooglePayVendorMethodKey = "changeGooglePayVendor" + const val IsDigitalWalletReadyMethodKey = "isDigitalWalletReady" + const val CreateDigitalWalletPaymentMethod = "createDigitalWalletPaymentMethod" + const val IsInitializedMethodKey = "isInitialized" + const val CreatePaymentMethodKey = "createPaymentMethod" + const val CreateCvvUpdateTokenKey = "createCvvUpdateToken" + const val GetStateMethodKey = "getState" + const val IsValidMethodKey = "isValid" + const val GetCardTypeMethodKey = "getCardType" + const val SetEnabledMethodKey = "setEnabled" + const val IsEnabledMethodKey = "isEnabled" + const val HasErrorMessageMethodKey = "hasErrorMessage" + const val GetErrorMessageMethodKey = "getErrorMessage" + const val ClearFieldsMethodKey = "clearFields" + const val RequestFocusMethodKey = "requestFocus" + const val ClearFocusMethodKey = "clearFocus" + const val RefreshUiMethod = "refreshUI" + + // Method Call Parameter Keys + const val EnabledParameterKey = "enabled" + const val IgnoreUneditedFieldsParameterKey = "ignoreUneditedFields" + const val DigitalWalletAmountParameterKey = "amount" + const val DigitalWalletCountryCodeParameterKey = "countryCode" + const val DigitalWalletErrorMessageParameterKey = "errorMessage" + const val GPayErrorTypeParameterKey = "googlePayErrorType" + const val DigitalWalletTypeParameterKey = "digitalWalletType" + const val DigitalWalletTypeParameterValue = "googlePay" + const val DigitalWalletReadyParameterKey = "isReady" + const val CreationParameters = "creationParams" + + // SDK Initialization Keys + const val ProductionEnvironmentKey = "productionEnvironment" + const val HybridSdkVersionKey = "version" + const val HybridBuildTypeKey = "buildType" + const val HybridBuildTypePublicValue = "public" + const val HybridBuildTypeInternalValue = "internal" + + // Google Pay Initialization Keys + const val GPayProductionEnvironmentKey = "googlePayProductionEnvironment" + const val GPayExistingPaymentMethodRequiredKey = "existingPaymentMethodRequired" + const val GPayEmailRequiredKey = "emailRequired" + const val GPayPhoneNumberRequiredKey = "phoneNumberRequired" + const val GPayFullAddressFormatKey = "fullAddressFormat" + const val GPayMerchantNameKey = "merchantName" + + // Payment Method Keys + const val PaymentMethodKey = "paymentMethod" + const val IDKey = "id" + const val Last4Key = "last4" + const val CardTypeKey = "cardType" + const val ExpirationMonthKey = "expMonth" + const val ExpirationYearKey = "expYear" + const val PostalCodeKey = "postalCode" + const val CountryCodeKey = "countryCode" + const val IsDigitalWalletKey = "isDigitalWallet" + + // Digital Wallet Payment Method Request Keys + const val GPayCurrencyCodeKey = "currencyCode" + const val GPayCurrencyMultiplierKey = "currencyMultiplier" + + // Event Handler Keys + const val OnFocusChangedEventHandlerKey = "onFocusChanged" + const val OnInputChangedEventHandlerKey = "onInputChanged" + const val OnValidStateChangedEventHandlerKey = "onValidStateChanged" + const val OnErrorMessageChangedEventHandlerKey = "onErrorMessageChanged" + const val DigitalWalletReadyEventHandlerKey = "digitalWalletReadyEvent" + + // EventHandler Parameter Keys + const val FocusedFieldParameterKey = "focusedField" + const val FieldStatesParameterKey = "fieldStates" + + //ICardFieldState Keys + const val IsValidKey = "isValid" + const val IsFocusedKey = "isFocused" + const val IsEmptyKey = "isEmpty" + const val WasEditedKey = "wasEdited" + const val WasFocusedKey = "wasFocused" + + // Text Styles Keys + const val TextColorKey = "textColor" + const val ErrorTextColorKey = "errorTextColor" + const val CursorColorKey = "cursorColor" + const val HintTextColorKey = "hintTextColor" + const val TextSizeKey = "textSize" + const val FontAssetKey = "fontAsset" + + // Background Style Keys + const val BackgroundColorKey = "backgroundColor" + const val BorderColorKey = "borderColor" + const val BorderWidthKey = "borderWidth" + const val BorderRadiusKey = "borderRadius" + + // Padding Style Keys + const val StartPaddingKey = "startPadding" + const val EndPaddingKey = "endPadding" + const val TopPaddingKey = "topPadding" + const val BottomPaddingKey = "bottomPadding" + + // View Initializer Argument Keys + const val HintsArgumentKey = "hints" + const val TextStylesArgumentsKey = "textStyles" + const val BackgroundStylesArgumentsKey = "backgroundStyles" + const val PaddingStylesArgumentsKey = "paddingStyles" + const val CustomErrorMessagesArgumentsKey = "customErrorMessages" + const val TextAlignmentKey = "textAlignment" + + // Error Types + const val EmptyErrorKey = "emptyError" + const val InvalidErrorKey = "invalidError" + + // Custom Error Message Keys + const val UnsupportedCardErrorKey = "unsupportedCardError" + + // Gravity Keys + const val GravityCenterKey = "center" + const val GravityRightKey = "right" + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/ErrorCodes.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/ErrorCodes.kt new file mode 100644 index 0000000..78a4811 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/ErrorCodes.kt @@ -0,0 +1,38 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.data + +class ErrorCodes { + companion object { + // Method call rejection code keys + const val InvalidParameter = "InvalidParameter" + const val MissingParameter = "MissingParameter" + const val UnexpectedParameterType = "UnexpectedParameterType" + const val UninitializedSdk = "SdkUninitialized" + const val InvalidGooglePaySetup = "InvalidGooglePaySetup" + const val GooglePayUninitialized = "GooglePayUninitialized" + const val GooglePayNotReady = "GooglePayNotReady" + + // Do not rename this key... it maps to an OPError type, but we also + // use if for some other scenarios in the RN SDK + const val GeneralError = "generalError" + + // Error codes based on API exceptions + const val ApiError = "ApiError" + const val InvalidRequest = "InvalidRequest" + const val Connection = "ConnectionError" + const val RateLimit = "RateLimitError" + const val Authentication = "AuthenticationError" + + // Error codes base on card exceptions + const val InvalidCardDetails = "InvalidCardDetails" + const val InvalidNumber = "InvalidNumber" + const val InvalidExpiration = "InvalidExpiration" + const val InvalidCvv = "InvalidCVV" + const val InvalidPostalCode = "InvalidPostalCode" + const val ExpiredCard = "ExpiredCard" + const val CardDeclined = "CardDeclined" + const val ProcessingError = "ProcessingError" + const val UnknownCardError = "UnknownCardError" + } +} diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/Exceptions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/Exceptions.kt new file mode 100644 index 0000000..6992f82 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/Exceptions.kt @@ -0,0 +1,23 @@ +package com.olo.flutter.olo_pay_sdk.data + +import java.lang.RuntimeException + +class MissingKeyException( + message: String, + exception: Throwable? = null +): RuntimeException(message, exception) + +class NullValueException( + message: String, + exception: Throwable? = null +): RuntimeException(message, exception) + +class UnexpectedTypeException( + message: String, + exception: Throwable? = null +): RuntimeException(message, exception) + +class EmptyValueException( + message: String, + exception: Throwable? = null +): RuntimeException(message, exception) diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/GlobalConstants.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/GlobalConstants.kt new file mode 100644 index 0000000..4ddeb7f --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/data/GlobalConstants.kt @@ -0,0 +1,12 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.data + +import android.os.Build + +class GlobalConstants { + companion object { + const val ApiOreo = Build.VERSION_CODES.O_MR1 + const val ApiQuinceTart = Build.VERSION_CODES.Q + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/CardFieldStateExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/CardFieldStateExtensions.kt new file mode 100644 index 0000000..5c829d4 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/CardFieldStateExtensions.kt @@ -0,0 +1,26 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.extensions + +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.olopay.data.CardField +import com.olo.olopay.data.ICardFieldState + +fun ICardFieldState.toMap(): Map { + return mapOf( + DataKeys.IsValidKey to this.isValid, + DataKeys.IsFocusedKey to this.isFocused, + DataKeys.IsEmptyKey to this.isEmpty, + DataKeys.WasEditedKey to this.wasEdited, + DataKeys.WasFocusedKey to this.wasFocused + ) +} + +fun Map.toMap(): Map { + return mapOf( + CardField.CardNumber.toString() to this[CardField.CardNumber]!!.toMap(), + CardField.Expiration.toString() to this[CardField.Expiration]!!.toMap(), + CardField.Cvv.toString() to this[CardField.Cvv]!!.toMap(), + CardField.PostalCode.toString() to this[CardField.PostalCode]!!.toMap() + ) +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/ContextExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/ContextExtensions.kt new file mode 100644 index 0000000..e709c1f --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/ContextExtensions.kt @@ -0,0 +1,17 @@ +package com.olo.flutter.olo_pay_sdk.extensions + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +fun Context.getActivity(): Activity? { + if (this is ContextWrapper) { + if (this is Activity) { + return this + } + + return baseContext.getActivity() + } + + return null +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/CvvUpdateTokenExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/CvvUpdateTokenExtensions.kt new file mode 100644 index 0000000..c9f57cc --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/CvvUpdateTokenExtensions.kt @@ -0,0 +1,14 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.extensions + +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.olopay.data.ICvvUpdateToken +import com.olo.olopay.data.OloPayEnvironment + +fun ICvvUpdateToken.toMap(): Map { + return mapOf( + DataKeys.IDKey to (this.id ?: ""), + DataKeys.ProductionEnvironmentKey to (this.environment == OloPayEnvironment.Production) + ) +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/GooglePayExceptionExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/GooglePayExceptionExtensions.kt new file mode 100644 index 0000000..337f360 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/GooglePayExceptionExtensions.kt @@ -0,0 +1,14 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.extensions + +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.olopay.exceptions.GooglePayException + +fun GooglePayException.toMap(): Map { + return mapOf( + DataKeys.DigitalWalletErrorMessageParameterKey to (this.message ?: ""), + DataKeys.GPayErrorTypeParameterKey to this.errorType.toString(), + DataKeys.DigitalWalletTypeParameterKey to DataKeys.DigitalWalletTypeParameterValue + ) +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/MethodCallExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/MethodCallExtensions.kt new file mode 100644 index 0000000..d4a50cf --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/MethodCallExtensions.kt @@ -0,0 +1,145 @@ +package com.olo.flutter.olo_pay_sdk.extensions + +import com.olo.flutter.olo_pay_sdk.data.EmptyValueException +import com.olo.flutter.olo_pay_sdk.data.ErrorCodes +import com.olo.flutter.olo_pay_sdk.data.MissingKeyException +import com.olo.flutter.olo_pay_sdk.data.NullValueException +import com.olo.flutter.olo_pay_sdk.data.UnexpectedTypeException +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +inline fun MethodCall.getArgOrErrorResult( + key: String, + baseError: String, + result: MethodChannel.Result +): T { + return getArgOrErrorResult(key, baseError, result, T::class.java) +} + +fun MethodCall.getArgOrErrorResult( + key: String, + baseError: String, + result: MethodChannel.Result, + tClass: Class +): T { + try { + return getArgOrThrow(key, tClass) + } catch (e: MissingKeyException) { + val errorMessage = "$baseError: Missing parameter '$key'" + result.oloError(ErrorCodes.MissingParameter, errorMessage) + throw MissingKeyException(errorMessage, e) + } catch (e: NullValueException) { + val errorMessage = "$baseError: Missing parameter '$key'" + result.oloError(ErrorCodes.MissingParameter, errorMessage) + throw NullValueException(errorMessage, e) + } catch (e: UnexpectedTypeException) { + val errorMessage = "$baseError: Value for '$key' is not of type ${tClass.simpleName}" + result.oloError(ErrorCodes.UnexpectedParameterType, errorMessage) + throw UnexpectedTypeException(errorMessage, e) + } +} + +inline fun MethodCall.getArgOrErrorResult( + key: String, + default: T, + baseError: String, + result: MethodChannel.Result +): T { + return getArgOrErrorResult(key, default, baseError, result, T::class.java) +} + +fun MethodCall.getArgOrErrorResult( + key: String, + default: T, + baseError: String, + result: MethodChannel.Result, + tClass: Class +): T { + try { + return getArgOrThrow(key, default, tClass) + } catch (e: UnexpectedTypeException) { + val errorMessage = "$baseError: Value for '$key' is not of type ${tClass.simpleName}" + result.oloError(ErrorCodes.UnexpectedParameterType, errorMessage) + throw UnexpectedTypeException(errorMessage, e) + } +} + +fun MethodCall.getStringArgOrErrorResult( + key: String, + default: String, + baseError: String, + acceptEmptyValue: Boolean, + result: MethodChannel.Result +): String { + try { + var value = getArgOrThrow(key, default, String::class.java).trim() + + if (!acceptEmptyValue && value.isEmpty()) { + value = default + } + + return value + } catch (e: UnexpectedTypeException) { + val errorMessage = "$baseError: Value for '$key' is not of type ${String::class.java.simpleName}" + result.oloError(ErrorCodes.UnexpectedParameterType, errorMessage) + throw UnexpectedTypeException(errorMessage, e) + } +} + +fun MethodCall.getStringArgOrErrorResult( + key: String, + baseError: String, + acceptEmptyValue: Boolean, + result: MethodChannel.Result +): String { + val value = getArgOrErrorResult(key, baseError, result, String::class.java).trim() + + if (!acceptEmptyValue && value.isEmpty()) { + val errorMessage = "$baseError: Value for '$key' cannot be empty" + result.oloError(ErrorCodes.InvalidParameter, errorMessage) + throw EmptyValueException(errorMessage) + } + + return value +} + +inline fun MethodCall.getArgOrThrow(key: String): T { + return getArgOrThrow(key, T::class.java) +} + +fun MethodCall.getArgOrThrow( + key: String, + tClass: Class +): T { + if (!this.hasArgument(key)) { + throw MissingKeyException("Missing key '$key'") + } + + this.argument(key)?.let { + if (it::class.java != tClass) { + throw UnexpectedTypeException("Value for '$key' is not of type ${tClass.simpleName}") + } + + return it + } + + throw NullValueException("Value for '$key' is null") +} + +inline fun MethodCall.getArgOrThrow(key: String, defaultValue: T): T { + return getArgOrThrow(key, defaultValue, T::class.java) +} + +fun MethodCall.getArgOrThrow( + key: String, + defaultValue: T, + tClass: Class +): T { + return try { + getArgOrThrow(key, tClass) + } catch (e: MissingKeyException) { + defaultValue + } catch (e: NullValueException) { + defaultValue + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/PaymentMethodExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/PaymentMethodExtensions.kt new file mode 100644 index 0000000..aa0f9e4 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/PaymentMethodExtensions.kt @@ -0,0 +1,23 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.extensions + +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.olopay.data.IPaymentMethod +import com.olo.olopay.data.OloPayEnvironment + +private const val InvalidExpiration = -1 + +fun IPaymentMethod.toMap(): Map { + return mapOf( + DataKeys.IDKey to (this.id ?: ""), + DataKeys.Last4Key to (this.last4 ?: ""), + DataKeys.CardTypeKey to (this.cardType?.description ?: ""), + DataKeys.ExpirationMonthKey to (this.expirationMonth ?: InvalidExpiration), + DataKeys.ExpirationYearKey to (this.expirationYear ?: InvalidExpiration), + DataKeys.PostalCodeKey to (this.postalCode ?: ""), + DataKeys.CountryCodeKey to (this.country ?: ""), + DataKeys.IsDigitalWalletKey to this.isGooglePay, + DataKeys.ProductionEnvironmentKey to (this.environment == OloPayEnvironment.Production) + ) +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/ResultExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/ResultExtensions.kt new file mode 100644 index 0000000..3a4615b --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/ResultExtensions.kt @@ -0,0 +1,65 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.extensions + +import com.olo.flutter.olo_pay_sdk.data.ErrorCodes +import com.olo.flutter.olo_pay_sdk.utils.OloPayLog +import com.olo.olopay.data.CardErrorType +import com.olo.olopay.exceptions.ApiConnectionException +import com.olo.olopay.exceptions.ApiException +import com.olo.olopay.exceptions.CardException +import com.olo.olopay.exceptions.InvalidRequestException +import com.olo.olopay.exceptions.OloPayException +import com.olo.olopay.exceptions.RateLimitException +import io.flutter.plugin.common.MethodChannel + +fun MethodChannel.Result.oloError(e: OloPayException) { + oloError(e, "Unexpected error occurred") +} + +fun MethodChannel.Result.oloError(e: OloPayException, defaultMessage: String) { + val errorCode = getErrorCode(e) + val errorMessage = if (e.message.isNullOrEmpty()) defaultMessage else e.message!! + OloPayLog.e(errorMessage, e) + error(errorCode, errorMessage, null) +} + +fun MethodChannel.Result.oloError(errorCode: String, message: String) { + OloPayLog.e("${errorCode}: $message") + error(errorCode, message, null) +} + +private fun getErrorCode(e: OloPayException): String { + val cardException = e as? CardException + if (cardException != null) { + return getErrorCode(cardException.type) + } + + if (e is ApiException) + return ErrorCodes.ApiError + + if (e is InvalidRequestException) + return ErrorCodes.InvalidRequest + + if (e is ApiConnectionException) + return ErrorCodes.Connection + + if (e is RateLimitException) + return ErrorCodes.RateLimit + + return ErrorCodes.GeneralError +} + +private fun getErrorCode(cardErrorType: CardErrorType): String { + return when (cardErrorType) { + CardErrorType.InvalidNumber -> ErrorCodes.InvalidNumber + CardErrorType.InvalidExpMonth -> ErrorCodes.InvalidExpiration + CardErrorType.InvalidExpYear -> ErrorCodes.InvalidExpiration + CardErrorType.InvalidCVV -> ErrorCodes.InvalidCvv + CardErrorType.InvalidZip -> ErrorCodes.InvalidPostalCode + CardErrorType.ExpiredCard -> ErrorCodes.ExpiredCard + CardErrorType.CardDeclined -> ErrorCodes.CardDeclined + CardErrorType.ProcessingError -> ErrorCodes.ProcessingError + CardErrorType.UnknownCardError -> ErrorCodes.UnknownCardError + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/SemaphoreExtensions.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/SemaphoreExtensions.kt new file mode 100644 index 0000000..891ec6f --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/extensions/SemaphoreExtensions.kt @@ -0,0 +1,11 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.extensions + +import kotlinx.coroutines.sync.Semaphore + +fun Semaphore.safeRelease() { + if (this.availablePermits == 0) { + this.release() + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/googlepay/GooglePayFragment.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/googlepay/GooglePayFragment.kt new file mode 100644 index 0000000..a33475c --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/googlepay/GooglePayFragment.kt @@ -0,0 +1,84 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.googlepay + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import com.olo.olopay.googlepay.Result +import com.olo.olopay.googlepay.GooglePayContext +import com.olo.olopay.googlepay.ReadyCallback +import com.olo.olopay.googlepay.ResultCallback +import io.flutter.plugin.common.MethodChannel + +class GooglePayFragment : Fragment() { + private var _isReady = false + private var _googlePayContext: GooglePayContext? = null + private var _promise: MethodChannel.Result? = null + + var merchantName: String? + get() = requireArguments().getString(MerchantNameKey, null) + set(newValue) = requireArguments().putString(MerchantNameKey, newValue) + + var countryCode: String? + get() = requireArguments().getString(CountryCodeKey, null) + set(newValue) = requireArguments().putString(CountryCodeKey, newValue) + + var resultCallback: FlutterGooglePayResultCallback? = null + + private var _readyCallback: ReadyCallback? = null + var readyCallback: ReadyCallback? + get() = _readyCallback + set(callback) { + _readyCallback = callback + if (isReady) + onGooglePayReady(isReady) + } + + init { + arguments = Bundle() + } + + val isReady: Boolean + get() = _isReady + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return FrameLayout(requireActivity()).also { + it.visibility = View.GONE + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _googlePayContext = GooglePayContext(this, ::onGooglePayReady, null, merchantName, countryCode) + } + + fun present(currencyCode: String, amount: Int, promise: MethodChannel.Result) { + _promise = promise + _googlePayContext?.resultCallback = ResultCallback { result -> onGooglePayResult(result) } + _googlePayContext?.present(currencyCode, amount) + } + + private fun onGooglePayResult(result: Result) { + if (_promise != null) + resultCallback?.onResult(result, _promise!!) + } + + private fun onGooglePayReady(isReady: Boolean) { + _isReady = isReady && !countryCode.isNullOrEmpty() && !merchantName.isNullOrEmpty() + readyCallback?.onReady(_isReady) + } + + companion object { + const val Tag = "com.olo.flutter.olo_pay_sdk.GooglePayFragment" + private const val MerchantNameKey = "${Tag}.MerchantName" + private const val CountryCodeKey = "${Tag}.CountryCode" + } +} + +fun interface FlutterGooglePayResultCallback { + fun onResult(result: Result, promise: MethodChannel.Result) +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/MethodFinishedCallback.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/MethodFinishedCallback.kt new file mode 100644 index 0000000..1b0b252 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/MethodFinishedCallback.kt @@ -0,0 +1,7 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.utils + +fun interface MethodFinishedCallback { + fun invoke() +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/OloPayLog.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/OloPayLog.kt new file mode 100644 index 0000000..bf78462 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/OloPayLog.kt @@ -0,0 +1,70 @@ +package com.olo.flutter.olo_pay_sdk.utils + +import io.flutter.Log + +enum class LogLevel(val intValue: Int) { + Assert(Log.ASSERT), + Debug(Log.DEBUG), + Error(Log.ERROR), + Info(Log.INFO), + Verbose(Log.VERBOSE), + Warn(Log.WARN) +} + +class OloPayLog { + companion object { + val OloPayTag = "OloPaySDK: Flutter" + + fun setLogLevel(level: LogLevel) { + Log.setLogLevel(level.intValue) + } + + fun println(message: String, level: LogLevel = LogLevel.Debug, tag: String = OloPayTag) { + Log.println(level.intValue, tag, message) + } + + fun v(message: String, tag: String = OloPayTag) { + Log.v(tag, message) + } + + fun i(message: String, tag: String = OloPayTag) { + Log.i(tag, message) + } + + fun d(message: String, tag: String = OloPayTag) { + Log.d(tag, message) + } + + fun d(message: String, tr: Throwable, tag: String = OloPayTag) { + Log.d(tag, message, tr) + } + + fun w(message: String, tag: String = OloPayTag) { + Log.w(tag, message) + } + + fun w(message: String, tr: Throwable, tag: String = OloPayTag) { + Log.w(tag, message, tr) + } + + fun e(message: String, tag: String = OloPayTag) { + Log.e(tag, message) + } + + fun e(message: String, tr: Throwable, tag: String = OloPayTag) { + Log.e(tag, message, tr) + } + + fun wtf(message: String, tag: String = OloPayTag) { + Log.wtf(tag, message) + } + + fun wtf(message: String, tr: Throwable, tag: String = OloPayTag) { + Log.wtf(tag, message, tr) + } + + fun getStackTrace(tr: Throwable): String { + return Log.getStackTraceString(tr) + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/ThreadingUtils.kt b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/ThreadingUtils.kt new file mode 100644 index 0000000..cde4e31 --- /dev/null +++ b/android/src/main/kotlin/com/olo/flutter/olo_pay_sdk/utils/ThreadingUtils.kt @@ -0,0 +1,19 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +fun backgroundOperation(operation: suspend() -> Unit) { + CoroutineScope(Dispatchers.IO).launch { + operation() + } +} + +fun uiOperation(operation: suspend() -> Unit) { + CoroutineScope(Dispatchers.Main).launch { + operation() + } +} \ No newline at end of file diff --git a/android/src/main/res/layout/flutter_olopay_cvv_view.xml b/android/src/main/res/layout/flutter_olopay_cvv_view.xml new file mode 100644 index 0000000..64520a0 --- /dev/null +++ b/android/src/main/res/layout/flutter_olopay_cvv_view.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/layout/flutter_olopay_single_line_view.xml b/android/src/main/res/layout/flutter_olopay_single_line_view.xml new file mode 100644 index 0000000..fd980da --- /dev/null +++ b/android/src/main/res/layout/flutter_olopay_single_line_view.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/values/colors.xml b/android/src/main/res/values/colors.xml new file mode 100644 index 0000000..bdd0192 --- /dev/null +++ b/android/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #EEEEEE + @android:color/darker_gray + \ No newline at end of file diff --git a/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/CardFieldStateExtensionTests.kt b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/CardFieldStateExtensionTests.kt new file mode 100644 index 0000000..d392cca --- /dev/null +++ b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/CardFieldStateExtensionTests.kt @@ -0,0 +1,67 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import com.olo.flutter.olo_pay_sdk.extensions.toMap +import com.olo.olopay.data.ICardFieldState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class CardFieldStateExtensionTests { + + @Test + fun testToMap_hasCorrectLength() { + assertEquals(5, MockCardFieldState().toMap().size) + } + + @Test + fun testToMap_hasCorrectKeys() { + val map = MockCardFieldState().toMap() + + assertNotNull(map["isValid"]) + assertNotNull(map["isFocused"]) + assertNotNull(map["isEmpty"]) + assertNotNull(map["wasEdited"]) + assertNotNull(map["wasFocused"]) + } + + @Test + fun testToMap_keysHaveCorrectValues() { + val map1 = MockCardFieldState( + isValid = true, + isEmpty = false, + isFocused = true, + wasEdited = false, + wasFocused = true, + ).toMap() + + assertEquals(true, map1["isValid"]) + assertEquals(false, map1["isEmpty"]) + assertEquals(true, map1["isFocused"]) + assertEquals(false, map1["wasEdited"]) + assertEquals(true, map1["wasFocused"]) + + val map2 = MockCardFieldState( + isValid = false, + isEmpty = true, + isFocused = false, + wasEdited = true, + wasFocused = false, + ).toMap() + + assertEquals(false, map2["isValid"]) + assertEquals(true, map2["isEmpty"]) + assertEquals(false, map2["isFocused"]) + assertEquals(true, map2["wasEdited"]) + assertEquals(false, map2["wasFocused"]) + } +} + +class MockCardFieldState( + override val isValid: Boolean = true, + override val isEmpty: Boolean = true, + override val isFocused: Boolean = true, + override val wasEdited: Boolean = true, + override val wasFocused: Boolean = true +) : ICardFieldState {} \ No newline at end of file diff --git a/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/CustomErrorMessagesTests.kt b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/CustomErrorMessagesTests.kt new file mode 100644 index 0000000..452b126 --- /dev/null +++ b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/CustomErrorMessagesTests.kt @@ -0,0 +1,1079 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import com.olo.flutter.olo_pay_sdk.data.CustomErrorMessages +import com.olo.flutter.olo_pay_sdk.data.DataKeys +import com.olo.olopay.data.CardBrand +import com.olo.olopay.data.CardField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CustomErrorMessagesTests { + + @Test + fun getCustomErrorMessage_cardNumberField_withCustomErrorMessagesDefined_returnsCustomErrorMessages() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card", + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState) + ) + + assertEquals("Empty card", emptyMessage) + assertEquals("Invalid card", invalidMessage) + } + + @Test + fun getCustomErrorMessage_cardNumberField_missingCustomErrorMessages_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + null to null + // DataKeys.EmptyErrorKey to "Empty card", removed for testing purposes + // DataKeys.InvalidErrorKey to "Invalid card" removed for testing purposes + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card", + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_cardNumberField_customErrorMessagesNull_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to null, + DataKeys.InvalidErrorKey to null, + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card", + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_cardNumberField_emptyCustomErrorMessages_returnsEmptyStrings() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "", + DataKeys.InvalidErrorKey to "" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card", + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState) + ) + + assertEquals("", emptyMessage) + assertEquals("", invalidMessage) + } + + @Test + fun getCustomErrorMessage_unsupportedCardBrand_withCustomErrorMessageDefined_returnsCustomErrorMessage() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card", + ) + ) + + val message = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState), + CardBrand.Unsupported + ) + + assertEquals("Unsupported card", message) + } + + @Test + fun getCustomErrorMessage_unsupportedCardBrand_missingCustomErrorMessage_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + // DataKeys.UnsupportedCardErrorKey to "Unsupported card", removed for testing purposes + ) + ) + + val message = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState), + CardBrand.Unsupported + ) + + assertEquals(null, message) + } + + @Test + fun getCustomErrorMessage_unsupportedCardBrand_customErrorMessageNull_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to null, + ) + ) + + val message = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState), + CardBrand.Unsupported + ) + + assertEquals(null, message) + } + + @Test + fun getCustomErrorMessage_unsupportedCardBrand_emptyCustomErrorMessage_returnsEmptyString() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "", + ) + ) + + val message = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.CardNumber to invalidFieldState), + CardBrand.Unsupported + ) + + assertEquals("", message) + } + + @Test + fun getCustomErrorMessage_expirationField_withCustomErrorMessagesDefined_returnsCustomErrorMessages() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to invalidFieldState) + ) + + assertEquals("Empty expiration", emptyMessage) + assertEquals("Invalid expiration", invalidMessage) + } + + @Test + fun getCustomErrorMessage_expirationField_missingCustomErrorMessages_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + null to null + // DataKeys.EmptyErrorKey to "Empty expiration", removed for testing purposes + // DataKeys.InvalidErrorKey to "Invalid expiration", removed for testing purposes + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_expirationField_customErrorMessagesNull_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to null, + DataKeys.InvalidErrorKey to null, + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_expirationField_emptyCustomErrorMessages_returnsEmptyStrings() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "", + DataKeys.InvalidErrorKey to "" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Expiration to invalidFieldState) + ) + + assertEquals("", emptyMessage) + assertEquals("", invalidMessage) + } + + @Test + fun getCustomErrorMessage_cvvField_withCustomErrorMessagesDefined_returnsCustomErrorMessages() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to invalidFieldState) + ) + + assertEquals("Empty cvv", emptyMessage) + assertEquals("Invalid cvv", invalidMessage) + } + + @Test + fun getCustomErrorMessage_cvvField_missingCustomErrorMessages_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + null to null + // DataKeys.EmptyErrorKey to "Empty cvv", removed for testing purposes + // DataKeys.InvalidErrorKey to "Invalid cvv", removed for testing purposes + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_cvvField_customErrorMessagesNull_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to null, + DataKeys.InvalidErrorKey to null + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_cvvField_emptyCustomErrorMessages_returnsEmptyStrings() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid Expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "", + DataKeys.InvalidErrorKey to "" + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.Cvv to invalidFieldState) + ) + + assertEquals("", emptyMessage) + assertEquals("", invalidMessage) + } + + @Test + fun getCustomErrorMessage_postalCodeField_withCustomErrorMessagesDefined_returnsCustomErrorMessages() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv", + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code" + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to invalidFieldState) + ) + + assertEquals("Empty postal code", emptyMessage) + assertEquals("Invalid postal code", invalidMessage) + } + + @Test + fun getCustomErrorMessage_postalCodeField_missingCustomErrorMessages_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv", + ), + CardField.PostalCode.toString() to mapOf( + null to null + // DataKeys.EmptyErrorKey to "Empty postal code", removed for testing purposes + // DataKeys.InvalidErrorKey to "Invalid postal code", removed for testing purposes + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_postalCodeField_customErrorMessagesNull_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv", + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to null, + DataKeys.InvalidErrorKey to null, + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to invalidFieldState) + ) + + assertEquals(null, emptyMessage) + assertEquals(null, invalidMessage) + } + + @Test + fun getCustomErrorMessage_postalCodeField_emptyCustomErrorMessages_returnsEmptyStrings() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv", + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "", + DataKeys.InvalidErrorKey to "", + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val emptyMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to emptyFieldState) + ) + + val invalidMessage = customErrorMessages.getCustomErrorMessage( + false, + mapOf(CardField.PostalCode to invalidFieldState) + ) + + assertEquals("", emptyMessage) + assertEquals("", invalidMessage) + } + + @Test + fun getCustomErrorMessage_emptyCardFieldMap_returnsNull() { + val customErrorMessages = CustomErrorMessages( + mapOf( + CardField.CardNumber.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty card", + DataKeys.InvalidErrorKey to "Invalid card" + ), + CardField.Expiration.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty expiration", + DataKeys.InvalidErrorKey to "Invalid expiration" + ), + CardField.Cvv.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty cvv", + DataKeys.InvalidErrorKey to "Invalid cvv", + ), + CardField.PostalCode.toString() to mapOf( + DataKeys.EmptyErrorKey to "Empty postal code", + DataKeys.InvalidErrorKey to "Invalid postal code", + ), + DataKeys.UnsupportedCardErrorKey to "Unsupported card" + ) + ) + + val message = customErrorMessages.getCustomErrorMessage( + false, + mapOf() + ) + + assertEquals(null, message) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsTrue_cardNumberField_notValidWasEditedWasFocused_returnsCardNumberField() { + val ignoreUneditedFields = true + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to MockCardFieldState( + isEmpty = true, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + CardField.Expiration to validFieldState, + CardField.Cvv to validFieldState, + CardField.PostalCode to validFieldState, + ) + ) + + assertEquals(errorFields.size, 1) + assertTrue(errorFields.containsKey(CardField.CardNumber)) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsTrue_cvvAndPostalCodeFields_invalidWasEditedWasFocused_returnsCvvAndPostalCodeFields() { + val ignoreUneditedFields = true + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to MockCardFieldState( + isEmpty = false, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = false, + ), + CardField.Expiration to MockCardFieldState( + isEmpty = true, + isValid = false, + isFocused = false, + wasEdited = false, + wasFocused = true, + ), + CardField.Cvv to MockCardFieldState( + isEmpty = false, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + CardField.PostalCode to MockCardFieldState( + isEmpty = true, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + ) + ) + + assertEquals(errorFields.size, 2) + assertTrue(errorFields.containsKey(CardField.Cvv)) + assertTrue(errorFields.containsKey(CardField.PostalCode)) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsTrue_allFields_invalidWasEditedWasFocused_returnsAllFields() { + val ignoreUneditedFields = true + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to MockCardFieldState( + isEmpty = false, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + CardField.Expiration to MockCardFieldState( + isEmpty = false, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + CardField.Cvv to MockCardFieldState( + isEmpty = true, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + CardField.PostalCode to MockCardFieldState( + isEmpty = true, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + ) + ) + + assertEquals(errorFields.size, 4) + assertTrue(errorFields.containsKey(CardField.CardNumber)) + assertTrue(errorFields.containsKey(CardField.Expiration)) + assertTrue(errorFields.containsKey(CardField.Cvv)) + assertTrue(errorFields.containsKey(CardField.PostalCode)) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsTrue_noFields_invalidWasEditedWasFocused_returnsNoFields() { + val ignoreUneditedFields = true + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to validFieldState, + CardField.Expiration to MockCardFieldState( + isEmpty = true, + isValid = true, + isFocused = false, + wasEdited = false, + wasFocused = true, + ), + CardField.Cvv to MockCardFieldState( + isEmpty = false, + isValid = true, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + CardField.PostalCode to MockCardFieldState( + isEmpty = true, + isValid = true, + isFocused = false, + wasEdited = true, + wasFocused = true, + ), + ) + ) + + assertEquals(errorFields.size, 0) + assertTrue(errorFields.isEmpty()) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsFalse_allValidFields_returnsEmptyMap() { + val ignoreUneditedFields = false + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to validFieldState, + CardField.Expiration to validFieldState, + CardField.Cvv to validFieldState, + CardField.PostalCode to validFieldState, + ) + ) + + assertTrue(errorFields.isEmpty()) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsFalse_onlyNumberFieldInvalid_returnsOnlyNumberField() { + val ignoreUneditedFields = false + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to invalidFieldState, + CardField.Expiration to validFieldState, + CardField.Cvv to validFieldState, + CardField.PostalCode to validFieldState, + ) + ) + + assertEquals(1, errorFields.size) + assertTrue(errorFields.containsKey(CardField.CardNumber)) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsFalse_expirationAndCvvFieldsInvalid_returnsCorrectErrorFields() { + val ignoreUneditedFields = false + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to validFieldState, + CardField.Expiration to invalidFieldState, + CardField.Cvv to invalidFieldState, + CardField.PostalCode to validFieldState, + ) + ) + + assertEquals(2, errorFields.size) + assertTrue(errorFields.containsKey(CardField.Expiration)) + assertTrue(errorFields.containsKey(CardField.Cvv)) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsFalse_allFieldsInvalid_returnsAllErrorFields() { + val ignoreUneditedFields = false + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to invalidFieldState, + CardField.Expiration to invalidFieldState, + CardField.Cvv to invalidFieldState, + CardField.PostalCode to MockCardFieldState( + isEmpty = false, + isValid = false, + isFocused = true, + wasEdited = true, + wasFocused = true + ), + ) + ) + + assertEquals(4, errorFields.size) + assertTrue(errorFields.containsKey(CardField.CardNumber)) + assertTrue(errorFields.containsKey(CardField.Expiration)) + assertTrue(errorFields.containsKey(CardField.Cvv)) + assertTrue(errorFields.containsKey(CardField.PostalCode)) + } + + @Test + fun getErrorFields_ignoreUneditedFieldsFalse_numberFieldInvalid_expirationWasEditedAndWasFocused_returnsOnlyCardField() { + val ignoreUneditedFields = false + + val customErrorMessages = CustomErrorMessages(mapOf(CardField.CardNumber to "")) + + val errorFields = customErrorMessages.getErrorFields( + ignoreUneditedFields, + mapOf( + CardField.CardNumber to invalidFieldState, + CardField.Expiration to MockCardFieldState( + isEmpty = false, + isValid = true, + isFocused = false, + wasEdited = true, + wasFocused = true + ), + CardField.Cvv to validFieldState, + CardField.PostalCode to validFieldState + ) + ) + + assertEquals(1, errorFields.size ) + assertTrue(errorFields.containsKey(CardField.CardNumber)) + } + + val invalidFieldState = MockCardFieldState( + isEmpty = false, + isValid = false, + isFocused = false, + wasEdited = true, + wasFocused = true + ) + + val emptyFieldState = MockCardFieldState( + isEmpty = true, + isValid = false, + isFocused = false, + wasEdited = false, + wasFocused = false, + ) + + val validFieldState = MockCardFieldState() +} diff --git a/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/GooglePayExceptionExtensionTests.kt b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/GooglePayExceptionExtensionTests.kt new file mode 100644 index 0000000..70eaafc --- /dev/null +++ b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/GooglePayExceptionExtensionTests.kt @@ -0,0 +1,47 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import com.olo.flutter.olo_pay_sdk.extensions.toMap +import com.olo.olopay.exceptions.GooglePayException +import com.olo.olopay.googlepay.GooglePayErrorType +import kotlin.Exception +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class GooglePayExceptionExtensionTests { + @Test + fun testToMap_hasCorrectLength() { + val map = GooglePayException( + throwable = Exception("message"), + errorType = GooglePayErrorType.InternalError, + ).toMap() + + assertEquals(3, map.size) + } + + @Test + fun testToMap_hasCorrectKeys() { + val map = GooglePayException( + throwable = Exception("message"), + errorType = GooglePayErrorType.InternalError, + ).toMap() + + assertNotNull(map["errorMessage"]) + assertNotNull(map["googlePayErrorType"]) + assertNotNull(map["digitalWalletType"]) + } + + @Test + fun testToMap_hasCorrectValues() { + val map = GooglePayException( + throwable = Exception("test error message"), + errorType = GooglePayErrorType.InternalError, + ).toMap() + + assertEquals("java.lang.Exception: test error message", map["errorMessage"]) + assertEquals("InternalError", map["googlePayErrorType"]) + assertEquals("googlePay", map["digitalWalletType"]) + } +} diff --git a/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/PaymentMethodExtensionTests.kt b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/PaymentMethodExtensionTests.kt new file mode 100644 index 0000000..947db3c --- /dev/null +++ b/android/src/test/kotlin/com/olo/flutter/olo_pay_sdk/PaymentMethodExtensionTests.kt @@ -0,0 +1,73 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk + +import android.os.Parcel +import com.olo.flutter.olo_pay_sdk.extensions.toMap +import com.olo.olopay.data.CardBrand +import com.olo.olopay.data.IPaymentMethod +import com.olo.olopay.data.OloPayEnvironment +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + + +class PaymentMethodExtensionTests { + + @Test + fun toMap_hasCorrectLength() { + assertEquals(9, MockPaymentMethod().toMap().size) + } + + @Test + fun toMap_hasCorrectKeys() { + val map = MockPaymentMethod().toMap() + + assertNotNull(map["cardType"]) + assertNotNull(map["countryCode"]) + assertNotNull(map["productionEnvironment"]) + assertNotNull(map["expMonth"]) + assertNotNull(map["expYear"]) + assertNotNull(map["id"]) + assertNotNull(map["isDigitalWallet"]) + assertNotNull(map["last4"]) + assertNotNull(map["postalCode"]) + } + + @Test + fun toMap_hasCorrectValues() { + val map = MockPaymentMethod().toMap() + + assertEquals("Visa", map["cardType"]) + assertEquals("US", map["countryCode"]) + assertEquals(false, map["productionEnvironment"]) + assertEquals(1, map["expMonth"]) + assertEquals(2001, map["expYear"]) + assertEquals("pm_1234", map["id"]) + assertEquals(false, map["isDigitalWallet"]) + assertEquals("1234", map["last4"]) + assertEquals("12345", map["postalCode"]) + } + +} + +class MockPaymentMethod( + override val cardType: CardBrand? = CardBrand.Visa, + override val country: String? = "US", + override val environment: OloPayEnvironment = OloPayEnvironment.Test, + override val expirationMonth: Int? = 1, + override val expirationYear: Int? = 2001, + override val id: String? = "pm_1234", + override val isGooglePay: Boolean = false, + override val last4: String? = "1234", + override val postalCode: String? = "12345" +) : IPaymentMethod { + override fun describeContents(): Int { + // Not needed for testing + return 0 + } + + override fun writeToParcel(p0: Parcel, p1: Int) { + // Not needed for testing + } +} \ No newline at end of file diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/dartdoc_options.yaml @@ -0,0 +1 @@ + diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..04eec88 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,67 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "com.olo.flutter.olo_pay_sdk_example" + compileSdkVersion 34 + ndkVersion "25.1.8937393" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.olo.flutter.olo_pay_sdk_example" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion 23 + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies {} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..411d8d1 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9cd8dea --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java new file mode 100644 index 0000000..7965440 --- /dev/null +++ b/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -0,0 +1,34 @@ +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.olo.flutter.olo_pay_sdk.OloPaySdkPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin olo_pay_sdk, com.olo.flutter.olo_pay_sdk.OloPaySdkPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.pay_android.PayPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin pay_android, io.flutter.plugins.pay_android.PayPlugin", e); + } + } +} diff --git a/example/android/app/src/main/kotlin/com/olo/flutter/olo_pay_sdk_example/MainActivity.kt b/example/android/app/src/main/kotlin/com/olo/flutter/olo_pay_sdk_example/MainActivity.kt new file mode 100644 index 0000000..7974c3c --- /dev/null +++ b/example/android/app/src/main/kotlin/com/olo/flutter/olo_pay_sdk_example/MainActivity.kt @@ -0,0 +1,8 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +package com.olo.flutter.olo_pay_sdk_example + +import io.flutter.embedding.android.FlutterFragmentActivity + +class MainActivity: FlutterFragmentActivity() { +} diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..467b397 --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..fd81109 --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..72182e9 --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..7848cad --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..411d8d1 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..19197b9 --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,37 @@ +buildscript { + ext.kotlin_version = '1.8.22' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + + // This repo is only used internally for testing purposes. It can be left here because it just + // specifies a location to look for additional dependencies. If the environment variable is not + // defined this repo declaration will simply be ignored by the build system + maven { + url System.getenv('LOCAL_DEV_RELEASE_REPO') ?: '' + } + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..73c3168 --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.jar b/example/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/example/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..46f01fe --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 14 22:34:42 MDT 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/example/android/gradlew b/example/android/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/example/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..40ba305 --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,29 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + plugins { + id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version '8.2.0' apply false +} + +include ":app" diff --git a/example/example.md b/example/example.md new file mode 100644 index 0000000..1052c82 --- /dev/null +++ b/example/example.md @@ -0,0 +1,275 @@ +```dart +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk.dart'; +import 'package:pay/pay.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Olo Pay SDK Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color.fromRGBO(1, 160, 219, 1)), + useMaterial3: true, + ), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.system, + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key}); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + // Step 1: Create the plugin instance + final _oloPaySdkPlugin = OloPaySdk(); + + // Controller classes allow interaction with the widget + CardDetailsSingleLineTextFieldController? _cardInputController; + + String _status = ''; + bool _sdkInitialized = false; + bool _digitalWalletsReady = false; + bool _enabled = true; + bool _showAll = false; + + @override + void initState() { + super.initState(); + initOloPaySDK(); + } + + void onSingleLineControllerCreated(CardDetailsSingleLineTextFieldController controller) { + _cardInputController = controller; + } + + void updatePaymentMethod(PaymentMethod paymentMethod) { + updateStatus("Payment Method\n${paymentMethod.toString()}"); + } + + void updateStatus(String status) { + setState(() { + _status = status; + }); + } + + void onInputChanged( + bool isValid, Map fieldStates, + ) { + // Add code here to handle this callback + // log('onInputChanged: $isValid'); + } + + void onValidStateChanged( + bool isValid, Map fieldStates, + ) { + // Add code here to handle this callback + // log('onValidStateChanged: $isValid'); + } + + void onFocusChanged( + CardField? focusedField, + bool isValid, + Map fieldStates, + ) { + // Add code here to handle this callback + // log('onFocusChanged: $focusedField'); + } + + void onDigitalWalletReady(bool isReady) { + setState(() { + _digitalWalletsReady = isReady; + }); + } + + // Step 2: Initialize the Olo Pay SDK + Future initOloPaySDK() async { + var sdkInitialized = false; + try { + _oloPaySdkPlugin.onDigitalWalletReady = onDigitalWalletReady; + + const OloPaySetupParameters sdkParams = OloPaySetupParameters( + productionEnvironment: false, + ); + + const GooglePaySetupParameters googlePayParams = GooglePaySetupParameters( + countryCode: "US", + merchantName: "Foosburgers", + productionEnvironment: false, + ); + + const ApplePaySetupParameters applePayParams = ApplePaySetupParameters( + merchantId: "merchant.com.olopaysdktestharness", + companyLabel: "SDK Test", + ); + + await _oloPaySdkPlugin.initializeOloPay( + oloPayParams: sdkParams, + googlePayParams: googlePayParams, + applePayParams: applePayParams, + ); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + + setState(() { + _sdkInitialized = true; + }); + } + + Future createPaymentMethod() async { + try { + var paymentMethod = await _cardInputController?.createPaymentMethod(); + if (paymentMethod != null) { + // Once a payment method is generated, it can be used to submit an order to the Olo Ordering API + updatePaymentMethod(paymentMethod); + } + } on PlatformException { + // Handle exceptions here + } + } + + Future createDigitalWalletPaymentMethod() async { + const DigitalWalletPaymentParameters paymentParams = DigitalWalletPaymentParameters(amount: 1.21); + + try { + PaymentMethod? paymentMethod = await _oloPaySdkPlugin + .createDigitalWalletPaymentMethod(paymentParams); + + if (paymentMethod == null) { + // User canceled digital wallet flow + updateStatus("User Canceled"); + } else { + // Once a payment method is generated, it can be used to submit an order to the Olo Ordering API + updatePaymentMethod(paymentMethod); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future clear() async { + try { + await _cardInputController?.clearFields(); + updateStatus(""); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text("Olo Pay SDK Demo"), + ), + body: SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + "SDK Initialized: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(_sdkInitialized.toString()) + ], + ), + Row( + children: [ + const Text( + "Digital Wallets Ready: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(_digitalWalletsReady.toString()) + ], + ), + const SizedBox(height: 16.0), + if (_sdkInitialized) //The SDK must be initialized prior to displaying the text field + CardDetailsSingleLineTextField( + // Step 3: Provide a callback to get a controller instance + onControllerCreated: onSingleLineControllerCreated, + onInputChanged: onInputChanged, + onValidStateChanged: onValidStateChanged, + onFocusChanged: onFocusChanged, + ), + const SizedBox(height: 16.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + // Step 4a: Create the payment method with a card input widget + onPressed: createPaymentMethod, + child: const Text("Submit"), + ), + ElevatedButton( + onPressed: clear, + child: const Text("Clear"), + ), + ], + ), + const SizedBox(height: 16.0), + Row( + children: [ + if (defaultTargetPlatform == TargetPlatform.android && + _digitalWalletsReady) + Expanded( + child: RawGooglePayButton( + type: GooglePayButtonType.buy, + // Step 4b: Create the payment method via Google Pay + onPressed: createDigitalWalletPaymentMethod, + ), + ), + if (defaultTargetPlatform == TargetPlatform.iOS && + _digitalWalletsReady) + Expanded( + child: RawApplePayButton( + type: ApplePayButtonType.buy, + // Step 4c: Create the payment method via Apple Pay + onPressed: createDigitalWalletPaymentMethod, + ), + ), + ], + ), + const SizedBox(height: 8.0), + Text( + _status, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.blue, + fontSize: 18.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} +``` \ No newline at end of file diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..870a712 --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,4 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Generated.xcconfig b/example/ios/Flutter/Generated.xcconfig new file mode 100644 index 0000000..c968331 --- /dev/null +++ b/example/ios/Flutter/Generated.xcconfig @@ -0,0 +1,14 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/Users/runner/hostedtoolcache/flutter-macOS-3.16.1-X64/flutter +FLUTTER_APPLICATION_PATH=/Users/runner/work/olo-pay-flutter-sdk/olo-pay-flutter-sdk/olo_pay_sdk/example +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib/main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=1.0.0 +FLUTTER_BUILD_NUMBER=1 +EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 +EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..0403f85 --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,4 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/flutter_export_environment.sh b/example/ios/Flutter/flutter_export_environment.sh new file mode 100755 index 0000000..74bc622 --- /dev/null +++ b/example/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/Users/runner/hostedtoolcache/flutter-macOS-3.16.1-X64/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/runner/work/olo-pay-flutter-sdk/olo-pay-flutter-sdk/olo_pay_sdk/example" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..230009a --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,49 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '13.0' + +source 'https://github.com/CocoaPods/Specs.git' +source 'https://github.com/ololabs/podspecs.git' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + pod 'OloPaySDK', :git => 'https://github.com/ololabs/olo-pay-ios-sdk-releases.git', :tag => '4.0.2' + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..da67f17 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,764 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1B1320B52BA4BE32006A4F99 /* CustomErrorMessagesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1320B42BA4BE32006A4F99 /* CustomErrorMessagesTests.swift */; }; + 331C808B294A63AB00263BE5 /* OPCardFieldExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* OPCardFieldExtensionTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 8EA356746766E42F7DD42F16 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FB785135715C3F4F656672AC /* Pods_RunnerTests.framework */; }; + 94EC06046F783FABCC8BEBDD /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25A56CFE8AAE39E9ACC79584 /* Pods_Runner.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D06BBA7C2B72FB4300D34C43 /* OloPaySDKPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA7B2B72FB4300D34C43 /* OloPaySDKPluginTests.swift */; }; + D06BBA7E2B74383900D34C43 /* OPCardFieldStateProtocolExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA7D2B74383900D34C43 /* OPCardFieldStateProtocolExtensionTests.swift */; }; + D06BBA802B74428100D34C43 /* DictionaryExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA7F2B74428100D34C43 /* DictionaryExtensionTests.swift */; }; + D06BBA822B744CA500D34C43 /* OPPaymentMethodProtocolExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA812B744CA500D34C43 /* OPPaymentMethodProtocolExtensionTests.swift */; }; + D06BBA842B75169F00D34C43 /* ErrorHandlingHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA832B75169F00D34C43 /* ErrorHandlingHelpersTests.swift */; }; + D06BBA8A2B75193600D34C43 /* OPCardErrorTypeExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA892B75193600D34C43 /* OPCardErrorTypeExtensionTests.swift */; }; + D06BBA8C2B751CAA00D34C43 /* OPErrorExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BBA8B2B751CAA00D34C43 /* OPErrorExtensionTests.swift */; }; + D07E9FFA2BDFE40D00BD41CC /* FlutterDictionaryExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07E9FF92BDFE40D00BD41CC /* FlutterDictionaryExtensionTests.swift */; }; + D0B5ED652B9B652C00090BB6 /* StringExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B5ED642B9B652C00090BB6 /* StringExtensionTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1B1320B42BA4BE32006A4F99 /* CustomErrorMessagesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomErrorMessagesTests.swift; sourceTree = ""; }; + 25A56CFE8AAE39E9ACC79584 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* OPCardFieldExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPCardFieldExtensionTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 71A81752D4934089389EEA44 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 89F61786AC7ECA2072DF86D5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 981EF1D16E30D1C65DFE1655 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + D06BBA7B2B72FB4300D34C43 /* OloPaySDKPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OloPaySDKPluginTests.swift; sourceTree = ""; }; + D06BBA7D2B74383900D34C43 /* OPCardFieldStateProtocolExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPCardFieldStateProtocolExtensionTests.swift; sourceTree = ""; }; + D06BBA7F2B74428100D34C43 /* DictionaryExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictionaryExtensionTests.swift; sourceTree = ""; }; + D06BBA812B744CA500D34C43 /* OPPaymentMethodProtocolExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPPaymentMethodProtocolExtensionTests.swift; sourceTree = ""; }; + D06BBA832B75169F00D34C43 /* ErrorHandlingHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandlingHelpersTests.swift; sourceTree = ""; }; + D06BBA892B75193600D34C43 /* OPCardErrorTypeExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPCardErrorTypeExtensionTests.swift; sourceTree = ""; }; + D06BBA8B2B751CAA00D34C43 /* OPErrorExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPErrorExtensionTests.swift; sourceTree = ""; }; + D07E9FF92BDFE40D00BD41CC /* FlutterDictionaryExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlutterDictionaryExtensionTests.swift; sourceTree = ""; }; + D0B5ED642B9B652C00090BB6 /* StringExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensionTests.swift; sourceTree = ""; }; + D3CCDA17817BCEF2B18C121B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F5DAFBA36F0A000F8067C691 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + FB785135715C3F4F656672AC /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FF8B0F46C491EBF9F2BC4F97 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4E1904523A3C0C8AE0E2A8F6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8EA356746766E42F7DD42F16 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 94EC06046F783FABCC8BEBDD /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + D07E9FF92BDFE40D00BD41CC /* FlutterDictionaryExtensionTests.swift */, + D0B5ED642B9B652C00090BB6 /* StringExtensionTests.swift */, + D06BBA8B2B751CAA00D34C43 /* OPErrorExtensionTests.swift */, + D06BBA892B75193600D34C43 /* OPCardErrorTypeExtensionTests.swift */, + D06BBA832B75169F00D34C43 /* ErrorHandlingHelpersTests.swift */, + D06BBA812B744CA500D34C43 /* OPPaymentMethodProtocolExtensionTests.swift */, + D06BBA7F2B74428100D34C43 /* DictionaryExtensionTests.swift */, + D06BBA7D2B74383900D34C43 /* OPCardFieldStateProtocolExtensionTests.swift */, + D06BBA7B2B72FB4300D34C43 /* OloPaySDKPluginTests.swift */, + 331C807B294A618700263BE5 /* OPCardFieldExtensionTests.swift */, + 1B1320B42BA4BE32006A4F99 /* CustomErrorMessagesTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 4F28D730A13879FB410BF368 /* Pods */ = { + isa = PBXGroup; + children = ( + FF8B0F46C491EBF9F2BC4F97 /* Pods-Runner.debug.xcconfig */, + 71A81752D4934089389EEA44 /* Pods-Runner.release.xcconfig */, + 89F61786AC7ECA2072DF86D5 /* Pods-Runner.profile.xcconfig */, + 981EF1D16E30D1C65DFE1655 /* Pods-RunnerTests.debug.xcconfig */, + D3CCDA17817BCEF2B18C121B /* Pods-RunnerTests.release.xcconfig */, + F5DAFBA36F0A000F8067C691 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 4F28D730A13879FB410BF368 /* Pods */, + A9D55B455EB3660326CD2727 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + A9D55B455EB3660326CD2727 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 25A56CFE8AAE39E9ACC79584 /* Pods_Runner.framework */, + FB785135715C3F4F656672AC /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + E5E3DA08EF090952FD2543B4 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 4E1904523A3C0C8AE0E2A8F6 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + EC2BF1DB585A12B48F94E673 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 623470463110C78AD7F85A06 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 623470463110C78AD7F85A06 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + E5E3DA08EF090952FD2543B4 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EC2BF1DB585A12B48F94E673 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* OPCardFieldExtensionTests.swift in Sources */, + D06BBA842B75169F00D34C43 /* ErrorHandlingHelpersTests.swift in Sources */, + D0B5ED652B9B652C00090BB6 /* StringExtensionTests.swift in Sources */, + D07E9FFA2BDFE40D00BD41CC /* FlutterDictionaryExtensionTests.swift in Sources */, + D06BBA7E2B74383900D34C43 /* OPCardFieldStateProtocolExtensionTests.swift in Sources */, + D06BBA822B744CA500D34C43 /* OPPaymentMethodProtocolExtensionTests.swift in Sources */, + D06BBA7C2B72FB4300D34C43 /* OloPaySDKPluginTests.swift in Sources */, + D06BBA8A2B75193600D34C43 /* OPCardErrorTypeExtensionTests.swift in Sources */, + 1B1320B52BA4BE32006A4F99 /* CustomErrorMessagesTests.swift in Sources */, + D06BBA802B74428100D34C43 /* DictionaryExtensionTests.swift in Sources */, + D06BBA8C2B751CAA00D34C43 /* OPErrorExtensionTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5247FXNRAV; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.olo.flutter.oloPaySdkExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 981EF1D16E30D1C65DFE1655 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.olo.flutter.oloPaySdkExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D3CCDA17817BCEF2B18C121B /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.olo.flutter.oloPaySdkExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F5DAFBA36F0A000F8067C691 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.olo.flutter.oloPaySdkExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5247FXNRAV; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.olo.flutter.oloPaySdkExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 5247FXNRAV; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.olo.flutter.oloPaySdkExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..3439c08 --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6d71004 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..08dbc27 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/GeneratedPluginRegistrant.h b/example/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 0000000..7a89092 --- /dev/null +++ b/example/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/example/ios/Runner/GeneratedPluginRegistrant.m b/example/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 0000000..2339c37 --- /dev/null +++ b/example/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,35 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import integration_test; +#endif + +#if __has_include() +#import +#else +@import olo_pay_sdk; +#endif + +#if __has_include() +#import +#else +@import pay_ios; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [IntegrationTestPlugin registerWithRegistrar:[registry registrarForPlugin:@"IntegrationTestPlugin"]]; + [OloPaySdkPlugin registerWithRegistrar:[registry registrarForPlugin:@"OloPaySdkPlugin"]]; + [PayPlugin registerWithRegistrar:[registry registrarForPlugin:@"PayPlugin"]]; +} + +@end diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..1ee60e5 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Olo Pay Sdk + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + olo_pay_sdk_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..d6d5337 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1,3 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/RunnerTests/CustomErrorMessagesTests.swift b/example/ios/RunnerTests/CustomErrorMessagesTests.swift new file mode 100644 index 0000000..8e48312 --- /dev/null +++ b/example/ios/RunnerTests/CustomErrorMessagesTests.swift @@ -0,0 +1,1119 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// CustomErrorMessagesTests.swift +// RunnerTests +// +// Created by Richard Dowdy on 3/15/24. +// + +import Foundation +import XCTest +@testable import olo_pay_sdk +@testable import OloPaySDK + +final class CustomErrorMessagesTests: XCTestCase { + + let invalidFieldState = MockCardFieldState( + isValid: false, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ) + + let emptyFieldState = MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: false, + isFirstResponder: false, + wasFirstResponder: false + ) + + let validFieldState = MockCardFieldState() + + func testGetDefaultErrorMessageForCardNumberField_cardBrandNil_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(for: .number, with: emptyFieldState, false, nil) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(for: .number, with: invalidFieldState, false) + + XCTAssertEqual(OPStrings.emptyCardNumberError, emptyMessage) + XCTAssertEqual(OPStrings.invalidCardNumberError, invalidMessage) + } + + func testGetDefaultErrorMessageForCvvField_cardBrandNil_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(for: .cvv, with: emptyFieldState, false) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(for: .cvv, with: invalidFieldState, false, nil) + + XCTAssertEqual(OPStrings.emptyCvvError, emptyMessage) + XCTAssertEqual(OPStrings.invalidCvvError, invalidMessage) + } + + func testGetDefaultErrorMessage_cardNumberField_errorFieldState_withValidCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.number: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(OPStrings.emptyCardNumberError, emptyMessage) + XCTAssertEqual(OPStrings.invalidCardNumberError, invalidMessage) + } + + func testGetDefaultErrorMessage_cardNumberField_errorFieldState_withUnsupportedCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.number: emptyFieldState], OPCardBrand.unsupported) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual(OPStrings.emptyCardNumberError, emptyMessage) + XCTAssertEqual(OPStrings.unsupportedCardError, invalidMessage) + } + + func testGetDefaultErrorMessage_cardNumberField_validFieldState_withValidCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.number: validFieldState], OPCardBrand.visa) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_cardNumberField_validFieldState_withUnsupportedCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.number: validFieldState], OPCardBrand.unsupported) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_expirationField_errorFieldState_withValidCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.expiration: emptyFieldState], OPCardBrand.discover) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.expiration: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(OPStrings.emptyExpirationError, emptyMessage) + XCTAssertEqual(OPStrings.invalidExpirationError, invalidMessage) + } + + func testGetDefaultErrorMessage_expirationField_errorFieldState_withUnsupportedCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.expiration: emptyFieldState], OPCardBrand.unsupported) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.expiration: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual(OPStrings.emptyExpirationError, emptyMessage) + XCTAssertEqual(OPStrings.invalidExpirationError, invalidMessage) + } + + func testGetDefaultErrorMessage_expirationField_validFieldState_withValidCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.expiration: validFieldState], OPCardBrand.visa) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_expirationField_validFieldState_withUnsupportedCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.expiration: validFieldState], OPCardBrand.unsupported) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_cvvField_errorFieldState_withValidCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.cvv: emptyFieldState], OPCardBrand.amex) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.cvv: invalidFieldState], OPCardBrand.amex) + + XCTAssertEqual(OPStrings.emptyCvvError, emptyMessage) + XCTAssertEqual(OPStrings.invalidCvvError, invalidMessage) + } + + func testGetDefaultErrorMessage_cvvField_errorFieldState_withUnsupportedCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.cvv: emptyFieldState], OPCardBrand.unsupported) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.cvv: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual(OPStrings.emptyCvvError, emptyMessage) + XCTAssertEqual(OPStrings.invalidCvvError, invalidMessage) + } + + func testGetDefaultErrorMessage_cvvField_validFieldState_withValidCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.cvv: validFieldState], OPCardBrand.visa) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_cvvField_validFieldState_withUnsupportedCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.cvv: validFieldState], OPCardBrand.unsupported) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_postalCodeField_errorFieldState_withValidCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.postalCode: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.postalCode: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(OPStrings.emptyPostalCodeError, emptyMessage) + XCTAssertEqual(OPStrings.invalidPostalCodeError, invalidMessage) + } + + func testGetDefaultErrorMessage_postalCodeField_errorFieldState_withUnsupportedCardBrand_returnsDefaultErrorMessage() { + let emptyMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.postalCode: emptyFieldState], OPCardBrand.unsupported) + + let invalidMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.postalCode: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual(OPStrings.emptyPostalCodeError, emptyMessage) + XCTAssertEqual(OPStrings.invalidPostalCodeError, invalidMessage) + } + + func testGetDefaultErrorMessage_postalCodeField_validFieldState_withValidCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.postalCode: validFieldState], OPCardBrand.visa) + + XCTAssertEqual("", errorMessage) + } + + func testGetDefaultErrorMessage_postalCodeField_validFieldState_withUnsupportedCardBrand_returnsEmptyString() { + let errorMessage = CustomErrorMessages.getDefaultErrorMessage(false, [OPCardField.postalCode: validFieldState], OPCardBrand.unsupported) + + XCTAssertEqual("", errorMessage) + } + + func testGetCustomErrorMessage_expirationField_cardBrandNil_withCustomErrorMessagesDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(for: .expiration, with: emptyFieldState, false, nil) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(for: .expiration, with: invalidFieldState, false) + + XCTAssertEqual("Empty expiration", emptyMessage) + XCTAssertEqual("Invalid expiration", invalidMessage) + } + + func testGetCustomErrorMessage_cvvField_cardBrandNil_withCustomErrorMessagesDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(for: .cvv, with: emptyFieldState, false) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(for: .cvv, with: invalidFieldState, false, nil) + + XCTAssertEqual("Empty cvv", emptyMessage) + XCTAssertEqual("Invalid cvv", invalidMessage) + } + + func testGetCustomErrorMessage_cardNumberField_withCustomErrorMessagesDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("Empty card", emptyMessage) + XCTAssertEqual("Invalid card", invalidMessage) + } + + func testGetCustomErrorMessage_cardNumberField_missingCustomErrorMessages_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + // DataKeys.emptyErrorKey: "Empty card", removed for testing purposes + // DataKeys.invalidErrorKey: "Invalid card", removed for testing purposes + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_cardNumberField_customErrorMessagesNil_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: nil, + DataKeys.invalidErrorKey: nil, + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_cardNumberField_customErrorMessagesEmpty_returnsEmptyString() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "", + DataKeys.invalidErrorKey: "", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("", emptyMessage) + XCTAssertEqual("", invalidMessage) + } + + func testGetCustomErrorMessage_unsupportedCardBrand_withCustomErrorMessageDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invali card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let message = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual("Unsupported card", message) + } + + func testGetCustomErrorMessage_unsupportedCardBrand_missingCustomErrorMessage_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invali card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ] + // DataKeys.unsupportedCardErrorKey: "Unsupported card", removed for testing purposes + ] + ) + + let message = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual(nil, message) + } + + func testGetCustomErrorMessage_unsupportedCardBrand_customErrorMessageNil_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invali card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: NSNull() + ] + ) + + let message = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual(nil, message) + } + + func testGetCustomErrorMessage_unsupportedCardBrand_emptyCustomErrorMessage_returnsEmptyString() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invali card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "" + ] + ) + + let message = customErrorMessages.getCustomErrorMessage(false, [OPCardField.number: invalidFieldState], OPCardBrand.unsupported) + + XCTAssertEqual("", message) + } + + func testGetCustomErrorMessage_expirationField_withCustomErrorMessagesDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("Empty expiration", emptyMessage) + XCTAssertEqual("Invalid expiration", invalidMessage) + } + + func testGetCustomErrorMessage_expirationField_missingCustomErrorMessages_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + // DataKeys.emptyErrorKey: "Empty expiration", removed for testing purposes + // DataKeys.invalidErrorKey: "Invalid expiration", removed for testing purposes + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_expirationField_customErrorMessagesNil_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: nil, + DataKeys.invalidErrorKey: nil, + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_expirationField_emptyCustomErrorMessages_returnsEmptyString() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "", + DataKeys.invalidErrorKey: "", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.expiration: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("", emptyMessage) + XCTAssertEqual("", invalidMessage) + } + + func testGetCustomErrorMessage_cvvField_withCustomErrorMessagesDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("Empty cvv", emptyMessage) + XCTAssertEqual("Invalid cvv", invalidMessage) + } + + func testGetCustomErrorMessage_cvvField_missingCustomErrorMessages_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + // DataKeys.emptyErrorKey: "Empty cvv", removed for testing purposes + // DataKeys.invalidErrorKey: "Invalid cvv", removed for testing purposes + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_cvvField_customErrorMessagesNil_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: nil, + DataKeys.invalidErrorKey: nil, + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_cvvField_emptyCustomErrorMessages_returnsEmptyString() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "", + DataKeys.invalidErrorKey: "", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.cvv: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("", emptyMessage) + XCTAssertEqual("", invalidMessage) + } + + func testGetCustomErrorMessage_postalCodeField_withCustomErrorMessagesDefined_returnsCustomErrorMessage() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("Empty postal code", emptyMessage) + XCTAssertEqual("Invalid postal code", invalidMessage) + } + + func testGetCustomErrorMessage_postalCodeField_missingCustomErrorMessages_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + // DataKeys.emptyErrorKey: "Empty postal code", removed for testing purposes + // DataKeys.invalidErrorKey: "Invalid postal code", removed for testing purposes + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_postalCodeField_customErrorMessagesNil_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: nil, + DataKeys.invalidErrorKey: nil, + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual(nil, emptyMessage) + XCTAssertEqual(nil, invalidMessage) + } + + func testGetCustomErrorMessage_postalCodeField_emptyCustomErrorMessages_returnsEmptyString() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "", + DataKeys.invalidErrorKey: "", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let emptyMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: emptyFieldState], OPCardBrand.visa) + + let invalidMessage = customErrorMessages.getCustomErrorMessage(false, [OPCardField.postalCode: invalidFieldState], OPCardBrand.visa) + + XCTAssertEqual("", emptyMessage) + XCTAssertEqual("", invalidMessage) + } + + func testGetCustomErrorMessage_emptyCardFieldFieldMap_withCustomErrorMessageDefined_returnsEmptyString() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let message = customErrorMessages.getCustomErrorMessage(false, [:], OPCardBrand.visa) + + XCTAssertEqual(nil, message) + } + + func testGetCustomErrorMessage_emptyCardFieldFieldMap_returnsNil() { + let customErrorMessages = CustomErrorMessages(customErrorMessages: + [ + OPCardField.number.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty card", + DataKeys.invalidErrorKey: "Invalid card", + ], + OPCardField.expiration.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty expiration", + DataKeys.invalidErrorKey: "Invalid expiration", + ], + OPCardField.cvv.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty cvv", + DataKeys.invalidErrorKey: "Invalid cvv", + ], + OPCardField.postalCode.flutterBridgeValue(): [ + DataKeys.emptyErrorKey: "Empty postal code", + DataKeys.invalidErrorKey: "Invalid postal code", + ], + DataKeys.unsupportedCardErrorKey: "Unsupported card" + ] + ) + + let message = customErrorMessages.getCustomErrorMessage(false, [:], OPCardBrand.visa) + + + XCTAssertEqual(nil, message) + } + + func testGetErrorFields_ignoreUneditedFieldsTrue_cardNumberField_invalidWasEditedWasFirstResponder_returnsCardNumberField() { + let ignoreUneditedFields = true + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.expiration: validFieldState, + OPCardField.cvv: validFieldState, + OPCardField.postalCode: validFieldState, + ] + ) + + XCTAssertEqual(errorFields.count, 1) + XCTAssertTrue(errorFields[OPCardField.number] != nil) + } + + func testGetErrorFields_ignoreUneditedFieldsTrue_cvvAndPostalCodeFields_invalidWasEditedWasFocused_returnsCvvAndPostalCodeFields() { + let ignoreUneditedFields = true + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : MockCardFieldState( + isValid: false, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: false + ), + OPCardField.expiration: MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: false, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.cvv: MockCardFieldState( + isValid: false, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.postalCode: MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + ] + ) + + XCTAssertEqual(errorFields.count, 2) + XCTAssertTrue(errorFields[OPCardField.cvv] != nil) + XCTAssertTrue(errorFields[OPCardField.postalCode] != nil) + } + + func testGetErrorFields_ignoreUneditedFieldsTrue_allFields_invalidWasEditedWasFocused_returnsAllFields() { + let ignoreUneditedFields = true + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : MockCardFieldState( + isValid: false, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.expiration: MockCardFieldState( + isValid: false, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.cvv: MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.postalCode: MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + ] + ) + + XCTAssertEqual(errorFields.count, 4) + XCTAssertTrue(errorFields[OPCardField.number] != nil) + XCTAssertTrue(errorFields[OPCardField.expiration] != nil) + XCTAssertTrue(errorFields[OPCardField.cvv] != nil) + XCTAssertTrue(errorFields[OPCardField.postalCode] != nil) + } + + func testGetErrorFields_ignoreUneditedFieldsTrue_noFields_invalidWasEditedWasFocused_returnsNoFields() { + let ignoreUneditedFields = true + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : validFieldState, + OPCardField.expiration: MockCardFieldState( + isValid: true, + isEmpty: true, + wasEdited: false, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.cvv: MockCardFieldState( + isValid: true, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.postalCode: MockCardFieldState( + isValid: true, + isEmpty: true, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + ] + ) + + XCTAssertEqual(errorFields.count, 0) + XCTAssertTrue(errorFields.isEmpty) + } + + func testGetErrorFields_ignoreUneditedFieldsFalse_allValidFields_returnsEmptyMap() { + let ignoreUneditedFields = false + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : validFieldState, + OPCardField.expiration: validFieldState, + OPCardField.cvv: validFieldState, + OPCardField.postalCode: validFieldState, + ] + ) + + XCTAssertEqual(errorFields.count, 0) + XCTAssertTrue(errorFields.isEmpty) + } + + func testGetErrorFields_ignoreUneditedFieldsFalse_onlyNumberFieldInvalid_returnsOnlyNumberField() { + let ignoreUneditedFields = false + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : invalidFieldState, + OPCardField.expiration: validFieldState, + OPCardField.cvv: validFieldState, + OPCardField.postalCode: validFieldState, + ] + ) + + XCTAssertEqual(errorFields.count, 1) + XCTAssertTrue(errorFields[OPCardField.number] != nil) + } + + func testGetErrorFields_ignoreUneditedFieldsFalse_expirationAndCvvFieldsInvalid_returnsCorrectErrorFields() { + let ignoreUneditedFields = false + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : validFieldState, + OPCardField.expiration: invalidFieldState, + OPCardField.cvv: invalidFieldState, + OPCardField.postalCode: validFieldState, + ] + ) + + XCTAssertEqual(errorFields.count, 2) + XCTAssertTrue(errorFields[OPCardField.expiration] != nil) + XCTAssertTrue(errorFields[OPCardField.cvv] != nil) + } + + func testGetErrorFields_ignoreUneditedFieldsFalse_allFieldsInvalid_returnsAllErrorFields() { + let ignoreUneditedFields = false + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : invalidFieldState, + OPCardField.expiration: invalidFieldState, + OPCardField.cvv: invalidFieldState, + OPCardField.postalCode: MockCardFieldState( + isValid: false, + isEmpty: false, + wasEdited: true, + isFirstResponder: true, + wasFirstResponder: true + ), + ] + ) + + XCTAssertEqual(errorFields.count, 4) + XCTAssertTrue(errorFields[OPCardField.number] != nil) + XCTAssertTrue(errorFields[OPCardField.expiration] != nil) + XCTAssertTrue(errorFields[OPCardField.cvv] != nil) + XCTAssertTrue(errorFields[OPCardField.postalCode] != nil) + } + + + func testGetErrorFields_ignoreUneditedFieldsFalse_numberFieldInvalid_expirationWasEditedAndWasFocused_returnsOnlyCardField() { + let ignoreUneditedFields = false + + let errorFields = CustomErrorMessages.getErrorFields( + ignoreUneditedFields, + [ + OPCardField.number : invalidFieldState, + OPCardField.expiration: MockCardFieldState( + isValid: true, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ), + OPCardField.cvv: validFieldState, + OPCardField.postalCode: validFieldState, + ] + ) + + XCTAssertEqual(errorFields.count, 1) + XCTAssertTrue(errorFields[OPCardField.number] != nil) + } +} diff --git a/example/ios/RunnerTests/DictionaryExtensionTests.swift b/example/ios/RunnerTests/DictionaryExtensionTests.swift new file mode 100644 index 0000000..55d62f1 --- /dev/null +++ b/example/ios/RunnerTests/DictionaryExtensionTests.swift @@ -0,0 +1,204 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// DictionaryExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/7/24. +// + +import XCTest + +@testable import olo_pay_sdk +import OloPaySDK + +final class DictionaryExtensionTests: XCTestCase { + func testGet_keyExists_valueIsCorrectType_returnsValue() { + let dictionary = [ "Foo" : "Bar" ] + let value: String? = dictionary.get("Foo") + XCTAssertEqual(value, "Bar") + } + + func testGet_keyExists_valueNotCorrectType_returnsNil() { + let dictionary = [ "Foo" : 2 ] + let value: String? = dictionary.get("Foo") + XCTAssertNil(value) + } + + func testGet_keyMissing_returnsNil() { + let dictionary = [ "Foo" : "Bar" ] + let value: String? = dictionary.get("Bar") + XCTAssertNil(value) + } + + func testGetOrThrow_keyMissing_throwsMissingKeyError() { + var correctErrorThrown = false + + let dictionary = [ "Foo" : 2.3 ] + do { + let _: Double = try dictionary.getOrThrow("Bar") + XCTFail("Error not thrown") + } catch OloError.MissingKeyError { + correctErrorThrown = true + } catch { + correctErrorThrown = false + } + + XCTAssertTrue(correctErrorThrown) + } + + // NSNull is how Flutter represents null values sent across the bridge + func testGetOrThrow_keyExists_valueIsNSNull_throwsNullValueError() { + var correctErrorThrown = false + + let dictionary = [ "Foo" : NSNull() ] as [String : Any?] + do { + let _: String = try dictionary.getOrThrow("Foo") + XCTFail("Error not thrown") + } catch OloError.NullValueError { + correctErrorThrown = true + } catch { + correctErrorThrown = false + } + + XCTAssertTrue(correctErrorThrown) + } + + func testGetOrThrow_keyExists_valueIsIncorrectType_throwsUnexpectedTypeError() { + var correctErrorThrown = false + + let dictionary = [ "Foo" : 123 ] as [String : Any?] + do { + let _: String = try dictionary.getOrThrow("Foo") + XCTFail("Error not thrown") + } catch OloError.UnexpectedTypeError { + correctErrorThrown = true + } catch { + correctErrorThrown = false + } + + XCTAssertTrue(correctErrorThrown) + } + + func testGetOrThrow_keyExists_valueIsCorrectType_returnsArgValue() { + let dictionary = [ "Foo" : 123 ] as [String : Any?] + do { + let value: Int = try dictionary.getOrThrow("Foo") + XCTAssertEqual(123, value) + } catch { + XCTFail("Error should not be thrown") + } + } + + func testGetOrThrow_withDefaultValue_keyMissing_returnsDefaultValue() { + let dictionary = [ "Foo" : 2.3 ] + do { + let value: Double = try dictionary.getOrThrow("Bar", defaultValue: 12.3) + XCTAssertEqual(12.3, value) + } catch { + XCTFail("Error should not be thrown") + } + } + + // NSNull is how Flutter represents null values sent across the bridge + func testGetOrThrow_withDefaultValue_keyExists_valueIsNSNull_returnsDefaultValue() { + let dictionary = [ "Foo" : NSNull() ] as [String : Any?] + do { + let value: String = try dictionary.getOrThrow("Foo", defaultValue: "Bar") + XCTAssertEqual("Bar", value) + } catch { + XCTFail("Error should not be thrown") + } + } + + func testGetOrThrow_withDefaultValue_keyExists_valueIsIncorrectType_throwsUnexpectedTypeError() { + var correctErrorThrown = false + + let dictionary = [ "Foo" : 123 ] as [String : Any?] + do { + let _: String = try dictionary.getOrThrow("Foo", defaultValue: "Bar") + XCTFail("Error not thrown") + } catch OloError.UnexpectedTypeError { + correctErrorThrown = true + } catch { + correctErrorThrown = false + } + + XCTAssertTrue(correctErrorThrown) + } + + func testGetOrThrow_withDefaultValue_keyExists_valueIsCorrectType_returnsArgValue() { + let dictionary = [ "Foo" : 123 ] as [String : Any?] + do { + let value: Int = try dictionary.getOrThrow("Foo", defaultValue: 2) + XCTAssertEqual(123, value) + } catch { + XCTFail("Error should not be thrown") + } + } + + func testGetDictionary_keyExists_valueIsDictionary_returnsValue() { + let dictionary = [ "Foo" : + [ "Bar" : 1 ] + ] + + let value = dictionary.getDictionary("Foo") + XCTAssertNotNil(value) + XCTAssertEqual((value!["Bar"] as! Int), 1) + } + + func testGetDictionary_keyExists_valueNotDictionary_returnsNil() { + let dictionary = [ "Foo" : 2 ] + XCTAssertNil(dictionary.getDictionary("Foo")) + } + + func testGetDictionary_keyMissing_returnsNil() { + let dictionary = [ "Foo" : "Bar" ] + XCTAssertNil(dictionary.getDictionary("Bar")) + } + + func testGetArray_keyExists_valueIsArray_returnsValue() { + let dictionary = [ "Foo" : + [ 1, 2, 3 ] + ] + + let value = dictionary.getArray("Foo") + XCTAssertNotNil(value) + XCTAssertEqual(value![0] as! Int, 1) + } + + func testGetArray_keyExists_valueNotArray_returnsNil() { + let dictionary = [ "Foo" : "Bar" ] + XCTAssertNil(dictionary.getArray("Bar")) + } + + func testGetArray_keyMissing_returnsNil() { + let dictionary = [ "Foo2" : + [ 1, 2, 3 ] + ] + + XCTAssertNil(dictionary.getArray("Foo")) + } + + func testFieldStateDictionary_ToDictionary_hasCorrectLength() { + XCTAssertEqual(getFieldStatesDictionary().toDictionary().count, 4) + } + + func testFieldStateDictionary_ToDictionary_hasCorrectKeys() { + let dictionary = getFieldStatesDictionary().toDictionary() + + XCTAssertNotNil(dictionary["CardNumber"]) + XCTAssertNotNil(dictionary["Expiration"]) + XCTAssertNotNil(dictionary["Cvv"]) + XCTAssertNotNil(dictionary["PostalCode"]) + } + + func getFieldStatesDictionary() -> [OPCardField : OPCardFieldStateProtocol] { + return [ + OPCardField.number : MockCardFieldState(), + OPCardField.expiration : MockCardFieldState(), + OPCardField.cvv : MockCardFieldState(), + OPCardField.postalCode: MockCardFieldState() + ] + } +} diff --git a/example/ios/RunnerTests/ErrorHandlingHelpersTests.swift b/example/ios/RunnerTests/ErrorHandlingHelpersTests.swift new file mode 100644 index 0000000..51951cc --- /dev/null +++ b/example/ios/RunnerTests/ErrorHandlingHelpersTests.swift @@ -0,0 +1,161 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// ErrorHandlingHelpersTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/8/24. +// + +import XCTest +@testable import olo_pay_sdk +import OloPaySDK +import Flutter + +final class ErrorHandlingHelpersTests: XCTestCase { + // IMPORTANT: Using expectations makes debugging difficult due to the + // waitForExpectationsCall() call. When debugging, consider setting + // the timeout value to something extraordinarily large to allow you + // time to step through the code. + let expectationTimeout: TimeInterval = 2 + + func testGetErrorMessage_errorHaslocalizedDescription_localizedDescriptionReturned() { + let error = OPError(errorType: .generalError, description: "Foo bar") + XCTAssertEqual(getErrorMessage(error: error, defaultMessage: "Hi"), "Foo bar") + } + + func testGetErrorMessage_errorMissingLocalizedDescription_defaultMessageReturned() { + let error = OPError(errorType: .generalError, description: "") + XCTAssertEqual(getErrorMessage(error: error, defaultMessage: "Hi"), "Hi") + } + + func testRejectError_withOPError_flutterResultCalled_withErrorMessage() { + let expectation = expectation(description: "result must be called") + let result: FlutterResult = { data in + guard let flutterError = self.assertError(data) else { + expectation.fulfill() + return + } + + XCTAssertEqual(flutterError.code, "generalError") + XCTAssertEqual(flutterError.message, "Test error message") + expectation.fulfill() + } + + let error = OPError(errorType: .generalError, description: "Test error message") + rejectError(error: error, result: result) + waitForExpectations(timeout: expectationTimeout) + } + + func testRejectError_withOPError_flutterResultCalled_withDefaultMessage() { + let expectation = expectation(description: "result must be called") + let result: FlutterResult = { data in + guard let flutterError = self.assertError(data) else { + expectation.fulfill() + return + } + + XCTAssertEqual(flutterError.code, "ApiError") + XCTAssertEqual(flutterError.message, "Unexpected error occurred") + expectation.fulfill() + } + + let error = OPError(errorType: .apiError, description: "") + rejectError(error: error, result: result) + waitForExpectations(timeout: expectationTimeout) + } + + func testRejectError_withOPErrorAsSwiftError_flutterResultCalled_withErrorMessage() { + let expectation = expectation(description: "result must be called") + let result: FlutterResult = { data in + guard let flutterError = self.assertError(data) else { + expectation.fulfill() + return + } + + XCTAssertEqual(flutterError.code, "generalError") + XCTAssertEqual(flutterError.message, "Test error message") + expectation.fulfill() + } + + let error: Error = OPError(errorType: .generalError, description: "Test error message") + rejectError(error: error, result: result) + waitForExpectations(timeout: expectationTimeout) + } + + func testRejectError_withOPErrorAsSwiftError_flutterResultCalled_withDefaultMessage() { + let expectation = expectation(description: "result must be called") + let result: FlutterResult = { data in + guard let flutterError = self.assertError(data) else { + expectation.fulfill() + return + } + + XCTAssertEqual(flutterError.code, "ApiError") + XCTAssertEqual(flutterError.message, "Unexpected error occurred") + expectation.fulfill() + } + + let error: Error = OPError(errorType: .apiError, description: "") + rejectError(error: error, result: result) + waitForExpectations(timeout: expectationTimeout) + } + + func testRejectError_withSwiftError_flutterResultCalled_withErrorMessage() { + let expectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + guard let flutterError = self.assertError(data) else { + expectation.fulfill() + return + } + + XCTAssertEqual(flutterError.code, "generalError") + XCTAssertEqual(flutterError.message, "Test error message") + expectation.fulfill() + } + + rejectError(error: MockError(message: "Test error message"), result: result) + waitForExpectations(timeout: expectationTimeout) + } + + func testRejectError_withSwiftError_flutterResultCalled_withDefaultErrorMessage() { + let expectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + guard let flutterError = self.assertError(data) else { + expectation.fulfill() + return + } + + XCTAssertEqual(flutterError.code, "generalError") + XCTAssertEqual(flutterError.message, "Unexpected error occurred") + expectation.fulfill() + } + + rejectError(error: MockError(message: ""), result: result) + waitForExpectations(timeout: expectationTimeout) + } + + private func assertError(_ result: Any?) -> FlutterError? { + guard let flutterError = result as? FlutterError else { + XCTFail("Result should be an error") + return nil + } + + return flutterError + } + + public class MockError : NSError { + init(message: String) { + var userInfo: [String : Any] = [:] + userInfo[NSLocalizedDescriptionKey] = message + + super.init(domain: "testdomain", code: 1, userInfo: userInfo) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + } +} diff --git a/example/ios/RunnerTests/FlutterDictionaryExtensionTests.swift b/example/ios/RunnerTests/FlutterDictionaryExtensionTests.swift new file mode 100644 index 0000000..862fd5e --- /dev/null +++ b/example/ios/RunnerTests/FlutterDictionaryExtensionTests.swift @@ -0,0 +1,701 @@ +// +// FlutterDictionaryExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 4/29/24. +// + +import XCTest +import Flutter +@testable import olo_pay_sdk + +final class FlutterDictionaryExtensionTests: XCTestCase { + // IMPORTANT: Using expectations makes debugging difficult due to the + // waitForExpectationsCall() call. When debugging and stepping through + // code, it may be necessary to set the timeout value to something + // much larger to allow time to step through the code. + // + // NOTE: The value of 30 may seem excessive when not debugging, but + // was reached through trial and error. Values less than this lead + // to flaky test results in Github Actions + let expectationTimeout: TimeInterval = 30 + + // MARK: Non-Null Dictionary Extension Tests + func testGetOrErrorResult_argsMissingKey_returnsMissingParameter_throwsMissingKeyError() { + let arguments = ["foo": "1.2.3"] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getOrErrorResult( + for: "bar", + baseError: "Test" + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Missing parameter 'bar'" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.MissingKeyError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetOrErrorResult_argValueIsNSNull_returnsMissingParameter_throwsNullValueError() { + let arguments = ["foo": NSNull()] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getOrErrorResult( + for: "foo", + baseError: "Test" + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Missing parameter 'foo'" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.NullValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetOrErrorResult_argValueIncorrectType_returnsUnexpectedParameterType_throwsUnexpectedTypeError() { + let arguments = ["foo": true] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getOrErrorResult( + for: "foo", + baseError: "Test" + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Value for 'foo' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + } catch OloError.UnexpectedTypeError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetOrErrorResult_argValueIsCorrectType_returnsArgValue() { + let arguments = ["foo": "bar"] + + do { + let strValue: String = try arguments.getOrErrorResult( + for: "foo", + baseError: "Test" + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("bar", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetOrErrorResult_withDefaultValue_argsMissingKey_returnsDefaultValue() { + let arguments = ["foo": "1.2.3"] + + do { + let strValue: String = try arguments.getOrErrorResult( + for: "bar", + withDefault: "default", + baseError: "Test" + ) { result in + XCTFail("Result should not be called") + } + + XCTAssertEqual("default", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetOrErrorResult_withDefaultValue_argValueIsNSNull_returnsDefaultValue() { + let arguments = ["foo": NSNull()] + + do { + let strValue: String = try arguments.getOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test" + ) { result in + XCTFail("Result should not be called") + } + + XCTAssertEqual("default", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetOrErrorResult_withDefaultValue_argValueIncorrectType_returnsUnexpectedParameterType_throwsUnexpectedTypeError() { + let arguments = ["foo": true] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test" + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Value for 'foo' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + } catch OloError.UnexpectedTypeError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetOrErrorResult_withDefaultValue_argValueIsCorrectType_returnsArgValue() { + let arguments = ["foo": "bar"] + + do { + let strValue: String = try arguments.getOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test" + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("bar", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_argsMissingKey_returnsMissingParameter_throwsMissingKeyError() { + let arguments = ["foo": "1.2.3"] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "bar", + baseError: "Test", + acceptEmptyValue: true + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Missing parameter 'bar'" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.MissingKeyError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_argValueIsNSNull_returnsMissingParameter_throwsNullValueError() { + let arguments = ["foo": NSNull()] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "foo", + baseError: "Test", + acceptEmptyValue: true + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Missing parameter 'foo'" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.NullValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_argValueNotString_returnsUnexpectedParameterType_throwsUnexpectedTypeError() { + let arguments = ["foo": true] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "foo", + baseError: "Test", + acceptEmptyValue: true + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Value for 'foo' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + } catch OloError.UnexpectedTypeError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_emptyValueNotAccepted_argValueIsEmpty_returnsInvalidParameter_throwsEmptyValueError() { + let arguments = ["foo": ""] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "foo", + baseError: "Test", + acceptEmptyValue: false + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Value for 'foo' cannot be empty" + ) + XCTAssertEqual(flutterError.code, "InvalidParameter") + methodCallExpectation.fulfill() + } + } catch OloError.EmptyValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_emptyValueNotAccepted_argValueNotEmpty_returnsArgValue() { + let arguments = ["foo": "bar"] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + baseError: "Test", + acceptEmptyValue: false + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("bar", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_emptyValueAccepted_argValueIsEmpty_returnsEmptyString() { + let arguments = ["foo": ""] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + baseError: "Test", + acceptEmptyValue: true + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_emptyValueAccepted_argValueNotEmpty_returnsArgValue() { + let arguments = ["foo": "bar"] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + baseError: "Test", + acceptEmptyValue: true + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("bar", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_withDefaultValue_argsMissingKey_returnsDefaultValue() { + let arguments = ["foo": "1.2.3"] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "bar", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: true + ) { result in + XCTFail("Result should not be called") + } + + XCTAssertEqual("default", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_withDefaultValue_argValueIsNSNull_returnsDefaultValue() { + let arguments = ["foo": NSNull()] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: true + ) { result in + XCTFail("Result should not be called") + } + + XCTAssertEqual("default", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_withDefaultValue_argValueNotString_returnsUnexpectedParameterType_throwsUnexpectedTypeError() { + let arguments = ["foo": true] + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: true + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Value for 'foo' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + } catch OloError.UnexpectedTypeError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_withDefaultValue_emptyValueNotAccepted_argValueEmpty_returnsDefaultValue() { + let arguments = ["foo": ""] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: false + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("default", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_withDefaultValue_emptyValueNotAccepted_argValueNotEmpty_returnsArgValue() { + let arguments = ["foo": "bar"] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: false + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("bar", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_withDefaultValue_emptyValueAccepted_argValueEmpty_returnsEmptyValue() { + let arguments = ["foo": ""] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: true + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + func testGetStringOrErrorResult_withDefaultValue_emptyValueAccepted_argValueNotEmpty_returnsArgValue() { + let arguments = ["foo": "bar"] + + do { + let strValue: String = try arguments.getStringOrErrorResult( + for: "foo", + withDefault: "default", + baseError: "Test", + acceptEmptyValue: true + ) { result in + XCTFail("Result should not be called") + } + XCTAssertEqual("bar", strValue) + } catch { + XCTFail("Exception should not be thrown") + } + } + + private func assertError(_ result: Any?) -> FlutterError? { + guard let flutterError = result as? FlutterError else { + XCTFail("Result should be an error") + return nil + } + + return flutterError + } + + // MARK: Nullable Dictionary Extension Tests + func testGetOrErrorResult_nullDictionary_throwsNullValueError() { + let arguments: Dictionary? = nil + let methodCallExpectation = expectation(description: "result must be called") + var correctExceptionThrown = false + + do { + let _: String = try arguments.getOrErrorResult( + for: "bar", + baseError: "Test" + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Arguments dictionary is nil" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.NullValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetOrErrorResult_withDefaultValue_nullDictionary_throwsNullValueError() { + let arguments: Dictionary? = nil + let methodCallExpectation = expectation(description: "result must be called") + var correctExceptionThrown = false + + do { + let _: String = try arguments.getOrErrorResult( + for: "bar", + withDefault: "foo", + baseError: "Test" + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Arguments dictionary is nil" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.NullValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_nullDictionary_throwsNullValueError() { + let arguments: Dictionary? = nil + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "bar", + baseError: "Test", + acceptEmptyValue: true + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Arguments dictionary is nil" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.NullValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } + + func testGetStringOrErrorResult_withDefaultValue_nullDictionary_throwsNullValueError() { + let arguments: Dictionary? = nil + let methodCallExpectation = expectation(description: "result must be called") + + var correctExceptionThrown = false + + do { + let _: String = try arguments.getStringOrErrorResult( + for: "bar", + withDefault: "foo", + baseError: "Test", + acceptEmptyValue: true + ) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Test: Arguments dictionary is nil" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + } catch OloError.NullValueError { + correctExceptionThrown = true + } catch { + correctExceptionThrown = false + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + XCTAssertTrue(correctExceptionThrown) + } +} diff --git a/example/ios/RunnerTests/OPCardErrorTypeExtensionTests.swift b/example/ios/RunnerTests/OPCardErrorTypeExtensionTests.swift new file mode 100644 index 0000000..641be76 --- /dev/null +++ b/example/ios/RunnerTests/OPCardErrorTypeExtensionTests.swift @@ -0,0 +1,50 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPCardErrorTypeExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/8/24. +// + +import XCTest +import OloPaySDK +@testable import olo_pay_sdk + +final class OPCardErrorTypeExtensionTests: XCTestCase { + func testFlutterBridgeErrorType_invalidNumber_returnsFlutterInvalidNumberCode() { + XCTAssertEqual(OPCardErrorType.invalidNumber.flutterBridgeErrorType(), "InvalidNumber") + } + + func testFlutterBridgeErrorType_invalidExpMonth_returnsFlutterInvalidExpirationCode() { + XCTAssertEqual(OPCardErrorType.invalidExpMonth.flutterBridgeErrorType(), "InvalidExpiration") + } + + func testFlutterBridgeErrorType_invalidExpYear_returnsFlutterInvalidExpirationCode() { + XCTAssertEqual(OPCardErrorType.invalidExpYear.flutterBridgeErrorType(), "InvalidExpiration") + } + + func testFlutterBridgeErrorType_invalidCvv_returnsFlutterInvalidCvvCode() { + XCTAssertEqual(OPCardErrorType.invalidCvv.flutterBridgeErrorType(), "InvalidCVV") + } + + func testFlutterBridgeErrorType_invalidZip_returnsFlutterInvalidPostalCode() { + XCTAssertEqual(OPCardErrorType.invalidZip.flutterBridgeErrorType(), "InvalidPostalCode") + } + + func testFlutterBridgeErrorType_expiredCard_returnsFlutterExpiredCardCode() { + XCTAssertEqual(OPCardErrorType.expiredCard.flutterBridgeErrorType(), "ExpiredCard") + } + + func testFlutterBridgeErrorType_cardDeclined_returnsFlutterCardDeclinedCode() { + XCTAssertEqual(OPCardErrorType.cardDeclined.flutterBridgeErrorType(), "CardDeclined") + } + + func testFlutterBridgeErrorType_processingError_returnsFlutterProcessingErrorCode() { + XCTAssertEqual(OPCardErrorType.processingError.flutterBridgeErrorType(), "ProcessingError") + } + + func testFlutterBridgeErrorType_unknownCardError_returnsFlutterUnknownCardErrorCode() { + XCTAssertEqual(OPCardErrorType.unknownCardError.flutterBridgeErrorType(), "UnknownCardError") + } +} diff --git a/example/ios/RunnerTests/OPCardFieldExtensionTests.swift b/example/ios/RunnerTests/OPCardFieldExtensionTests.swift new file mode 100644 index 0000000..c29237a --- /dev/null +++ b/example/ios/RunnerTests/OPCardFieldExtensionTests.swift @@ -0,0 +1,30 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import Flutter +import UIKit +import XCTest + +@testable import olo_pay_sdk +import OloPaySDK + +final class OPCardFieldExtensionTests: XCTestCase { + func testFlutterBridgeValue_cardNumber_returnsNumberString() { + XCTAssertEqual(OPCardField.number.flutterBridgeValue(), "CardNumber") + } + + func testFlutterBridgeValue_expiration_returnsExpirationString() { + XCTAssertEqual(OPCardField.expiration.flutterBridgeValue(), "Expiration") + } + + func testFlutterBridgeValue_cvv_returnsCvvString() { + XCTAssertEqual(OPCardField.cvv.flutterBridgeValue(), "Cvv") + } + + func testFlutterBridgeValue_postalCode_returnsPostalCodeString() { + XCTAssertEqual(OPCardField.postalCode.flutterBridgeValue(), "PostalCode") + } + + func testFlutterBridgeValue_unknown_returnsEmptyString() { + XCTAssertEqual(OPCardField.unknown.flutterBridgeValue(), "") + } +} diff --git a/example/ios/RunnerTests/OPCardFieldStateProtocolExtensionTests.swift b/example/ios/RunnerTests/OPCardFieldStateProtocolExtensionTests.swift new file mode 100644 index 0000000..43859ac --- /dev/null +++ b/example/ios/RunnerTests/OPCardFieldStateProtocolExtensionTests.swift @@ -0,0 +1,91 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPCardFieldStateProtocolExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/7/24. +// + +import XCTest +import OloPaySDK + +final class OPCardFieldStateProtocolExtensionTests: XCTestCase { + func testToDictionary_hasCorrectLength() { + XCTAssertEqual(MockCardFieldState().toDictionary().count, 5) + } + + func testToDictionary_hasCorrectKeys() { + let dictionary = MockCardFieldState().toDictionary() + + XCTAssertNotNil(dictionary["isValid"]) + XCTAssertNotNil(dictionary["isEmpty"]) + XCTAssertNotNil(dictionary["wasEdited"]) + XCTAssertNotNil(dictionary["isFocused"]) + XCTAssertNotNil(dictionary["wasFocused"]) + } + + func testToDictionary_keysHaveCorrectValues() { + let dictionary1 = MockCardFieldState( + isValid: true, + isEmpty: false, + wasEdited: true, + isFirstResponder: false, + wasFirstResponder: true + ).toDictionary() + + XCTAssertTrue((dictionary1["isValid"] as? Bool)!) + XCTAssertFalse((dictionary1["isEmpty"] as? Bool)!) + XCTAssertTrue((dictionary1["wasEdited"] as? Bool)!) + XCTAssertFalse((dictionary1["isFocused"] as? Bool)!) + XCTAssertTrue((dictionary1["wasFocused"] as? Bool)!) + + let dictionary2 = MockCardFieldState( + isValid: false, + isEmpty: true, + wasEdited: false, + isFirstResponder: true, + wasFirstResponder: false + ).toDictionary() + + XCTAssertFalse((dictionary2["isValid"] as? Bool)!) + XCTAssertTrue((dictionary2["isEmpty"] as? Bool)!) + XCTAssertFalse((dictionary2["wasEdited"] as? Bool)!) + XCTAssertTrue((dictionary2["isFocused"] as? Bool)!) + XCTAssertFalse((dictionary2["wasFocused"] as? Bool)!) + } +} + +public class MockCardFieldState : NSObject, OPCardFieldStateProtocol { + public var isValid: Bool + + public var isEmpty: Bool + + public var wasEdited: Bool + + public var isFirstResponder: Bool + + public var wasFirstResponder: Bool + + override init() { + isValid = true + isEmpty = true + wasEdited = true + isFirstResponder = true + wasFirstResponder = true + } + + init( + isValid: Bool, + isEmpty: Bool, + wasEdited: Bool, + isFirstResponder: Bool, + wasFirstResponder: Bool + ) { + self.isValid = isValid + self.isEmpty = isEmpty + self.wasEdited = wasEdited + self.isFirstResponder = isFirstResponder + self.wasFirstResponder = wasFirstResponder + } +} diff --git a/example/ios/RunnerTests/OPErrorExtensionTests.swift b/example/ios/RunnerTests/OPErrorExtensionTests.swift new file mode 100644 index 0000000..ce86289 --- /dev/null +++ b/example/ios/RunnerTests/OPErrorExtensionTests.swift @@ -0,0 +1,58 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPErrorExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/8/24. +// + +import XCTest +@testable import olo_pay_sdk +import OloPaySDK + +final class OPErrorExtensionTests: XCTestCase { + func testFlutterBridgeErrorType_connectionError_returnsFlutterConnectionErrorCode() { + let error = OPError(errorType: .connectionError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "ConnectionError") + } + + func testFlutterBridgeErrorType_invalidRequestError_returnsFlutterInvalidRequestCode() { + let error = OPError(errorType: .invalidRequestError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "InvalidRequest") + } + + func testFlutterBridgeErrorType_apiError_returnsFlutterApiErrorCode() { + let error = OPError(errorType: .apiError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "ApiError") + } + + func testFlutterBridgeErrorType_cancellationError_returnsFlutterCancellationErrorCode() { + let error = OPError(errorType: .cancellationError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "CancellationError") + } + + func testFlutterBridgeErrorType_authenticationError_returnsFlutterAuthenticationErrorCode() { + let error = OPError(errorType: .authenticationError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "AuthenticationError") + } + + func testFlutterBridgeErrorType_generalError_returnsFlutterGeneralErrorCode() { + let error = OPError(errorType: .generalError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "generalError") + } + + func testFlutterBridgeErrorType_applePayContextError_returnsFlutterGeneralErrorCode() { + let error = OPError(errorType: .applePayContextError, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "generalError") + } + + // NOTE: We are only testing one card error type to ensure card error logic is processed correctly + // with OPError.flutterBridgeErrorType(). The remaining card error logic is tested separately + // in OPCardErrorTypeExtensionTests. If we were to add tests here for each kind of card type + // error we'd essentially be testing the same logic twice + func testFlutterBridgeErrorType_cardError_invalidNumber_returnsFlutterInvalidNumberErrorCode() { + let error = OPError(cardErrorType: .invalidNumber, description: "") + XCTAssertEqual(error.flutterBridgeErrorType(), "InvalidNumber") + } +} diff --git a/example/ios/RunnerTests/OPPaymentMethodProtocolExtensionTests.swift b/example/ios/RunnerTests/OPPaymentMethodProtocolExtensionTests.swift new file mode 100644 index 0000000..06e8d03 --- /dev/null +++ b/example/ios/RunnerTests/OPPaymentMethodProtocolExtensionTests.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPPaymentMethodProtocolExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/7/24. +// + +import XCTest +import OloPaySDK + +final class OPPaymentMethodProtocolExtensionTests: XCTestCase { + func testToDictionary_hasCorrectLength() { + XCTAssertEqual(MockPaymentMethod().toDictionary().count, 9) + } + + func testToDictionary_hasCorrectKeys() { + let result = MockPaymentMethod().toDictionary() + + XCTAssertNotNil(result["id"]) + XCTAssertNotNil(result["last4"]) + XCTAssertNotNil(result["cardType"]) + XCTAssertNotNil(result["expMonth"]) + XCTAssertNotNil(result["expYear"]) + XCTAssertNotNil(result["postalCode"]) + XCTAssertNotNil(result["countryCode"]) + XCTAssertNotNil(result["isDigitalWallet"]) + XCTAssertNotNil(result["productionEnvironment"]) + } + + func testToDictionary_keysHaveCorrectValues() { + let result = MockPaymentMethod().toDictionary() + + XCTAssertEqual(result["id"] as? String, "testId") + XCTAssertEqual(result["last4"] as? String, "1234") + XCTAssertEqual(result["cardType"] as? String, "Visa") + XCTAssertEqual(result["expMonth"] as? NSNumber, 11) + XCTAssertEqual(result["expYear"] as? NSNumber, 23) + XCTAssertEqual(result["postalCode"] as? String, "55056") + XCTAssertEqual(result["countryCode"] as? String, "US") + XCTAssertEqual(result["isDigitalWallet"] as? Bool, true) + XCTAssertEqual(result["productionEnvironment"] as? Bool, false) + } +} + +public class MockPaymentMethod: NSObject, OPPaymentMethodProtocol { + public var id: String = "testId" + public var last4: String? = "1234" + public var cardType: OPCardBrand = OPCardBrand.visa + public var expirationMonth: NSNumber? = 11 + public var expirationYear: NSNumber? = 23 + public var postalCode: String? = "55056" + public var isApplePay: Bool = true + public var country: String? = "US" + public var environment: OPEnvironment = OPEnvironment.test +} diff --git a/example/ios/RunnerTests/OloPaySDKPluginTests.swift b/example/ios/RunnerTests/OloPaySDKPluginTests.swift new file mode 100644 index 0000000..507f7a6 --- /dev/null +++ b/example/ios/RunnerTests/OloPaySDKPluginTests.swift @@ -0,0 +1,1121 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OloPaySDKPluginTests.swift +// RunnerTests +// +// Created by Justin Anderson on 2/6/24. +// +import Flutter +import UIKit +import XCTest +@testable import olo_pay_sdk +@testable import OloPaySDK + +final class OloPaySDKPluginTests: XCTestCase { + // IMPORTANT: Using expectations makes debugging difficult due to the + // waitForExpectationsCall() call. When debugging and stepping through + // code, it may be necessary to set the timeout value to something + // much larger to allow time to step through the code. + // + // NOTE: The value of 30 may seem excessive when not debugging, but + // was reached through trial and error. Values less than this lead + // to flaky test results in Github Actions + let expectationTimeout: TimeInterval = 30 + + var _plugin: OloPaySdkPlugin? = nil + var plugin: OloPaySdkPlugin { + get { _plugin! } + } + + override func setUp() { + _plugin = OloPaySdkPlugin() + } + + override func tearDown() { + OloPayAPI.sdkWrapperInfo = nil + OPStorage.reset() + } + + func testInitializeOloPay_withNilArgs_sdkInitialized_environmentDefaultsToProduction() { + let call = FlutterMethodCall(methodName: "initialize", arguments: nil) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OPEnvironment.production, OloPayAPI.environment) + XCTAssertTrue(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withoutEnvironmentArg_sdkInitialized_environmentDefaultsToProduction() { + let arguments = ["Foo" : "Bar"] + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OPEnvironment.production, OloPayAPI.environment) + XCTAssertTrue(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withFalseEnvironmentArg_sdkInitialized_environmentSetToTest() { + let arguments = ["productionEnvironment" : false] + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OPEnvironment.test, OloPayAPI.environment) + XCTAssertTrue(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withTrueEnvironmentArg_sdkInitialized_environmentSetToProduction() { + let arguments = ["productionEnvironment" : true] + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OPEnvironment.production, OloPayAPI.environment) + XCTAssertTrue(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_environmentArgNotBoolean_returnsUnexpectedParameterTypeError() { + let arguments = ["productionEnvironment" : "true"] + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize OloPaySdk: Value for 'productionEnvironment' is not of type Bool" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_environmentArgNil_sdkInitialized_environmentSetToProduction() { + let arguments = ["productionEnvironment" : nil] as [String : Any?] + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OPEnvironment.production, OloPayAPI.environment) + XCTAssertTrue(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_missingMerchantId_returnsMissingParameterError() { + let arguments = [ + "applePaySetup" : [ + "companyLabel" : "" + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize Apple Pay: Missing parameter 'merchantId'" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_emptyMerchantId_returnsInvalidParameterError() { + let arguments = [ + "applePaySetup" : [ + "merchantId" : "" + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize Apple Pay: Value for 'merchantId' cannot be empty" + ) + XCTAssertEqual(flutterError.code, "InvalidParameter") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_merchantIdNotString_returnsUnexpectedParameterTypeError() { + let arguments = [ + "applePaySetup" : [ + "merchantId" : true + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize Apple Pay: Value for 'merchantId' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_missingCompanyLabel_returnsMissingParameterError() { + let arguments = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test" + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize Apple Pay: Missing parameter 'companyLabel'" + ) + XCTAssertEqual(flutterError.code, "MissingParameter") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_emptyCompanyLabel_returnsInvalidParameterError() { + let arguments = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "" + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize Apple Pay: Value for 'companyLabel' cannot be empty" + ) + XCTAssertEqual(flutterError.code, "InvalidParameter") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_companyLabelNotString_returnsUnexpectedParameterTypeError() { + let arguments = [ + "applePaySetup" : [ + "merchantId": "com.merchant.test", + "companyLabel" : true + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize Apple Pay: Value for 'companyLabel' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeOloPay_withApplePayArgs_sdkInitialized_applePayArgsSet() { + let arguments = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + + let call = FlutterMethodCall(methodName: "initialize", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OPApplePayContext.companyLabel, "Foosburgers") + XCTAssertEqual(OPApplePayContext.merchantId, "com.merchant.test") + XCTAssertTrue(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testIsInitialized_sdkNotInitialized_returnsFalse() { + let call = FlutterMethodCall(methodName: "isInitialized", arguments: []) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let initialized = self.assertBool(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertFalse(initialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testIsInitialized_sdkIsInitialized_returnsTrue() { + waitForInitialization() + + let call = FlutterMethodCall(methodName: "isInitialized", arguments: []) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let initialized = self.assertBool(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertTrue(initialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testIsDigitalWalletReady_sdkNotInitialized_returnsFalse() { + let call = FlutterMethodCall(methodName: "isDigitalWalletReady", arguments: []) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let digitalWalletsReady = self.assertBool(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertFalse(digitalWalletsReady) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + //NOTE: We can't ensure that the test will return true for digital wallets being + // ready when the SDK is initialized, but we can verify it returns a boolean + func testIsDigitalWalletReady_sdkInitialized_returnsBoolean() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + waitForInitialization(applePayArgs) + + let call = FlutterMethodCall(methodName: "isDigitalWalletReady", arguments: []) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + let _ = self.assertBool(result) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetFontNamesForAssets_withNilArgs_returnsMissingParameterError() { + let call = FlutterMethodCall(methodName: "getFontNames", arguments: nil) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to get font names: Missing parameter fontAssetList" + ) + + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetFontNamesForAssets_missingAssetListArg_returnsMissingParameterError() { + let call = FlutterMethodCall(methodName: "getFontNames", arguments: [ + "foo" : "foo" + ]) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to get font names: Missing parameter 'fontAssetList'" + ) + + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetFontNamesForAssets_assetListNotArray_returnsUnexpectedParameterTypeError() { + let call = FlutterMethodCall(methodName: "getFontNames", arguments: [ + "fontAssetList" : "foo" + ]) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to get font names: Value for 'fontAssetList' is not of type Array" + ) + + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetFontNamesForAssets_assetListNotString_returnsInvalidParameterError() { + let call = FlutterMethodCall(methodName: "getFontNames", arguments: [ + "fontAssetList" : [1, 2, 3] + ]) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to load font asset. Asset is not a string or is empty" + ) + + XCTAssertEqual(flutterError.code, "InvalidParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetFontNamesForAssets_assetMissing_returnsAssetNotFoundError() { + let call = FlutterMethodCall(methodName: "getFontNames", arguments: [ + "fontAssetList" : ["path/to/font.ttf"] + ]) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to find font asset: path/to/font.ttf" + ) + + XCTAssertEqual(flutterError.code, "AssetNotFoundError") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkNotInitialized_returnsSdkUninitializedError() { + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: []) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Olo Pay SDK has not been initialized" + ) + + XCTAssertEqual(flutterError.code, "SdkUninitialized") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_withNilArgs_returnsMissingParameterError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + + waitForInitialization(applePayArgs) + + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: nil) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Missing parameter amount" + ) + + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_amountNil_returnsMissingParameterError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + + waitForInitialization(applePayArgs) + + let args = ["amount" : nil ] as [String : Any?] + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: args) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Missing parameter 'amount'" + ) + + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_amountMissing_returnsMissingParameterError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + waitForInitialization(applePayArgs) + + let args = ["Foo" : "Bar" ] as [String : Any?] + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: args) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Missing parameter 'amount'" + ) + + XCTAssertEqual(flutterError.code, "MissingParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_amountNotDouble_returnsUnexpectedParameterTypeError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + waitForInitialization(applePayArgs) + + let args = ["amount" : "Foo"] + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: args) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Value for 'amount' is not of type Double" + ) + + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_amountNegative_returnsInvalidParameterError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + waitForInitialization(applePayArgs) + + let args = ["amount" : -1.2] + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: args) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: amount cannot be negative" + ) + + XCTAssertEqual(flutterError.code, "InvalidParameter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_countryCodeNotString_returnsUnexpectedParameterTypeError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + waitForInitialization(applePayArgs) + + let args = [ + "amount" : 1.25, + "countryCode" : true + ] as [String : Any?] + + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: args) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Value for 'countryCode' is not of type String" + ) + + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testGetDigitalWalletPaymentMethod_sdkInitialized_currencyCodeNotString_returnsUnexpectedParameterTypeError() { + let applePayArgs = [ + "applePaySetup" : [ + "merchantId" : "com.merchant.test", + "companyLabel" : "Foosburgers" + ] + ] + waitForInitialization(applePayArgs) + + let args = [ + "amount" : 1.25, + "countryCode" : "US", + "currencyCode" : false + ] as [String : Any?] + + let call = FlutterMethodCall(methodName: "createDigitalWalletPaymentMethod", arguments: args) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to create payment method: Value for 'currencyCode' is not of type String" + ) + + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testApplePaymentMethodCreated_applePayResultNotCached_returnsError() { + let error = plugin.applePaymentMethodCreated( + MockApplePayContext(), + didCreatePaymentMethod: MockPaymentMethod() + ) + + XCTAssertNotNil(error) + } + + func testApplePaymentMethodCompleted_applePayResultCached_returnsNil() { + let result: FlutterResult = { arg in } + plugin._applePayResult = result + + let error = plugin.applePaymentMethodCreated( + MockApplePayContext(), + didCreatePaymentMethod: MockPaymentMethod() + ) + + XCTAssertNil(error) + } + + func testApplePaymentCompleted_withErrorStatus_returnsErrorData() { + let methodCallExpectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + let result = data as? [String : String] + XCTAssertEqual(result!["errorMessage"], "Test Error") + XCTAssertEqual(result!["digitalWalletType"], "applePay") + methodCallExpectation.fulfill() + } + + plugin._applePayResult = result + let _ = plugin.applePaymentMethodCreated( + MockApplePayContext(), + didCreatePaymentMethod: MockPaymentMethod() + ) + + plugin.applePaymentCompleted( + MockApplePayContext(), + didCompleteWith: .error, + error: OPError(errorType: .generalError, description: "Test Error") + ) + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testApplePaymentCompleted_withSuccessStatus_withoutCachedPaymentMethod_returnsErrorData() { + let methodCallExpectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + let result = data as? [String : String] + XCTAssertEqual(result!["errorMessage"], "Unexpected error: Payment method is nil") + XCTAssertEqual(result!["digitalWalletType"], "applePay") + methodCallExpectation.fulfill() + } + + plugin._applePayResult = result + plugin.applePaymentCompleted( + MockApplePayContext(), + didCompleteWith: .success, + error: nil + ) + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testApplePaymentCompleted_withSuccessStatus_withCachedPaymentMethod_returnsPaymentMethodData() { + let methodCallExpectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + let result = data as? [String : Any] + XCTAssertEqual(result!["id"] as? String, "testId") + XCTAssertEqual(result!["last4"] as? String, "1234") + XCTAssertEqual(result!["cardType"] as? String, "Visa") + XCTAssertEqual(result!["expMonth"] as? NSNumber, 11) + XCTAssertEqual(result!["expYear"] as? NSNumber, 23) + XCTAssertEqual(result!["postalCode"] as? String, "55056") + XCTAssertEqual(result!["countryCode"] as? String, "US") + XCTAssertEqual(result!["isDigitalWallet"] as? Bool, true) + XCTAssertEqual(result!["productionEnvironment"] as? Bool, false) + methodCallExpectation.fulfill() + } + + plugin._applePayResult = result + let _ = plugin.applePaymentMethodCreated( + MockApplePayContext(), + didCreatePaymentMethod: MockPaymentMethod() + ) + + plugin.applePaymentCompleted( + MockApplePayContext(), + didCompleteWith: .success, + error: nil + ) + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testApplePaymentCompleted_withUserCancelletationStatus_withoutCachedPaymentMethod_returnsNil() { + let methodCallExpectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + XCTAssertNil(data) + methodCallExpectation.fulfill() + } + + plugin._applePayResult = result + plugin.applePaymentCompleted( + MockApplePayContext(), + didCompleteWith: .userCancellation, + error: nil + ) + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testApplePaymentCompleted_withUserCancelletationStatus_withCachedPaymentMethod_returnsNil() { + let methodCallExpectation = expectation(description: "result must be called") + + let result: FlutterResult = { data in + XCTAssertNil(data) + methodCallExpectation.fulfill() + } + + plugin._applePayResult = result + + let _ = plugin.applePaymentMethodCreated( + MockApplePayContext(), + didCreatePaymentMethod: MockPaymentMethod() + ) + + plugin.applePaymentCompleted( + MockApplePayContext(), + didCompleteWith: .userCancellation, + error: nil + ) + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_withNilArgs_wrapperInfoSetToDefaults_returnsNil() { + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: nil) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.version.description, "0.0.0") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.buildType.description, "internal") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_metadataContainsCorrectValues_wrapperInfoSetCorrectly_returnsNil() { + let arguments = ["version": "3.2.1", "buildType": "public"] // non default values + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.version.description, "3.2.1") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.buildType.description, "public") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_emptyHybridVersion_setsDefaultHybridVersion_returnsNil() { + let arguments = ["version": "", "buildType": "internal"] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.version.description, "0.0.0") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_nilHybridVersion_setsDefaultHybridVersion_returnsNil() { + let arguments = ["version": nil, "buildType": "internal"] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.version.description, "0.0.0") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_missingHybridVersion_setsDefaultHybridVersion_returnsNil() { + let arguments = ["buildType": "internal"] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.version.description, "0.0.0") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_hybridVersionNotString_returnsUnexpectedParameterTypeError() { + let arguments = ["version": false, "buildType": "internal"] as [String : Any] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize metadata: Value for 'version' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_emptyHybridBuildType_setsDefaultHybridBuildType_returnsNil() { + let arguments = ["version": "1.2.3", "buildType": ""] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.buildType.description, "internal") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_nilHybridBuildType_setsDefaultHybridBuildType_returnsNil() { + let arguments = ["version": "1.2.3", "buildType": nil] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.buildType.description, "internal") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_missingHybridBuildType_setsDefaultHybridBuildType_returnsNil() { + let arguments = ["version": "1.2.3"] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + XCTAssertNil(result) + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.buildType.description, "internal") + XCTAssertEqual(OloPayAPI.sdkWrapperInfo?.platform.description, "flutter") + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + func testInitializeMetadata_hybridBuildTypeNotString_returnsUnexpectedParameterTypeError() { + let arguments = ["version": "1.2.3", "buildType": false] as [String : Any] + let call = FlutterMethodCall(methodName: "initializeMetadata", arguments: arguments) + let methodCallExpectation = expectation(description: "result must be called") + + plugin.handle(call) { result in + guard let flutterError = self.assertError(result) else { + methodCallExpectation.fulfill() + return + } + + XCTAssertEqual( + flutterError.message, + "Unable to initialize metadata: Value for 'buildType' is not of type String" + ) + XCTAssertEqual(flutterError.code, "UnexpectedParameterType") + XCTAssertFalse(self.plugin.sdkInitialized) + methodCallExpectation.fulfill() + } + + wait(for: [methodCallExpectation], timeout: expectationTimeout) + } + + private func assertError(_ result: Any?) -> FlutterError? { + guard let flutterError = result as? FlutterError else { + XCTFail("Result should be an error") + return nil + } + + return flutterError + } + + private func assertBool(_ result: Any?) -> Bool? { + guard let response = result as? Bool else { + XCTFail("Result should be a boolean") + return nil + } + + return response + } + + private func waitForInitialization(_ args: [String : Any] = [:]) { + let initializeExpectation = expectation(description: "plugin not initialized") + let call = FlutterMethodCall(methodName: "initialize", arguments: args) + + plugin.handle(call) { result in + XCTAssertNil(result) + initializeExpectation.fulfill() + } + + wait(for: [initializeExpectation], timeout: expectationTimeout) + } + + private class MockApplePayContext: NSObject, OPApplePayContextProtocol { + var basketId: String? + + func presentApplePay(completion: OloPaySDK.OPVoidBlock?) throws { + // Do nothing here + } + + func presentApplePay(merchantId: String, companyLabel: String, completion: OloPaySDK.OPVoidBlock?) throws { + // Do nothing here + } + } +} + diff --git a/example/ios/RunnerTests/StringExtensionTests.swift b/example/ios/RunnerTests/StringExtensionTests.swift new file mode 100644 index 0000000..9611ff4 --- /dev/null +++ b/example/ios/RunnerTests/StringExtensionTests.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// StringExtensionTests.swift +// RunnerTests +// +// Created by Justin Anderson on 3/8/24. +// + +import XCTest +@testable import olo_pay_sdk + +final class StringExtensionTests: XCTestCase { + + func testTrim_trimsOnlyLeadingAndTrailingWhitespace() { + let value = " This is a test " + let trimmedValue = value.trim() + + XCTAssertEqual("This is a test", trimmedValue) + } + +} diff --git a/example/lib/app.dart b/example/lib/app.dart new file mode 100644 index 0000000..5caae82 --- /dev/null +++ b/example/lib/app.dart @@ -0,0 +1,145 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk.dart'; +import 'package:olo_pay_sdk_example/pages/card_details_page.dart'; +import 'package:olo_pay_sdk_example/pages/cvv_page.dart'; +import 'package:olo_pay_sdk_example/pages/digital_wallets_page.dart'; + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + // title: 'Olo Pay SDK Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color.fromRGBO(1, 160, 219, 1)), + useMaterial3: true, + ), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.system, + home: const NavigationWrapper(), + ); + } +} + +class NavigationWrapper extends StatefulWidget { + const NavigationWrapper({super.key}); + + @override + State createState() => _NavigationWrapperState(); +} + +class _NavigationWrapperState extends State { + // Step 1: Create the plugin instance + final _oloPaySdkPlugin = OloPaySdk(); + int currentPageIndex = 0; + String _error = ''; + bool _sdkInitialized = false; + bool _digitalWalletsReady = false; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + void onDigitalWalletReady(bool isReady) { + setState(() { + _digitalWalletsReady = isReady; + }); + } + + Future initPlatformState() async { + var sdkInitialized = false; + try { + // Step 2: Add a digital wallet listener + _oloPaySdkPlugin.onDigitalWalletReady = onDigitalWalletReady; + + const OloPaySetupParameters sdkParams = OloPaySetupParameters( + productionEnvironment: false, + ); + + const GooglePaySetupParameters googlePayParams = GooglePaySetupParameters( + countryCode: "US", + merchantName: "Foosburgers", + productionEnvironment: false, + ); + + const ApplePaySetupParameters applePayParams = ApplePaySetupParameters( + merchantId: "merchant.com.olopaysdktestharness", + companyLabel: "SDK Test", + ); + + // Step 3: Initialize the Olo Pay SDK + await _oloPaySdkPlugin.initializeOloPay( + oloPayParams: sdkParams, + googlePayParams: googlePayParams, + applePayParams: applePayParams, + ); + + // If initializeOloPay() succeeds there is no need for this method call in a production app... just directly set state to true + //We do this just to test that the isOloPayInitialized() method is working properly. + sdkInitialized = await _oloPaySdkPlugin.isOloPayInitialized(); + } on PlatformException catch (e) { + updateError(e.message!); + } + + setState(() { + _sdkInitialized = sdkInitialized; + }); + } + + void updateError(String error) { + setState(() { + _error = error; + }); + } + + @override + Widget build(BuildContext context) { + // final ThemeData theme = Theme.of(context); + return Scaffold( + bottomNavigationBar: NavigationBar( + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + indicatorColor: const Color.fromRGBO(1, 160, 219, 1), + selectedIndex: currentPageIndex, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.credit_card), + label: 'Card', + ), + NavigationDestination( + icon: Icon(Icons.wallet), + label: 'Digital Wallet', + ), + NavigationDestination( + icon: Icon(Icons.payments), + label: 'CVV', + ), + ], + ), + body: [ + CardDetailsPage( + sdkInitialized: _sdkInitialized, + digitalWalletReady: _digitalWalletsReady, + ), + DigitalWalletsPage( + sdkInitialized: _sdkInitialized, + digitalWalletReady: _digitalWalletsReady, + oloPaySdkPlugin: _oloPaySdkPlugin, + ), + CvvPage( + sdkInitialized: _sdkInitialized, + ), + ][currentPageIndex], + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..1c0b7bf --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,9 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; + +import 'package:olo_pay_sdk_example/app.dart'; + +void main() { + runApp(const MyApp()); +} diff --git a/example/lib/pages/card_details_page.dart b/example/lib/pages/card_details_page.dart new file mode 100644 index 0000000..7067c7c --- /dev/null +++ b/example/lib/pages/card_details_page.dart @@ -0,0 +1,341 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk.dart'; + +class CardDetailsPage extends StatefulWidget { + const CardDetailsPage({ + super.key, + required this.sdkInitialized, + required this.digitalWalletReady, + }); + + final bool sdkInitialized; + final bool digitalWalletReady; + + @override + State createState() => _CardDetailsPageState(); +} + +class _CardDetailsPageState extends State { + CardDetailsSingleLineTextFieldController? _cardInputController; + + String _status = ''; + bool _enabled = true; + bool _showAll = false; + + @override + void initState() { + super.initState(); + } + + void onSingleLineControllerCreated( + CardDetailsSingleLineTextFieldController controller) { + _cardInputController = controller; + } + + void updatePaymentMethod(PaymentMethod paymentMethod) { + updateStatus("Payment Method\n${paymentMethod.toString()}"); + } + + void updateStatus(String status) { + setState(() { + _status = status; + }); + } + + void onInputChanged( + bool isValid, Map fieldStates) { + // log('onInputChanged: $isValid'); + } + + void onValidStateChanged( + bool isValid, Map fieldStates) { + // log('onValidStateChanged: $isValid'); + } + + void onFocusChanged(CardField? focusedField, bool isValid, + Map fieldStates) { + // log('onFocusChanged: $focusedField'); + } + + Future createPaymentMethod() async { + try { + var paymentMethod = await _cardInputController?.createPaymentMethod(); + if (paymentMethod != null) { + updatePaymentMethod(paymentMethod); + } + } on PlatformException { + // Handle PaymentMethod error here + } + } + + Future getState() async { + try { + var state = await _cardInputController?.getState(); + if (state != null) { + var status = state.entries + .map((e) => "${e.key}\n${e.value.toString()}\n") + .reduce((value, element) => value + element); + updateStatus(status); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future isValid() async { + try { + var valid = await _cardInputController?.isValid(); + if (valid != null) { + updateStatus("Is Valid: $valid"); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future getCardType() async { + try { + var cardType = await _cardInputController?.getCardType(); + if (cardType != null) { + updateStatus("Card Type: ${cardType.stringValue}"); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future isEnabled() async { + if (_cardInputController == null) { + return; + } + + var isEnabled = await _cardInputController!.isEnabled(); + updateStatus("Enabled: $isEnabled"); + } + + Future toggleEnabled() async { + if (_cardInputController == null) { + return; + } + + try { + await _cardInputController!.setEnabled(!_enabled); + setState(() { + _enabled = !_enabled; + updateStatus("Enabled: $_enabled"); + }); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future clear() async { + try { + await _cardInputController?.clearFields(); + updateStatus(""); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future hasErrorMessage() async { + if (_cardInputController == null) { + return; + } + + try { + var editedFieldsError = await _cardInputController?.hasErrorMessage(); + var uneditedFieldsError = await _cardInputController?.hasErrorMessage( + ignoreUneditedFields: false); + + updateStatus( + "Has Error Message:\nEdited Fields Only: $editedFieldsError\nAll Fields: $uneditedFieldsError"); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future getErrorMessage() async { + if (_cardInputController == null) { + return; + } + + try { + var editedFieldsError = await _cardInputController?.getErrorMessage(); + var uneditedFieldsError = await _cardInputController?.getErrorMessage( + ignoreUneditedFields: false); + + updateStatus( + "Error Message:\n\nEdited Fields Only:\n$editedFieldsError\nAll Fields:\n$uneditedFieldsError"); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future requestFocus() async { + if (_cardInputController == null) { + return; + } + + try { + await _cardInputController?.requestFocus(); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future clearFocus() async { + if (_cardInputController == null) { + return; + } + + try { + await _cardInputController?.clearFocus(); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text("Card Controls"), + ), + body: SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + "SDK Initialized: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(widget.sdkInitialized.toString()) + ], + ), + Row( + children: [ + const Text( + "Digital Wallets Ready: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(widget.digitalWalletReady.toString()) + ], + ), + const SizedBox(height: 20.0), + if (widget + .sdkInitialized) //The SDK must be initialized prior to displaying the text field + CardDetailsSingleLineTextField( + onControllerCreated: onSingleLineControllerCreated, + onInputChanged: onInputChanged, + onValidStateChanged: onValidStateChanged, + onFocusChanged: onFocusChanged, + ), + const SizedBox(height: 12.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: createPaymentMethod, + child: const Text("Submit"), + ), + ElevatedButton( + onPressed: clear, + child: const Text("Clear"), + ), + TextButton.icon( + icon: Text(_showAll ? "Show Less" : "Show More"), + label: Icon( + _showAll + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 24.0, + ), + onPressed: () { + setState(() { + _showAll = !_showAll; + }); + }, + ), + ], + ), + const SizedBox(height: 8.0), + if (_showAll) + GridView.count( + crossAxisCount: 3, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + childAspectRatio: 3, + shrinkWrap: true, + padding: const EdgeInsets.all(8.0), + children: [ + ElevatedButton( + onPressed: getState, + child: const Text("State"), + ), + ElevatedButton( + onPressed: isValid, + child: const Text("Is Valid?"), + ), + ElevatedButton( + onPressed: getCardType, + child: const Text("Card Type"), + ), + ElevatedButton( + onPressed: isEnabled, + child: const Text("Enabled?"), + ), + ElevatedButton( + onPressed: hasErrorMessage, + child: const Text("Has Error?"), + ), + ElevatedButton( + onPressed: getErrorMessage, + child: const Text("Errors"), + ), + ElevatedButton( + onPressed: requestFocus, + child: const Text("Focus"), + ), + ElevatedButton( + onPressed: clearFocus, + child: const Text("Clear Focus"), + ), + TextButton.icon( + icon: const Text("Enable"), + label: Icon( + _enabled + ? Icons.check_box + : Icons.check_box_outline_blank, + size: 24.0, + ), + onPressed: toggleEnabled, + ), + ], + ), + const SizedBox(height: 8.0), + Text( + _status, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.blue, + fontSize: 18.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/pages/cvv_page.dart b/example/lib/pages/cvv_page.dart new file mode 100644 index 0000000..db22322 --- /dev/null +++ b/example/lib/pages/cvv_page.dart @@ -0,0 +1,309 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk.dart'; + +class CvvPage extends StatefulWidget { + const CvvPage({ + super.key, + required this.sdkInitialized, + }); + + final bool sdkInitialized; + + @override + State createState() => _CvvPageState(); +} + +class _CvvPageState extends State { + CvvTextFieldController? _cvvInputController; + + String _status = ''; + bool _enabled = true; + bool _showAll = false; + + @override + void initState() { + super.initState(); + } + + void onCvvFieldControllerCreated(CvvTextFieldController controller) { + _cvvInputController = controller; + } + + void updateCvvUpdateToken(CvvUpdateToken cvvUpdateToken) { + updateStatus("Cvv Update Token\n${cvvUpdateToken.toString()}"); + } + + void updateStatus(String status) { + setState(() { + _status = status; + }); + } + + void onInputChanged(CardFieldState fieldState) { + // log('onInputChanged:\n$fieldState'); + } + + void onValidStateChanged(CardFieldState fieldState) { + // log('onValidStateChanged:\n$fieldState'); + } + + void onFocusChanged(CardFieldState fieldState) { + // log('onFocusChanged:\n$fieldState'); + } + + Future createCvvUpdateToken() async { + try { + var cvvUpdateToken = await _cvvInputController?.createCvvUpdateToken(); + if (cvvUpdateToken != null) { + updateCvvUpdateToken(cvvUpdateToken); + } + } on PlatformException { + // Handle CvvUpdateToken error here + } + } + + Future getState() async { + try { + var state = await _cvvInputController?.getState(); + if (state != null) { + var status = state.toString(); + updateStatus(status); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future isValid() async { + try { + var valid = await _cvvInputController?.isValid(); + if (valid != null) { + updateStatus("Is Valid: $valid"); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future isEnabled() async { + if (_cvvInputController == null) { + return; + } + + var isEnabled = await _cvvInputController!.isEnabled(); + updateStatus("Enabled: $isEnabled"); + } + + Future toggleEnabled() async { + if (_cvvInputController == null) { + return; + } + + try { + await _cvvInputController!.setEnabled(!_enabled); + setState(() { + _enabled = !_enabled; + updateStatus("Enabled: $_enabled"); + }); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future clear() async { + try { + await _cvvInputController?.clear(); + updateStatus(""); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future hasErrorMessage() async { + if (_cvvInputController == null) { + return; + } + + try { + var editedFieldError = await _cvvInputController?.hasErrorMessage(); + var uneditedFieldError = await _cvvInputController?.hasErrorMessage( + ignoreUneditedField: false); + + updateStatus( + "Has Error Message:\nEdited Field Error: $editedFieldError\nUnedited Field Error: $uneditedFieldError"); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future getErrorMessage() async { + if (_cvvInputController == null) { + return; + } + + try { + var editedFieldError = await _cvvInputController?.getErrorMessage(); + var uneditedFieldError = await _cvvInputController?.getErrorMessage( + ignoreUneditedField: false); + + updateStatus( + "Error Message:\n\nEdited Field Error:\n$editedFieldError\nUnedited Field Error:\n$uneditedFieldError"); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future requestFocus() async { + if (_cvvInputController == null) { + return; + } + + try { + await _cvvInputController?.requestFocus(); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + Future clearFocus() async { + if (_cvvInputController == null) { + return; + } + + try { + await _cvvInputController?.clearFocus(); + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text("CVV Field"), + ), + body: SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + "SDK Initialized: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(widget.sdkInitialized.toString()) + ], + ), + const SizedBox(height: 20.0), + if (widget + .sdkInitialized) //The SDK must be initialized prior to displaying the text field + CvvTextField( + onControllerCreated: onCvvFieldControllerCreated, + onFocusChanged: onFocusChanged, + onInputChanged: onInputChanged, + onValidStateChanged: onValidStateChanged, + ), + const SizedBox(height: 12.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: createCvvUpdateToken, + child: const Text("Submit"), + ), + ElevatedButton( + onPressed: clear, + child: const Text("Clear"), + ), + TextButton.icon( + icon: Text(_showAll ? "Show Less" : "Show More"), + label: Icon( + _showAll + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 24.0, + ), + onPressed: () { + setState(() { + _showAll = !_showAll; + }); + }, + ), + ], + ), + const SizedBox(height: 8.0), + if (_showAll) + GridView.count( + crossAxisCount: 3, + crossAxisSpacing: 8.0, + mainAxisSpacing: 8.0, + childAspectRatio: 3, + shrinkWrap: true, + padding: const EdgeInsets.all(8.0), + children: [ + ElevatedButton( + onPressed: getState, + child: const Text("State"), + ), + ElevatedButton( + onPressed: isValid, + child: const Text("Is Valid?"), + ), + ElevatedButton( + onPressed: isEnabled, + child: const Text("Enabled?"), + ), + ElevatedButton( + onPressed: hasErrorMessage, + child: const Text("Has Error?"), + ), + ElevatedButton( + onPressed: getErrorMessage, + child: const Text("Errors"), + ), + ElevatedButton( + onPressed: requestFocus, + child: const Text("Focus"), + ), + ElevatedButton( + onPressed: clearFocus, + child: const Text("Clear Focus"), + ), + TextButton.icon( + icon: const Text("Enable"), + label: Icon( + _enabled + ? Icons.check_box + : Icons.check_box_outline_blank, + size: 24.0, + ), + onPressed: toggleEnabled, + ), + ], + ), + const SizedBox(height: 8.0), + Text( + _status, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.blue, + fontSize: 18.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/pages/digital_wallets_page.dart b/example/lib/pages/digital_wallets_page.dart new file mode 100644 index 0000000..c326c9c --- /dev/null +++ b/example/lib/pages/digital_wallets_page.dart @@ -0,0 +1,166 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk.dart'; +import 'package:pay/pay.dart'; + +class DigitalWalletsPage extends StatefulWidget { + const DigitalWalletsPage({ + super.key, + required this.sdkInitialized, + required this.digitalWalletReady, + required this.oloPaySdkPlugin, + }); + + final bool sdkInitialized; + final bool digitalWalletReady; + final OloPaySdk oloPaySdkPlugin; + + @override + State createState() => _DigitalWalletsPageState(); +} + +class _DigitalWalletsPageState extends State { + OloPaySdk? _oloPaySdkPlugin; + String _error = ''; + String _status = ''; + bool _digitalWalletsReady = false; + bool _enabled = true; + bool _showAll = false; + + @override + void initState() { + super.initState(); + _digitalWalletsReady = widget.digitalWalletReady; + _oloPaySdkPlugin = widget.oloPaySdkPlugin; + } + + void updateError(String error) { + setState(() { + _error = error; + }); + } + + void updatePaymentMethod(PaymentMethod paymentMethod) { + updateStatus("Payment Method\n${paymentMethod.toString()}"); + } + + void updateStatus(String status) { + setState(() { + _status = status; + }); + } + + void onInputChanged( + bool isValid, Map fieldStates) { + // log('onInputChanged: $isValid'); + } + + void onValidStateChanged( + bool isValid, Map fieldStates) { + // log('onValidStateChanged: $isValid'); + } + + void onFocusChanged(CardField? focusedField, bool isValid, + Map fieldStates) { + // log('onFocusChanged: $focusedField'); + } + + void onDigitalWalletReady(bool isReady) { + setState(() { + _digitalWalletsReady = isReady; + }); + } + + Future createDigitalWalletPaymentMethod() async { + const DigitalWalletPaymentParameters paymentParams = + DigitalWalletPaymentParameters( + amount: 1.21, + ); + + try { + PaymentMethod? paymentMethod = await _oloPaySdkPlugin + ?.createDigitalWalletPaymentMethod(paymentParams); + + if (paymentMethod == null) { + updateStatus("User Canceled"); + } else { + updatePaymentMethod(paymentMethod); + } + } on PlatformException catch (e) { + updateStatus(e.message!); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text("Digital Wallets"), + ), + body: SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + "SDK Initialized: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(widget.sdkInitialized.toString()) + ], + ), + Row( + children: [ + const Text( + "Digital Wallets Ready: ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(_digitalWalletsReady.toString()) + ], + ), + const SizedBox(height: 20.0), + Row( + children: [ + if (defaultTargetPlatform == TargetPlatform.android && + _digitalWalletsReady) + Expanded( + child: RawGooglePayButton( + type: GooglePayButtonType.buy, + onPressed: createDigitalWalletPaymentMethod, + ), + ), + if (defaultTargetPlatform == TargetPlatform.iOS && + _digitalWalletsReady) + Expanded( + child: RawApplePayButton( + type: ApplePayButtonType.buy, + onPressed: createDigitalWalletPaymentMethod, + ), + ), + ], + ), + const SizedBox(height: 15.0), + Text( + _status, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.blue, + fontSize: 18.0, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..d6e7c96 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,86 @@ +name: olo_pay_sdk_example +description: "Demonstrates how to use the olo_pay_sdk plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: '>=3.2.1 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + olo_pay_sdk: + # When depending on this package from a real application you should use: + # olo_pay_sdk: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + pay: ^1.1.2 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..472786f --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,29 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:olo_pay_sdk_example/app.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Text && widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Classes/Data/CustomErrorMessages.swift b/ios/Classes/Data/CustomErrorMessages.swift new file mode 100644 index 0000000..e9b98ef --- /dev/null +++ b/ios/Classes/Data/CustomErrorMessages.swift @@ -0,0 +1,138 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// CustomErrorMessages.swift +// olo_pay_sdk +// +// Created by Richard Dowdy on 3/5/24. +// + +import Foundation +import OloPaySDK + +class CustomErrorMessages { + private let invalidCardNumber: String? + private let emptyCardNumber: String? + private let invalidExpiration: String? + private let emptyExpiration: String? + private let invalidCvv: String? + private let emptyCvv: String? + private let invalidPostalCode: String? + private let emptyPostalCode: String? + let unsupportedCardNumber: String? + + init(customErrorMessages: NSDictionary?) { + self.invalidCardNumber = (customErrorMessages?[OPCardField.number.flutterBridgeValue()] as? [String:Any])?[DataKeys.invalidErrorKey] as? String + self.emptyCardNumber = (customErrorMessages?[OPCardField.number.flutterBridgeValue()] as? [String:Any])?[DataKeys.emptyErrorKey] as? String + self.invalidExpiration = (customErrorMessages?[OPCardField.expiration.flutterBridgeValue()] as? [String:Any])?[DataKeys.invalidErrorKey] as? String + self.emptyExpiration = (customErrorMessages?[OPCardField.expiration.flutterBridgeValue()] as? [String:Any])?[DataKeys.emptyErrorKey] as? String + self.invalidCvv = (customErrorMessages?[OPCardField.cvv.flutterBridgeValue()] as? [String:Any])?[DataKeys.invalidErrorKey] as? String + self.emptyCvv = (customErrorMessages?[OPCardField.cvv.flutterBridgeValue()] as? [String:Any])?[DataKeys.emptyErrorKey] as? String + self.invalidPostalCode = (customErrorMessages?[OPCardField.postalCode.flutterBridgeValue()] as? [String:Any])?[DataKeys.invalidErrorKey] as? String + self.emptyPostalCode = (customErrorMessages?[OPCardField.postalCode.flutterBridgeValue()] as? [String:Any])?[DataKeys.emptyErrorKey] as? String + self.unsupportedCardNumber = customErrorMessages?[DataKeys.unsupportedCardErrorKey] as? String + } + + init(cvvCustomErrors: NSDictionary?) { + invalidCardNumber = nil + emptyCardNumber = nil + invalidExpiration = nil + emptyExpiration = nil + invalidCvv = cvvCustomErrors?[DataKeys.invalidErrorKey] as? String + emptyCvv = cvvCustomErrors?[DataKeys.emptyErrorKey] as? String + invalidPostalCode = nil + emptyPostalCode = nil + unsupportedCardNumber = nil + } + + static func getDefaultErrorMessage( + for cardField: OPCardField, + with state: OPCardFieldStateProtocol, + _ ignoreUneditedFields: Bool, + _ cardBrand: OPCardBrand? = nil + ) -> String { + return getDefaultErrorMessage(ignoreUneditedFields, [cardField: state], cardBrand ?? OPCardBrand.unknown) + } + + static func getDefaultErrorMessage( + _ ignoreUneditedFields: Bool, + _ cardFields: [OPCardField: OPCardFieldStateProtocol], + _ cardBrand: OPCardBrand + ) -> String { + let errorFields = getErrorFields(ignoreUneditedFields, cardFields) + + if let numberState = errorFields[.number] { + if numberState.isEmpty { + return OPStrings.emptyCardNumberError + } else if cardBrand == .unsupported { + return OPStrings.unsupportedCardError + } else { + return OPStrings.invalidCardNumberError + } + } + + if let expirationState = errorFields[.expiration] { + return expirationState.isEmpty ? OPStrings.emptyExpirationError : OPStrings.invalidExpirationError + } + + if let cvvState = errorFields[.cvv] { + return cvvState.isEmpty ? OPStrings.emptyCvvError : OPStrings.invalidCvvError + } + + if let postalCodeState = errorFields[.postalCode] { + return postalCodeState.isEmpty ? OPStrings.emptyPostalCodeError : OPStrings.invalidPostalCodeError + } + + return "" + } + + func getCustomErrorMessage( + for cardField: OPCardField, + with state: OPCardFieldStateProtocol, + _ ignoreUneditedFields: Bool, + _ cardBrand: OPCardBrand? = nil + ) -> String? { + return getCustomErrorMessage(ignoreUneditedFields, [cardField: state], cardBrand ?? OPCardBrand.unknown) + } + + func getCustomErrorMessage( + _ ignoreUneditedFields: Bool, + _ cardFields: [OPCardField: OPCardFieldStateProtocol], + _ cardBrand: OPCardBrand + ) -> String? { + let errorFields = CustomErrorMessages.getErrorFields(ignoreUneditedFields, cardFields) + + if let numberState = errorFields[.number] { + if numberState.isEmpty { + return emptyCardNumber + } else if cardBrand == .unsupported { + return unsupportedCardNumber + } else { + return invalidCardNumber + } + } + + if let expirationState = errorFields[.expiration] { + return (expirationState.isEmpty ? emptyExpiration : invalidExpiration) + } + + if let cvvState = errorFields[.cvv] { + return (cvvState.isEmpty ? emptyCvv : invalidCvv) + } + + if let postalCodeState = errorFields[.postalCode] { + return (postalCodeState.isEmpty ? emptyPostalCode : invalidPostalCode) + } + + return nil + } + + static func getErrorFields(_ ignoreUneditedFields: Bool, _ cardState: [OPCardField: OPCardFieldStateProtocol]) -> [OPCardField: OPCardFieldStateProtocol] { + if(!ignoreUneditedFields) { + return cardState.filter({!$0.value.isValid}) + } + + return cardState.filter({!$0.value.isValid && $0.value.wasEdited && $0.value.wasFirstResponder}) + } + +} diff --git a/ios/Classes/Data/DataKeys.swift b/ios/Classes/Data/DataKeys.swift new file mode 100644 index 0000000..1685ad5 --- /dev/null +++ b/ios/Classes/Data/DataKeys.swift @@ -0,0 +1,144 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// File.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/6/23. +// + +import Foundation + +public class DataKeys { + // Prefix Keys + public static let BridgePrefix = "com.olo.flutter.olo_pay_sdk" + public static let SingleLineViewType = "PaymentCardDetailsSingleLineView" + public static let CvvViewType = "PaymentCardCvvView" + + // Method Channel Keys + public static let OloPaySdkMethodChannelKey = "\(BridgePrefix)/sdk" + public static let SingleLineBaseMethodChannelKey = "\(BridgePrefix)/\(SingleLineViewType):" + public static let CvvBaseMethodChannelKey = "\(BridgePrefix)/\(CvvViewType):" + + // View Registration Keys + public static let PaymentCardDetailsSingleLineViewKey = "\(BridgePrefix)/\(SingleLineViewType)" + public static let PaymentCardCvvViewKey = "\(BridgePrefix)/\(CvvViewType)" + + // Method Call Keys + public static let InitializeMethodKey = "initialize" + public static let InitializeMetadataMethodKey = "initializeMetadata" + public static let IsInitializedMethodKey = "isInitialized" + public static let IsDigitalWalletReadyMethodKey = "isDigitalWalletReady" + public static let CreatePaymentMethodKey = "createPaymentMethod" + public static let CreateCvvTokenMethodKey = "createCvvUpdateToken" + public static let CreateDigitalWalletPaymentMethod = "createDigitalWalletPaymentMethod" + public static let GetStateMethodKey = "getState" + public static let IsValidMethodKey = "isValid" + public static let GetCardTypeMethodKey = "getCardType" + public static let SetEnabledMethodKey = "setEnabled" + public static let IsEnabledMethodKey = "isEnabled" + public static let HasErrorMessageMethodKey = "hasErrorMessage" + public static let GetErrorMessageMethodKey = "getErrorMessage" + public static let ClearFieldsMethodKey = "clearFields" + public static let RequestFocusMethodKey = "requestFocus" + public static let ClearFocusMethodKey = "clearFocus" + public static let RefreshUiMethodKey = "refreshUI" + public static let GetFontNamesMethodKey = "getFontNames" + + // Method Call Parameter Keys + public static let EnabledParameterKey = "enabled" + public static let IgnoreUneditedFieldsParameterKey = "ignoreUneditedFields" + public static let DigitalWalletAmountParameterKey = "amount" + public static let DigitalWalletCountryCodeParameterKey = "countryCode" + public static let DigitalWalletCurrencyCodeParameterKey = "currencyCode" + public static let DigitalWalletErrorMessageParameterKey = "errorMessage" + public static let DigitalWalletTypeParameterKey = "digitalWalletType" + public static let DigitalWalletTypeParameterValue = "applePay" + public static let ApplePayMerchantIdParameterKey = "merchantId" + public static let ApplePayCompanyLabelParameterKey = "companyLabel" + public static let DigitalWalletReadyParameterKey = "isReady" + public static let CreationParameters = "creationParams" + + // SDK Initialization Keys + public static let ProductionEnvironmentKey = "productionEnvironment" + public static let ApplePaySetupArgsKey = "applePaySetup" + public static let HybridSdkVersionKey = "version" + public static let HybridBuildTypeKey = "buildType" + public static let HybridBuildTypePublicValue = "public" + public static let HybridBuildTypeInternalValue = "internal" + + // Payment Method Keys + public static let IDKey = "id" + public static let Last4Key = "last4" + public static let CardTypeKey = "cardType" + public static let ExpirationMonthKey = "expMonth" + public static let ExpirationYearKey = "expYear" + public static let PostalCodeKey = "postalCode" + public static let CountryCodeKey = "countryCode" + public static let IsDigitalWalletKey = "isDigitalWallet" + + // Event Handler Keys + public static let OnFocusChangedEventHandlerKey = "onFocusChanged" + public static let OnInputChangedEventHandlerKey = "onInputChanged" + public static let OnValidStateChangedEventHandlerKey = "onValidStateChanged" + public static let OnErrorMessageChangedEventHandlerKey = "onErrorMessageChanged" + public static let DigitalWalletReadyEventHandlerKey = "digitalWalletReadyEvent" + + // EventHandler Parameter Keys + public static let FocusedFieldParameterKey = "focusedField" + public static let FieldStatesParameterKey = "fieldStates" + + // OPCardFieldStateProtocol Keys + public static let IsValidKey = "isValid" + public static let IsFocusedKey = "isFocused" + public static let IsEmptyKey = "isEmpty" + public static let WasEditedKey = "wasEdited" + public static let WasFocusedKey = "wasFocused" + + // Card field values (Matches with Android enums) + public static let CardNumberFieldValueKey = "CardNumber" + public static let ExpirationFieldValueKey = "Expiration" + public static let CvvFieldValueKey = "Cvv" + public static let PostalCodeFieldValueKey = "PostalCode" + + // Text Style Keys + public static let TextColorKey = "textColor" + public static let ErrorTextColorKey = "errorTextColor" + public static let CursorColorKey = "cursorColor" + public static let HintTextColorKey = "hintTextColor" + public static let TextSizeKey = "textSize" + public static let FontAssetKey = "fontAsset" + public static let FontNameKey = "fontName" + public static let FontAssetListKey = "fontAssetList" + + // Background Style Keys + public static let BackgroundColorKey = "backgroundColor" + public static let BorderColorKey = "borderColor" + public static let BorderWidthKey = "borderWidth" + public static let BorderRadiusKey = "borderRadius" + + // Padding Style Keys + public static let StartPaddingKey = "startPadding" + public static let EndPaddingKey = "endPadding" + public static let TopPaddingKey = "topPadding" + public static let BottomPaddingKey = "bottomPadding" + + // View Initializer Argument Keys + public static let HintsArgumentsKey = "hints" + public static let TextStylesArgumentsKey = "textStyles" + public static let BackgroundStylesArgumentsKey = "backgroundStyles" + public static let PaddingStylesArgumentsKey = "paddingStyles" + public static let CustomErrorMessagesArgumentsKey = "customErrorMessages" + public static let TextAlignmentKey = "textAlignment" + + // Error Types + public static let emptyErrorKey = "emptyError" + public static let invalidErrorKey = "invalidError" + + // Custom Error Message Keys + public static let unsupportedCardErrorKey = "unsupportedCardError" + + // Alignment keys + public static let AlignmentCenterKey = "center" + public static let AlignmentRightKey = "right" +} diff --git a/ios/Classes/Data/ErrorCodes.swift b/ios/Classes/Data/ErrorCodes.swift new file mode 100644 index 0000000..37c1603 --- /dev/null +++ b/ios/Classes/Data/ErrorCodes.swift @@ -0,0 +1,48 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// ErrorCodes.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/8/23. +// + +import Foundation + +public class ErrorCodes { + // Method Call Rejection Codes + public static let InvalidParameter = "InvalidParameter" + public static let MissingParameter = "MissingParameter" + public static let UnexpectedParameterType = "UnexpectedParameterType" + public static let UninitializedSdk = "SdkUninitialized" + public static let ApplePayUnsupported = "ApplePayUnsupported" + public static let Unimplemented = "UNIMPLEMENTED" + public static let ViewNotFound = "ViewNotFound" + + // Do not rename this key... it maps to an OPError type, but we also + // use if for some other scenarios in the RN SDK + public static let GeneralError = "generalError" + + // API Exception Error Codes + public static let ApiError = "ApiError" + public static let InvalidRequest = "InvalidRequest" + public static let Connection = "ConnectionError" + public static let Cancellation = "CancellationError" + public static let Authentication = "AuthenticationError" + + // Card Exception Error Codes + public static let InvalidCardDetails = "InvalidCardDetails" + public static let InvalidNumber = "InvalidNumber" + public static let InvalidExpiration = "InvalidExpiration" + public static let InvalidCvv = "InvalidCVV" + public static let InvalidPostalCode = "InvalidPostalCode" + public static let ExpiredCard = "ExpiredCard" + public static let CardDeclined = "CardDeclined" + public static let ProcessingError = "ProcessingError" + public static let UnknownCard = "UnknownCardError" + + // Font Error Codes + public static let UnexpectedError = "UnexpectedError" + public static let AssetNotFoundError = "AssetNotFoundError" + public static let FontLoadError = "FontLoadError" +} diff --git a/ios/Classes/Data/OloError.swift b/ios/Classes/Data/OloError.swift new file mode 100644 index 0000000..fae487a --- /dev/null +++ b/ios/Classes/Data/OloError.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// FontError.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 2/21/24. +// + +import Foundation + +enum OloError: Error { + case AssetNotFoundError + case FontLoadError + case FontNameError + case UnexpectedError + case UnexpectedTypeError + case MissingKeyError + case EmptyValueError + case NullValueError +} diff --git a/ios/Classes/Extensions/DictionaryExtensions.swift b/ios/Classes/Extensions/DictionaryExtensions.swift new file mode 100644 index 0000000..8801594 --- /dev/null +++ b/ios/Classes/Extensions/DictionaryExtensions.swift @@ -0,0 +1,80 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// DictionaryExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/6/23. +// + +import Foundation +import OloPaySDK + +extension Dictionary where Key == String { + func getOrThrow(_ key: String, defaultValue: T) throws -> T { + do { + return try getOrThrow(key) + } catch OloError.MissingKeyError { + return defaultValue + } catch OloError.NullValueError { + return defaultValue + } + } + + func getOrThrow(_ key: String) throws -> T { + if (!keyExists(key)) { + throw OloError.MissingKeyError + } + + let valueIsNil = (self[key]) is NSNull + if valueIsNil { + throw OloError.NullValueError + } + + if let value: T = get(key) { + return value + } + + throw OloError.UnexpectedTypeError + } + + func get(_ key: String) -> T? { + guard let value = self[key] as? T else { + return nil + } + + return value + } + + func keyExists(_ key: String) -> Bool { + return self[key] != nil + } + + func getDictionary(_ key: String) -> Dictionary? { + guard let value = self[key] as? Dictionary else { + return nil + } + + return value + } + + func getArray(_ key: String) -> [Any]? { + guard let value = self[key] as? [Any] else { + return nil + } + + return value + } +} + +extension Dictionary { + public func toDictionary() -> [String: Any] { + return [ + OPCardField.number.flutterBridgeValue(): self[OPCardField.number]!.toDictionary(), + OPCardField.expiration.flutterBridgeValue(): self[OPCardField.expiration]!.toDictionary(), + OPCardField.cvv.flutterBridgeValue(): self[OPCardField.cvv]!.toDictionary(), + OPCardField.postalCode.flutterBridgeValue(): self[OPCardField.postalCode]!.toDictionary() + ] + } +} + diff --git a/ios/Classes/Extensions/FlutterDictionaryExtensions.swift b/ios/Classes/Extensions/FlutterDictionaryExtensions.swift new file mode 100644 index 0000000..3e27c66 --- /dev/null +++ b/ios/Classes/Extensions/FlutterDictionaryExtensions.swift @@ -0,0 +1,217 @@ +// +// FlutterDictionaryExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 4/25/24. +// + +import Foundation +import Flutter + +// MARK: Non-Null Extension Methods +extension Dictionary where Key == String { + func getOrErrorResult( + for key: String, + withDefault defaultValue: T, + baseError: String, + result: @escaping FlutterResult + ) throws -> T { + do { + return try self.getOrThrow(key, defaultValue: defaultValue) + } catch let error { + result(FlutterError( + code: ErrorCodes.UnexpectedParameterType, + message: "\(baseError): Value for '\(key)' is not of type \(String(describing: T.self))", + details: nil + )) + + throw error + } + } + + func getOrErrorResult( + for key: String, + baseError: String, + result: @escaping FlutterResult + ) throws -> T { + var errorMessage: String = "" + var errorCode: String = "" + var oloError: OloError? = nil + + do { + return try self.getOrThrow(key) + } catch let error as OloError where error == .MissingKeyError { + errorMessage = "\(baseError): Missing parameter '\(key)'" + errorCode = ErrorCodes.MissingParameter + oloError = error + } catch let error as OloError where error == .NullValueError { + errorMessage = "\(baseError): Missing parameter '\(key)'" + errorCode = ErrorCodes.MissingParameter + oloError = error + } catch { // OloError.UnexpectedTypeError + errorMessage = "\(baseError): Value for '\(key)' is not of type \(String(describing: T.self))" + errorCode = ErrorCodes.UnexpectedParameterType + oloError = OloError.UnexpectedTypeError + } + + result(FlutterError( + code: errorCode, + message: errorMessage, + details: nil + )) + + throw oloError! + } + + func getStringOrErrorResult( + for key: String, + withDefault defaultValue: String, + baseError: String, + acceptEmptyValue: Bool, + result: @escaping FlutterResult + ) throws -> String { + do { + var value: String = try self.getOrThrow(key, defaultValue: defaultValue) + value = value.trim() + + if !acceptEmptyValue && value.isEmpty { + value = defaultValue + } + + return value + } catch let error { + result(FlutterError( + code: ErrorCodes.UnexpectedParameterType, + message: "\(baseError): Value for '\(key)' is not of type String", + details: nil + )) + + throw error + } + } + + func getStringOrErrorResult( + for key: String, + baseError: String, + acceptEmptyValue: Bool, + result: @escaping FlutterResult + ) throws -> String { + var value: String = try self.getOrErrorResult( + for: key, + baseError: baseError, + result: result + ) + + value = value.trim() + + if !acceptEmptyValue && value.isEmpty { + result(FlutterError( + code: ErrorCodes.InvalidParameter, + message: "\(baseError): Value for '\(key)' cannot be empty", + details: nil + )) + throw OloError.EmptyValueError + } + + return value + } +} + +// MARK: Nullable Dictionary Extension Methods +extension Optional where Wrapped == Dictionary { + func getOrErrorResult( + for key: String, + withDefault defaultValue: T, + baseError: String, + result: @escaping FlutterResult + ) throws -> T { + guard let args = self else { + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: "\(baseError): Arguments dictionary is nil", + details: nil + )) + + throw OloError.NullValueError + } + + return try args.getOrErrorResult( + for: key, + withDefault: defaultValue, + baseError: baseError, + result: result + ) + } + + func getOrErrorResult( + for key: String, + baseError: String, + result: @escaping FlutterResult + ) throws -> T { + guard let args = self else { + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: "\(baseError): Arguments dictionary is nil", + details: nil + )) + + throw OloError.NullValueError + } + + return try args.getOrErrorResult( + for: key, + baseError: baseError, + result: result + ) + } + + func getStringOrErrorResult( + for key: String, + withDefault defaultValue: String, + baseError: String, + acceptEmptyValue: Bool, + result: @escaping FlutterResult + ) throws -> String { + guard let args = self else { + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: "\(baseError): Arguments dictionary is nil", + details: nil + )) + + throw OloError.NullValueError + } + + return try args.getStringOrErrorResult( + for: key, + withDefault: defaultValue, + baseError: baseError, + acceptEmptyValue: acceptEmptyValue, + result: result + ) + } + + func getStringOrErrorResult( + for key: String, + baseError: String, + acceptEmptyValue: Bool, + result: @escaping FlutterResult + ) throws -> String { + guard let args = self else { + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: "\(baseError): Arguments dictionary is nil", + details: nil + )) + + throw OloError.NullValueError + } + + return try args.getStringOrErrorResult( + for: key, + baseError: baseError, + acceptEmptyValue: acceptEmptyValue, + result: result + ) + } +} diff --git a/ios/Classes/Extensions/OPCardErrorTypeExtensions.swift b/ios/Classes/Extensions/OPCardErrorTypeExtensions.swift new file mode 100644 index 0000000..1b6868c --- /dev/null +++ b/ios/Classes/Extensions/OPCardErrorTypeExtensions.swift @@ -0,0 +1,36 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPCardErrorTypeExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 2/8/24. +// + +import Foundation +import OloPaySDK + +extension OPCardErrorType { + func flutterBridgeErrorType() -> String { + switch self { + case .invalidNumber: + return ErrorCodes.InvalidNumber + case .invalidExpMonth: + return ErrorCodes.InvalidExpiration + case .invalidExpYear: + return ErrorCodes.InvalidExpiration + case .invalidCvv: + return ErrorCodes.InvalidCvv + case .invalidZip: + return ErrorCodes.InvalidPostalCode + case .expiredCard: + return ErrorCodes.ExpiredCard + case .cardDeclined: + return ErrorCodes.CardDeclined + case .processingError: + return ErrorCodes.ProcessingError + case .unknownCardError: + return ErrorCodes.UnknownCard + } + } +} diff --git a/ios/Classes/Extensions/OPCardFieldExtensions.swift b/ios/Classes/Extensions/OPCardFieldExtensions.swift new file mode 100644 index 0000000..174b59f --- /dev/null +++ b/ios/Classes/Extensions/OPCardFieldExtensions.swift @@ -0,0 +1,28 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPCardFieldExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/13/23. +// + +import Foundation +import OloPaySDK + +extension OPCardField { + func flutterBridgeValue() -> String { + switch self { + case .number: + return DataKeys.CardNumberFieldValueKey + case .expiration: + return DataKeys.ExpirationFieldValueKey + case .cvv: + return DataKeys.CvvFieldValueKey + case .postalCode: + return DataKeys.PostalCodeFieldValueKey + case .unknown: + return "" + } + } +} diff --git a/ios/Classes/Extensions/OPCardFieldStateProtocolExtensions.swift b/ios/Classes/Extensions/OPCardFieldStateProtocolExtensions.swift new file mode 100644 index 0000000..8a3eac6 --- /dev/null +++ b/ios/Classes/Extensions/OPCardFieldStateProtocolExtensions.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPCardFieldStateProtocolExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/13/23. +// + +import Foundation +import OloPaySDK + +extension OPCardFieldStateProtocol { + public func toDictionary() -> [String: Any] { + return [ + DataKeys.IsValidKey: self.isValid, + DataKeys.IsFocusedKey: self.isFirstResponder, + DataKeys.IsEmptyKey: self.isEmpty, + DataKeys.WasEditedKey: self.wasEdited, + DataKeys.WasFocusedKey: self.wasFirstResponder + ] + } +} diff --git a/ios/Classes/Extensions/OPCvvUpdateTokenProtocolExtensions.swift b/ios/Classes/Extensions/OPCvvUpdateTokenProtocolExtensions.swift new file mode 100644 index 0000000..e426ba4 --- /dev/null +++ b/ios/Classes/Extensions/OPCvvUpdateTokenProtocolExtensions.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPCvvUpdateTokenProtocolExtensions.swift +// olo_pay_sdk +// +// Created by Richard Dowdy on 4/16/24. +// + +import Foundation +import OloPaySDK + + +extension OPCvvUpdateTokenProtocol { + public func toDictionary() -> [String: Any] { + return [ + DataKeys.IDKey: self.id, + DataKeys.ProductionEnvironmentKey: self.environment == .production + ] + } +} diff --git a/ios/Classes/Extensions/OPErrorExtensions.swift b/ios/Classes/Extensions/OPErrorExtensions.swift new file mode 100644 index 0000000..a7489b0 --- /dev/null +++ b/ios/Classes/Extensions/OPErrorExtensions.swift @@ -0,0 +1,34 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPErrorExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 2/8/24. +// + +import Foundation +import OloPaySDK + +extension OPError { + func flutterBridgeErrorType() -> String { + switch self.errorType { + case .connectionError: + return ErrorCodes.Connection + case .invalidRequestError: + return ErrorCodes.InvalidRequest + case .apiError: + return ErrorCodes.ApiError + case .cardError: + return self.cardErrorType!.flutterBridgeErrorType() + case .cancellationError: + return ErrorCodes.Cancellation + case .authenticationError: + return ErrorCodes.Authentication + case .generalError: + return ErrorCodes.GeneralError + case .applePayContextError: + return ErrorCodes.GeneralError //This should never happen + } + } +} diff --git a/ios/Classes/Extensions/OPPaymentMethodProtocolExtensions.swift b/ios/Classes/Extensions/OPPaymentMethodProtocolExtensions.swift new file mode 100644 index 0000000..1e7cc80 --- /dev/null +++ b/ios/Classes/Extensions/OPPaymentMethodProtocolExtensions.swift @@ -0,0 +1,29 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// OPPaymentMethodProtocolExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/8/23. +// + +import Foundation +import OloPaySDK + +private let InvalidExpiration = -1 + +extension OPPaymentMethodProtocol { + public func toDictionary() -> [String: Any] { + return [ + DataKeys.IDKey: self.id, + DataKeys.Last4Key: self.last4 ?? "", + DataKeys.CardTypeKey: self.cardType.description, + DataKeys.ExpirationMonthKey: self.expirationMonth ?? InvalidExpiration, + DataKeys.ExpirationYearKey: self.expirationYear ?? InvalidExpiration, + DataKeys.PostalCodeKey: self.postalCode ?? "", + DataKeys.CountryCodeKey: self.country ?? "", + DataKeys.IsDigitalWalletKey: self.isApplePay, + DataKeys.ProductionEnvironmentKey: self.environment == .production + ] + } +} diff --git a/ios/Classes/Extensions/StringExtensions.swift b/ios/Classes/Extensions/StringExtensions.swift new file mode 100644 index 0000000..41a0fef --- /dev/null +++ b/ios/Classes/Extensions/StringExtensions.swift @@ -0,0 +1,16 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// StringExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 3/6/24. +// + +import Foundation + +extension String { + func trim() -> String { + return self.trimmingCharacters(in: NSCharacterSet.whitespaces) + } +} diff --git a/ios/Classes/Extensions/UIColorExtensions.swift b/ios/Classes/Extensions/UIColorExtensions.swift new file mode 100644 index 0000000..7cf353c --- /dev/null +++ b/ios/Classes/Extensions/UIColorExtensions.swift @@ -0,0 +1,34 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// UIColorExtensions.swift +// olo_pay_sdk +// +// Created by Richard Dowdy on 12/18/23. +// + +import Foundation + +extension UIColor { + // Based Stripe React Native example: + // https://github.com/stripe/stripe-react-native/blob/master/ios/UIColorExtension.swift + public convenience init(hex: String) { + let a, r, g, b: UInt64 + let hexInt = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int = UInt64() + + Scanner(string: hexInt).scanHexInt64(&int) + switch hexInt.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + + self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255) + } +} diff --git a/ios/Classes/Extensions/UIFontExtensions.swift b/ios/Classes/Extensions/UIFontExtensions.swift new file mode 100644 index 0000000..221a2dd --- /dev/null +++ b/ios/Classes/Extensions/UIFontExtensions.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// UIFontExtensions.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 2/22/24. +// + +import Foundation + +extension UIFont { + public static func from(assetPath: String, with name: String?, size: Float) throws -> UIFont { + let cgFont = try registerFont(from: assetPath) + let cgSize = CGFloat(size) + + if let name = name, !name.isEmpty { + if let uiFont = UIFont(name: name, size: cgSize) { + return uiFont + } + } + + if let psName = cgFont.postScriptName as? String, !psName.isEmpty { + if let uiFont = UIFont(name: psName, size: cgSize) { + return uiFont + } + } + + if let fullName = cgFont.fullName as? String, !fullName.isEmpty { + if let uiFont = UIFont(name: fullName, size: cgSize) { + return uiFont + } + } + + throw OloError.FontNameError + } +} diff --git a/ios/Classes/Helpers/ErrorHandlingHelpers.swift b/ios/Classes/Helpers/ErrorHandlingHelpers.swift new file mode 100644 index 0000000..4274d20 --- /dev/null +++ b/ios/Classes/Helpers/ErrorHandlingHelpers.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// ErrorHandlingHelpers.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/8/23. +// + +import Foundation +import OloPaySDK +import Flutter + +func rejectError(error: Error, result: @escaping FlutterResult, defaultMessage: String = "Unexpected error occurred") { + guard let opError = error as? OPError else { + result(FlutterError(code: ErrorCodes.GeneralError, message: getErrorMessage(error: error, defaultMessage: defaultMessage), details: nil)) + return + } + + rejectError(error: opError, result: result) +} + +func rejectError(error: OPError, result: @escaping FlutterResult, defaultMessage: String = "Unexpected error occurred") { + let errorCode = error.flutterBridgeErrorType() + let errorMessage = getErrorMessage(error: error, defaultMessage: defaultMessage) + + result(FlutterError(code: errorCode, message: errorMessage, details: nil)) +} + +internal func getErrorMessage(error: Error, defaultMessage: String) -> String { + return error.localizedDescription.isEmpty ? defaultMessage : error.localizedDescription +} diff --git a/ios/Classes/Helpers/FontHelpers.swift b/ios/Classes/Helpers/FontHelpers.swift new file mode 100644 index 0000000..b8ba7d1 --- /dev/null +++ b/ios/Classes/Helpers/FontHelpers.swift @@ -0,0 +1,49 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// FontHelpers.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 2/16/24. +// + +import Foundation +import Flutter + +// Adapated from this tutorial: +// https://tonyowen.medium.com/sharing-fonts-between-flutter-and-native-a9d98517f372 +func registerFont(from assetPath: String) throws -> CGFont { + // Get the appDelegate for the app... this should never fail + guard let appDelegate = UIApplication.shared.delegate as? FlutterAppDelegate else { + throw OloError.UnexpectedError + } + + // Get the root view controller for the app... this should never fail + guard let viewController = appDelegate.window?.rootViewController as? FlutterViewController else { + throw OloError.UnexpectedError + } + + // Get the path to the flutter font asset + let relativeFontPath = viewController.lookupKey(forAsset: assetPath) + guard let fullFontPath = Bundle.main.path(forResource: relativeFontPath, ofType: nil) else { + throw OloError.AssetNotFoundError + } + + // Create a data provider with the font url + let url = URL(fileURLWithPath: fullFontPath, relativeTo: nil) + guard let dataProvider = CGDataProvider(url: url as CFURL) else { + throw OloError.FontLoadError + } + + // Load the font + guard let fontRef = CGFont(dataProvider) else { + throw OloError.FontLoadError + } + + // Register and return the font + // NOTE: There isn't a great way to distinguish between fonts that have already been + // registered and fonts that failed to be registered. We can still get the and attempt + // to use the font reference regardless of whether the register call succeeded or failed + CTFontManagerRegisterGraphicsFont(fontRef, nil) + return fontRef +} diff --git a/ios/Classes/Helpers/ThreadHelpers.swift b/ios/Classes/Helpers/ThreadHelpers.swift new file mode 100644 index 0000000..fd69b67 --- /dev/null +++ b/ios/Classes/Helpers/ThreadHelpers.swift @@ -0,0 +1,18 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// ThreadHelpers.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/20/23. +// + +import Foundation + +func dispatchToMainThreadIfNecessary(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } +} diff --git a/ios/Classes/OloPaySdkPlugin.swift b/ios/Classes/OloPaySdkPlugin.swift new file mode 100644 index 0000000..3542622 --- /dev/null +++ b/ios/Classes/OloPaySdkPlugin.swift @@ -0,0 +1,453 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import Flutter +import UIKit +import os +import os.log + +import OloPaySDK + +public class OloPaySdkPlugin: NSObject, FlutterPlugin, OPApplePayContextDelegate { + private let _logger = OSLog.init(subsystem: Bundle.main.bundleIdentifier!, category: DataKeys.BridgePrefix) + + private let _sdkInitializingSemaphore = DispatchSemaphore(value: 1) + private let _sdkInitializedSemaphore = DispatchSemaphore(value: 1) + private let _applePaySemaphore = DispatchSemaphore(value: 1) + + private var _applePayContext: OPApplePayContext? = nil + private var _applePayPaymentMethod: OloPaySDK.OPPaymentMethodProtocol? = nil + private var _initializeMetadataCalled = false + + private static var _methodChannel: FlutterMethodChannel? = nil + + //NOTE: Not private for testing purposes + internal var _applePayResult: FlutterResult? = nil + + // WARNING: NEVER ACCESS/MODIFY THIS VARIABLE DIRECTLY. USE THREAD-SAFE GETTERS AND SETTERS + private var _sdkInitialized = false + + var sdkInitialized: Bool { + get { + self._sdkInitializedSemaphore.wait() + let value = _sdkInitialized + self._sdkInitializedSemaphore.signal() + return value + } + set(newValue) { + self._sdkInitializedSemaphore.wait() + _sdkInitialized = newValue + self._sdkInitializedSemaphore.signal() + } + } + + public static func register(with registrar: FlutterPluginRegistrar) { + _methodChannel = FlutterMethodChannel(name: DataKeys.OloPaySdkMethodChannelKey, binaryMessenger: registrar.messenger()) + + let instance = OloPaySdkPlugin() + registrar.addMethodCallDelegate(instance, channel: _methodChannel!) + + let messenger = registrar.messenger() + + registrar.register( + PaymentCardDetailsSingleLineViewFactory(messenger: messenger), + withId: DataKeys.PaymentCardDetailsSingleLineViewKey + ) + + registrar.register( + PaymentCardCvvViewFactory(messenger: messenger), + withId: DataKeys.PaymentCardCvvViewKey + ) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case DataKeys.InitializeMethodKey: + initializeOloPay(call: call, result: result) + case DataKeys.InitializeMetadataMethodKey: + initializeMetadata(call: call, result: result) + case DataKeys.IsInitializedMethodKey: + isInitialized(result: result) + case DataKeys.IsDigitalWalletReadyMethodKey: + isApplePayReady(result: result) + case DataKeys.CreateDigitalWalletPaymentMethod: + getDigitalWalletPaymentMethod(call: call, result: result) + case DataKeys.GetFontNamesMethodKey: + getFontNamesForAssets(call: call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func initializeOloPay(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + DispatchQueue.global(qos: .background).async { + self._sdkInitializingSemaphore.wait() + + self.sdkInitialized = false + + var productionEnvironment: Bool = OloPaySdkPlugin.defaultProductionEnvironment + if args != nil { + do { + productionEnvironment = try args!.getOrErrorResult( + for: DataKeys.ProductionEnvironmentKey, + withDefault: OloPaySdkPlugin.defaultProductionEnvironment, + baseError: "Unable to initialize OloPaySdk", + result: result + ) + } catch { + return + } + } + + var merchantId: String? = nil + var companyLabel: String? = nil + let applePayArgs = args?.getDictionary(DataKeys.ApplePaySetupArgsKey) + + if let applePayArgs = applePayArgs { + let baseError = "Unable to initialize Apple Pay" + + do { + merchantId = try applePayArgs.getStringOrErrorResult( + for: DataKeys.ApplePayMerchantIdParameterKey, + baseError: baseError, + acceptEmptyValue: false, + result: result + ) + } catch { + return + } + + do { + companyLabel = try applePayArgs.getStringOrErrorResult( + for: DataKeys.ApplePayCompanyLabelParameterKey, + baseError: baseError, + acceptEmptyValue: false, + result: result + ) + } catch { + return + } + } + + let setupParameters = OPSetupParameters( + withEnvironment: productionEnvironment ? .production : .test, + withApplePayMerchantId: merchantId, + withApplePayCompanyLabel: companyLabel + ) + + OloPayApiInitializer().setup(with: setupParameters) { + self.sdkInitialized = true + self._sdkInitializingSemaphore.signal() + result(nil) + + if applePayArgs != nil { + dispatchToMainThreadIfNecessary { + OloPaySdkPlugin._methodChannel?.invokeMethod( + DataKeys.DigitalWalletReadyEventHandlerKey, + arguments: [DataKeys.DigitalWalletReadyParameterKey: OloPayAPI().deviceSupportsApplePay()] + ) + } + } + } + } + } + + private func initializeMetadata(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + if (!_initializeMetadataCalled) { + _initializeMetadataCalled = true + + var hybridVersion = defaultVersionString + var hybridBuildType = DataKeys.HybridBuildTypeInternalValue + + if args != nil { + let baseError = "Unable to initialize metadata" + + do { + hybridVersion = try args!.getStringOrErrorResult( + for: DataKeys.HybridSdkVersionKey, + withDefault: defaultVersionString, + baseError: baseError, + acceptEmptyValue: false, + result: result + ) + } catch { + return + } + + do { + hybridBuildType = try args!.getStringOrErrorResult( + for: DataKeys.HybridBuildTypeKey, + withDefault: DataKeys.HybridBuildTypeInternalValue, + baseError: baseError, + acceptEmptyValue: false, + result: result + ) + } catch { + return + } + } + + setSdkWrapperInfo(version: hybridVersion, buildType: hybridBuildType) + } + + result(nil) + } + + private func getFontNamesForAssets(call: FlutterMethodCall, result: @escaping FlutterResult) { + let baseError = "Unable to get font names" + + guard let args = call.arguments as? Dictionary else { + let message = "\(baseError): Missing parameter \(DataKeys.FontAssetListKey)" + + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: message, + details: nil + )) + + return + } + + var fontAssets: [Any] = [] + do { + fontAssets = try args.getOrErrorResult( + for: DataKeys.FontAssetListKey, + baseError: baseError, + result: result) + } catch { + return + } + + if (!registerFontAssets(assets: fontAssets, result: result)) { + return + } + + // Return font data for all installed fonts because there is no way to single out JUST the + // fonts that were dynamically installed + var fontData: [String : Any] = [:] + UIFont.familyNames.forEach { familyName in + fontData.updateValue(UIFont.fontNames(forFamilyName: familyName), forKey: familyName) + } + + result(fontData) + } + + private func registerFontAssets(assets assetList: [Any], result: @escaping FlutterResult) -> Bool { + var success = true + + for asset in assetList { + guard let fontAsset = asset as? String, !fontAsset.isEmpty else { + success = false + let error = "Unable to load font asset. Asset is not a string or is empty" + result(FlutterError(code: ErrorCodes.InvalidParameter, message: error, details: nil)) + break + } + + do { + let _ = try registerFont(from: fontAsset) + } catch OloError.AssetNotFoundError { + success = false + result(FlutterError(code: ErrorCodes.AssetNotFoundError, message: "Unable to find font asset: \(fontAsset)", details: nil)) + break + } catch OloError.FontLoadError { + success = false + result(FlutterError(code: ErrorCodes.FontLoadError, message: "Unable to load font asset: \(fontAsset)", details: nil)) + break + } catch { // OloError.UnexpectedError + success = false + result(FlutterError(code: ErrorCodes.UnexpectedError, message: "Unexpected asset error: \(fontAsset)", details: nil)) + break + } + } + + return success + } + + private func getDigitalWalletPaymentMethod(call: FlutterMethodCall, result: @escaping FlutterResult) { + let baseError = "Unable to create payment method" + + guard sdkInitialized else { + let error = "\(baseError): Olo Pay SDK has not been initialized" + result(FlutterError(code: ErrorCodes.UninitializedSdk, message: error, details: nil)) + return + } + + guard let args = call.arguments as? Dictionary else { + let message = "\(baseError): Missing parameter \(DataKeys.DigitalWalletAmountParameterKey)" + + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: message, + details: nil + )) + + return + } + + var amountDouble: Double = 0.0 + do { + amountDouble = try args.getOrErrorResult( + for: DataKeys.DigitalWalletAmountParameterKey, + baseError: baseError, + result: result) + } catch { + return + } + + guard amountDouble >= 0 else { + let error = "\(baseError): \(DataKeys.DigitalWalletAmountParameterKey) cannot be negative" + result(FlutterError(code: ErrorCodes.InvalidParameter, message: error, details: nil)) + return + } + + var countryCode = self.defaultCountryCode + do { + countryCode = try args.getStringOrErrorResult( + for: DataKeys.DigitalWalletCountryCodeParameterKey, + withDefault: self.defaultCountryCode, + baseError: baseError, + acceptEmptyValue: false, + result: result) + } catch { + return + } + + var currencyCode = self.defaultCurrencyCode + do { + currencyCode = try args.getStringOrErrorResult( + for: DataKeys.DigitalWalletCurrencyCodeParameterKey, + withDefault: self.defaultCurrencyCode, + baseError: baseError, + acceptEmptyValue: false, + result: result) + } catch { + return + } + + // NOTE: This deviceSupportsApplePay() check is moved here so we can + // test the logic around parameters passed into this call. + // We cannot write unit tests for anything beyond this point. + let oloPayApi = OloPayAPI() + guard oloPayApi.deviceSupportsApplePay() else { + let error = "\(baseError): Apple Pay is not supported on this device" + result(FlutterError(code: ErrorCodes.ApplePayUnsupported, message: error, details: nil)) + return + } + + DispatchQueue.global(qos: .userInteractive).async { + self._applePaySemaphore.wait() + + let amount = NSDecimalNumber(decimal: Decimal(amountDouble)) + + var errorMessage: String + + do { + let paymentRequest = try oloPayApi.createPaymentRequest(forAmount: amount, inCountry: countryCode, withCurrency: currencyCode) + guard let applePayContext = OPApplePayContext(paymentRequest: paymentRequest, delegate: self) else { + result(FlutterError(code: ErrorCodes.GeneralError, message: "Unexpected Error: Apple Pay Context is nil", details: nil)) + return + } + self._applePayContext = applePayContext + try self._applePayContext!.presentApplePay() + self._applePayResult = result + return + } catch { + errorMessage = error.localizedDescription + } + + let errorData = [ + DataKeys.DigitalWalletErrorMessageParameterKey: errorMessage, + DataKeys.DigitalWalletTypeParameterKey: DataKeys.DigitalWalletTypeParameterValue + ] + + self.applePayCleanup() + result(errorData) + } + } + + public func applePaymentMethodCreated(_ context: OloPaySDK.OPApplePayContextProtocol, didCreatePaymentMethod paymentMethod: OloPaySDK.OPPaymentMethodProtocol) -> NSError? { + guard _applePayResult != nil else { + os_log("Unexpected error: Saved method call resolver is nil", log: _logger) + return OPError(errorType: .generalError, description: "Unexpected error: Saved method call resolver is nil") + } + + _applePayPaymentMethod = paymentMethod + + // NOTE: This will trigger a success flow in the Apple Pay Sheet... If the actual order placement fails with the Olo Odering API it + // will be up to the app developer to display a different error in their app to let the end user know that the Apple Pay flow did not + // succeed. There is currenlty no way around this limitation. + return nil + } + + public func applePaymentCompleted(_ context: OPApplePayContextProtocol, didCompleteWith status: OPPaymentStatus, error: Error?) { + guard let applePayResult = _applePayResult else { + // If there is no saved resolver there is nothing to be done except log the problem and return + os_log("Unexpected error: Saved method call resolver is nil", log: _logger) + applePayCleanup() + return + } + + var applePayData: Dictionary? = nil + + if status == .error { + applePayData = [ + DataKeys.DigitalWalletErrorMessageParameterKey: error!.localizedDescription, + DataKeys.DigitalWalletTypeParameterKey: DataKeys.DigitalWalletTypeParameterValue + ] + } else if status == .success && _applePayPaymentMethod == nil { + applePayData = [ + DataKeys.DigitalWalletErrorMessageParameterKey: "Unexpected error: Payment method is nil", + DataKeys.DigitalWalletTypeParameterKey: DataKeys.DigitalWalletTypeParameterValue + ] + } else if status == .success && _applePayPaymentMethod != nil { + applePayData = _applePayPaymentMethod!.toDictionary() + } + + applePayCleanup() + applePayResult(applePayData) + } + + private func applePayCleanup() { + _applePayContext = nil + _applePayPaymentMethod = nil + _applePayResult = nil + _applePaySemaphore.signal() + } + + private func isInitialized(result: @escaping FlutterResult) { + result(self.sdkInitialized) + } + + private func isApplePayReady(result: @escaping FlutterResult) { + result(sdkInitialized && OloPayAPI().deviceSupportsApplePay()) + } + + private func setSdkWrapperInfo(version: String, buildType: String) { + let versionStrings = version.components(separatedBy: ".") + let majorVersionString = versionStrings.count > MajorVersionIndex ? versionStrings[MajorVersionIndex] : "0" + let minorVersionString = versionStrings.count > MinorVersionIndex ? versionStrings[MinorVersionIndex] : "0" + let buildVersionString = versionStrings.count > BuildVersionIndex ? versionStrings[BuildVersionIndex] : "0" + + let sdkWrapperInfo = OPSdkWrapperInfo( + withMajorVersion: Int(majorVersionString) ?? 0, + withMinorVersion: Int(minorVersionString) ?? 0, + withBuildVersion: Int(buildVersionString) ?? 0, + withSdkBuildType: buildType == DataKeys.HybridBuildTypePublicValue ? .publicBuild : .internalBuild, + withSdkPlatform: .flutter + ) + + OloPayAPI.sdkWrapperInfo = sdkWrapperInfo + } + + // Default values + private static let defaultProductionEnvironment = true + private let defaultCountryCode = "US" + private let defaultCurrencyCode = "USD" + private let MajorVersionIndex = 0 + private let MinorVersionIndex = 1 + private let BuildVersionIndex = 2 + private let defaultVersionString = "0.0.0" +} diff --git a/ios/Classes/PaymentCardCvvView/PaymentCardCvvView.swift b/ios/Classes/PaymentCardCvvView/PaymentCardCvvView.swift new file mode 100644 index 0000000..ca6eb96 --- /dev/null +++ b/ios/Classes/PaymentCardCvvView/PaymentCardCvvView.swift @@ -0,0 +1,365 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// PaymentCardCvvView.swift +// integration_test +// +// Created by Justin Anderson on 4/10/24. +// + +import Foundation +import Flutter +import UIKit +import OloPaySDK +import os.log + +class PaymentCardCvvView: NSObject, FlutterPlatformView, OPPaymentCardCvvViewDelegate { + private let _logger = OSLog.init(subsystem: Bundle.main.bundleIdentifier!, category: DataKeys.BridgePrefix) + + private var _customErrorMessages: CustomErrorMessages? = nil + private let _cvvView: OPPaymentCardCvvView + private let _methodChannel: FlutterMethodChannel + + init(withFrame frame: CGRect, viewId: Int64, args: Any?, messenger: FlutterBinaryMessenger) { + + _cvvView = OPPaymentCardCvvView(frame: frame) + _cvvView.displayGeneratedErrorMessages = false + + let channelName = "\(DataKeys.CvvBaseMethodChannelKey)\(viewId)" + _methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + + super.init() + _methodChannel.setMethodCallHandler(onMethodCall(call:result:)) + + _cvvView.cvvDetailsDelegate = self + + loadCustomArgs(args: args) + } + + var customErrorMessages: Dictionary? = nil { + didSet { + if let customErrorMessages: Dictionary = customErrorMessages { + _customErrorMessages = CustomErrorMessages(cvvCustomErrors: customErrorMessages as NSDictionary) + } else { + _customErrorMessages = nil + } + } + } + + func view() -> UIView { + return _cvvView + } + + private func onMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case DataKeys.CreateCvvTokenMethodKey: + createCvvUpdateToken(result: result) + case DataKeys.GetStateMethodKey: + getState(result: result) + case DataKeys.IsValidMethodKey: + isValid(result: result) + case DataKeys.SetEnabledMethodKey: + setEnabled(call: call, result: result) + case DataKeys.IsEnabledMethodKey: + isEnabled(result: result) + case DataKeys.HasErrorMessageMethodKey: + hasErrorMessage(call: call, result: result) + case DataKeys.GetErrorMessageMethodKey: + getErrorMessage(call: call, result: result) + case DataKeys.ClearFieldsMethodKey: + clear(result: result) + case DataKeys.RequestFocusMethodKey: + requestFocus(result: result) + case DataKeys.ClearFocusMethodKey: + clearFocus(result: result) + case DataKeys.RefreshUiMethodKey: + refreshUI(call: call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func createCvvUpdateToken(result: @escaping FlutterResult) { + guard let params = _cvvView.getCvvTokenParams() else { + result(FlutterError(code: ErrorCodes.InvalidCvv, message: getErrorMessage(false), details: nil)) + return + } + + OloPayAPI().createCvvUpdateToken(with: params) { cvvToken, error in + if (error != nil) { + rejectError(error: error!, result: result) + return + } + + guard let cvvToken = cvvToken else { + result(FlutterError(code: ErrorCodes.GeneralError, message: "Unexpected error occurred", details: nil)) + return + } + + result(cvvToken.toDictionary()) + } + } + + private func getState(result: @escaping FlutterResult) { + result(_cvvView.fieldState.toDictionary()) + } + + private func isValid(result: @escaping FlutterResult) { + result(_cvvView.isValid) + } + + private func setEnabled(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + do { + _cvvView.isEnabled = try args.getOrErrorResult( + for: DataKeys.EnabledParameterKey, + baseError: "Unable to set enabled state", + result: result + ) + + result(nil) + } catch { + return + } + } + + private func isEnabled(result: @escaping FlutterResult) { + result(_cvvView.isEnabled) + } + + private func hasErrorMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + do { + let ignoreUneditedField: Bool = try args.getOrErrorResult( + for: DataKeys.IgnoreUneditedFieldsParameterKey, + baseError: "Unable to check for error message", + result: result + ) + + result(_cvvView.hasErrorMessage(ignoreUneditedFieldErrors: ignoreUneditedField)) + } catch { + return + } + } + + private func getErrorMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + do { + let ignoreUneditedField: Bool = try args.getOrErrorResult( + for: DataKeys.IgnoreUneditedFieldsParameterKey, + baseError: "Unable to get error message", + result: result + ) + + result(self.getErrorMessage(ignoreUneditedField)) + } catch { + return + } + } + + private func getErrorMessage(_ ignoreUneditedFieldErrors: Bool = true) -> String { + if (_cvvView.isValid || !_cvvView.hasErrorMessage(ignoreUneditedFieldErrors: ignoreUneditedFieldErrors)) { + return "" + } + + let defaultError = CustomErrorMessages.getDefaultErrorMessage( + for: .cvv, + with: _cvvView.fieldState, + ignoreUneditedFieldErrors + ) + + var customError: String? = nil + + if let customErrorMessages = _customErrorMessages { + customError = customErrorMessages.getCustomErrorMessage( + for: .cvv, + with: _cvvView.fieldState, + ignoreUneditedFieldErrors + ) + } + + return customError ?? defaultError + } + + private func errorMessageHandler(_ cvvFieldState: OPCardFieldStateProtocol, _ ignoreUneditedFieldErrors: Bool) -> String { + return getErrorMessage(ignoreUneditedFieldErrors) + } + + private func clear(result: @escaping FlutterResult) { + _cvvView.clear() + result(nil) + } + + private func requestFocus(result: @escaping FlutterResult) { + result(_cvvView.becomeFirstResponder()) + } + + private func clearFocus(result: @escaping FlutterResult) { + result(_cvvView.resignFirstResponder()) + } + + + private func refreshUI(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + if let creationParams = args?.getDictionary(DataKeys.CreationParameters) { + loadCustomArgs(args: creationParams) + } + + result(nil) + } + + private func loadCustomArgs(args: Any?) { + guard let viewArgs = args as? Dictionary else { + return + } + + if let hints = viewArgs[DataKeys.HintsArgumentsKey] as? Dictionary { + if let cvvHint = hints[OPCardField.cvv.flutterBridgeValue()] as? String { + _cvvView.placeholderText = cvvHint + } + } + + if let textStyles = viewArgs[DataKeys.TextStylesArgumentsKey] as? Dictionary, !textStyles.isEmpty { + loadTextStyles(textStyles: textStyles) + } + + if let backgroundStyles = viewArgs[DataKeys.BackgroundStylesArgumentsKey] as? Dictionary, !backgroundStyles.isEmpty { + loadBackgroundStyles(backgroundStyles: backgroundStyles) + } + + if let customErrorMessages = viewArgs[DataKeys.CustomErrorMessagesArgumentsKey] as? Dictionary, !customErrorMessages.isEmpty { + if let cvvErrorMessages = customErrorMessages[OPCardField.cvv.flutterBridgeValue()] as? Dictionary, !cvvErrorMessages.isEmpty { + self.customErrorMessages = cvvErrorMessages + } else { + self.customErrorMessages = nil + } + } else { + self.customErrorMessages = nil + } + + OPPaymentCardCvvView.errorMessageHandler = errorMessageHandler(_:_:) + + if let textAlign = viewArgs[DataKeys.TextAlignmentKey] as? String { + let alignment = getTextAlignment(textAlign) + _cvvView.textAlignment = alignment + } + } + + private func loadTextStyles(textStyles: Dictionary) { + if let textColor = textStyles[DataKeys.TextColorKey] as? String, !textColor.isEmpty { + _cvvView.cvvTextColor = UIColor(hex: textColor) + } + + if let errorTextColor = textStyles[DataKeys.ErrorTextColorKey] as? String, !errorTextColor.isEmpty { + _cvvView.errorTextColor = UIColor(hex: errorTextColor) + } + + if let cursorColor = textStyles[DataKeys.CursorColorKey] as? String, !cursorColor.isEmpty { + _cvvView.cursorColor = UIColor(hex: cursorColor) + } + + if let hintTextColor = textStyles[DataKeys.HintTextColorKey] as? String, !hintTextColor.isEmpty { + _cvvView.placeholderColor = UIColor(hex: hintTextColor) + } + + if let fontSize = textStyles[DataKeys.TextSizeKey] as? Float { + // We need a font size in order to set a font... thankfully we always send a font size from the + // Flutter side of things + let fontName = textStyles[DataKeys.FontNameKey] as? String + + var font = UIFont.systemFont(ofSize: CGFloat(fontSize)) + + if let fontAsset = textStyles[DataKeys.FontAssetKey] as? String { + do { + font = try UIFont.from(assetPath: fontAsset, with: fontName, size: fontSize) + } catch OloError.AssetNotFoundError { + os_log("Font asset not found: %@", log: _logger, fontAsset) + } catch OloError.FontLoadError { + os_log("Unable to load font: %@", log: _logger, fontAsset) + } catch OloError.FontNameError { + os_log("Font name not found: %@", log: _logger, fontName ?? "") + } catch { + os_log("Unexpected error loading font: %@", log: _logger, fontAsset) + } + } + + _cvvView.cvvFont = UIFontMetrics.default.scaledFont(for: font) + } + } + + private func loadBackgroundStyles(backgroundStyles: Dictionary) { + if let backgroundColor = backgroundStyles[DataKeys.BackgroundColorKey] as? String, !backgroundColor.isEmpty { + _cvvView.backgroundColor = UIColor(hex: backgroundColor) + } + + if let borderColor = backgroundStyles[DataKeys.BorderColorKey] as? String, !borderColor.isEmpty { + _cvvView.borderColor = UIColor(hex: borderColor) + } + + if let borderWidth = backgroundStyles[DataKeys.BorderWidthKey] as? Double { + _cvvView.borderWidth = CGFloat(borderWidth) + } + + if let borderRadius = backgroundStyles[DataKeys.BorderRadiusKey] as? Double { + _cvvView.cornerRadius = CGFloat(borderRadius) + } + } + + func fieldChanged(with state: OPCardFieldStateProtocol) { + let args = [ + DataKeys.FieldStatesParameterKey: state.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnInputChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + func didBeginEditing(with state: OPCardFieldStateProtocol) { + let args = [ + DataKeys.FieldStatesParameterKey: state.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnFocusChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + func didEndEditing(with state: OPCardFieldStateProtocol) { + let args = [ + DataKeys.FieldStatesParameterKey: state.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnFocusChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + func validStateChanged(with state: OPCardFieldStateProtocol) { + let args = [ + DataKeys.FieldStatesParameterKey: state.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnValidStateChangedEventHandlerKey, arguments: args) + + emitErrorMessage() + } + + private func emitErrorMessage() { + _methodChannel.invokeMethod( + DataKeys.OnErrorMessageChangedEventHandlerKey, + arguments: getErrorMessage(true) + ) + } + + private func getTextAlignment(_ alignmentString: String) -> NSTextAlignment { + if(alignmentString == DataKeys.AlignmentRightKey) { + return NSTextAlignment.right + } else if(alignmentString == DataKeys.AlignmentCenterKey) { + return NSTextAlignment.center + } else { + return NSTextAlignment.left + } + } +} diff --git a/ios/Classes/PaymentCardCvvView/PaymentCardCvvViewFactory.swift b/ios/Classes/PaymentCardCvvView/PaymentCardCvvViewFactory.swift new file mode 100644 index 0000000..435d280 --- /dev/null +++ b/ios/Classes/PaymentCardCvvView/PaymentCardCvvViewFactory.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// PaymentCardCvvViewFactory.swift +// integration_test +// +// Created by Justin Anderson on 4/10/24. +// + +import Foundation +import Flutter + +class PaymentCardCvvViewFactory: NSObject, FlutterPlatformViewFactory { + private var messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return PaymentCardCvvView( + withFrame: frame, + viewId: viewId, + args: args, + messenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} diff --git a/ios/Classes/PaymentCardDetailsSingleLineView/PaymentCardDetailsSingleLineView.swift b/ios/Classes/PaymentCardDetailsSingleLineView/PaymentCardDetailsSingleLineView.swift new file mode 100644 index 0000000..811cb9e --- /dev/null +++ b/ios/Classes/PaymentCardDetailsSingleLineView/PaymentCardDetailsSingleLineView.swift @@ -0,0 +1,386 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// PaymentCardDetailsSingleLineView.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/7/23. +// + +import Foundation +import Flutter +import UIKit +import OloPaySDK +import os.log + +class PaymentCardDetailsSingleLineView: NSObject, FlutterPlatformView, OPPaymentCardDetailsViewDelegate { + private let _logger = OSLog.init(subsystem: Bundle.main.bundleIdentifier!, category: DataKeys.BridgePrefix) + + private var _customErrorMessages: CustomErrorMessages? = nil + private var _cardInputView: OPPaymentCardDetailsView + private let _methodChannel: FlutterMethodChannel + + init(withFrame frame: CGRect, viewId: Int64, args: Any?, messenger: FlutterBinaryMessenger) { + _cardInputView = OPPaymentCardDetailsView(frame: frame) + _cardInputView.displayGeneratedErrorMessages = false + + let channelName = "\(DataKeys.SingleLineBaseMethodChannelKey)\(viewId)" + _methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + + super.init() + _methodChannel.setMethodCallHandler(onMethodCall(call:result:)) + + _cardInputView.cardDetailsDelegate = self + + loadCustomArgs(args: args) + } + + var customErrorMessages: Dictionary? = nil { + didSet { + if let customErrorMessages: Dictionary = customErrorMessages { + _customErrorMessages = CustomErrorMessages(customErrorMessages: customErrorMessages as NSDictionary) + } else { + _customErrorMessages = nil + } + } + } + + func view() -> UIView { + return _cardInputView + } + + private func onMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case DataKeys.CreatePaymentMethodKey: + createPaymentMethod(result: result) + case DataKeys.GetStateMethodKey: + getState(result: result) + case DataKeys.IsValidMethodKey: + isValid(result: result) + case DataKeys.GetCardTypeMethodKey: + getCardType(result: result) + case DataKeys.SetEnabledMethodKey: + setEnabled(call: call, result: result) + case DataKeys.IsEnabledMethodKey: + isEnabled(result: result) + case DataKeys.HasErrorMessageMethodKey: + hasErrorMessage(call: call, result: result) + case DataKeys.GetErrorMessageMethodKey: + getErrorMessage(call: call, result: result) + case DataKeys.ClearFieldsMethodKey: + clearFields(result: result) + case DataKeys.RequestFocusMethodKey: + requestFocus(result: result) + case DataKeys.ClearFocusMethodKey: + clearFocus(result: result) + case DataKeys.RefreshUiMethodKey: + refreshUI(call: call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + private func createPaymentMethod(result: @escaping FlutterResult) { + guard let params = _cardInputView.getPaymentMethodParams() else { + result(FlutterError(code: getErrorCode(), message: getErrorMessage(false), details: nil)) + return + } + + OloPayAPI().createPaymentMethod(with: params) { paymentMethod, error in + if (error != nil) { + rejectError(error: error!, result: result) + return + } + + guard let paymentMethod = paymentMethod else { + result(FlutterError(code: ErrorCodes.GeneralError, message: "Unexpected error occurred", details: nil)) + return + } + + result(paymentMethod.toDictionary()) + } + } + + private func getState(result: @escaping FlutterResult) { + result(_cardInputView.fieldStates.toDictionary()) + } + + private func isValid(result: @escaping FlutterResult) { + result(_cardInputView.isValid) + } + + private func getCardType(result: @escaping FlutterResult) { + result(_cardInputView.cardType.description) + } + + private func setEnabled(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + do { + _cardInputView.isEnabled = try args.getOrErrorResult( + for: DataKeys.EnabledParameterKey, + baseError: "Unable to set enabled state", + result: result + ) + + result(nil) + } catch { + return + } + } + + private func isEnabled(result: @escaping FlutterResult) { + result(_cardInputView.isEnabled) + } + + private func hasErrorMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + guard let ignoreUneditedFields: Bool = args?.get(DataKeys.IgnoreUneditedFieldsParameterKey) else { + result(FlutterError( + code: ErrorCodes.MissingParameter, + message: "Missing Parameter \(DataKeys.IgnoreUneditedFieldsParameterKey)", + details: nil) + ) + return + } + + result(_cardInputView.hasErrorMessage(ignoreUneditedFields)) + } + + private func getErrorMessage(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + do { + let ignoreUneditedFields: Bool = try args.getOrErrorResult( + for: DataKeys.IgnoreUneditedFieldsParameterKey, + baseError: "Unable to get error message", + result: result + ) + + result(self.getErrorMessage(ignoreUneditedFields)) + } catch { + return + } + } + + private func getErrorMessage(_ ignoreUneditedFieldErrors: Bool = true) -> String { + if (_cardInputView.isValid || !_cardInputView.hasErrorMessage(ignoreUneditedFieldErrors)) { + return "" + } + + let defaultError = CustomErrorMessages.getDefaultErrorMessage( + ignoreUneditedFieldErrors, + _cardInputView.fieldStates, + _cardInputView.cardType + ) + + var customError: String? = nil + + if let customErrorMessages = _customErrorMessages { + customError = customErrorMessages.getCustomErrorMessage( + ignoreUneditedFieldErrors, + _cardInputView.fieldStates, + _cardInputView.cardType + ) + } + + return customError ?? defaultError + } + + private func errorMessageHandler(_ cardState: NSDictionary, _ cardBrand: OPCardBrand, _ ignoreUneditedFieldErrors: Bool) -> String { + return getErrorMessage(ignoreUneditedFieldErrors) + } + + private func clearFields(result: @escaping FlutterResult) { + _cardInputView.clear() + result(nil) + } + + private func requestFocus(result: @escaping FlutterResult) { + _cardInputView.becomeFirstResponder() + result(nil) + } + + private func clearFocus(result: @escaping FlutterResult) { + _cardInputView.resignFirstResponder() + result(nil) + } + + private func refreshUI(call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + + if let creationParams = args?.getDictionary(DataKeys.CreationParameters) { + loadCustomArgs(args: creationParams) + } + + result(nil) + } + + private func getErrorCode() -> String { + if !_cardInputView.fieldStates[OPCardField.number]!.isValid { + return ErrorCodes.InvalidNumber + } else if !_cardInputView.fieldStates[OPCardField.expiration]!.isValid { + return ErrorCodes.InvalidExpiration + } else if !_cardInputView.fieldStates[OPCardField.cvv]!.isValid { + return ErrorCodes.InvalidCvv + } else if !_cardInputView.fieldStates[OPCardField.postalCode]!.isValid { + return ErrorCodes.InvalidPostalCode + } + + return ErrorCodes.InvalidCardDetails + } + + func paymentCardDetailsViewDidChange(with fieldStates: NSDictionary, isValid: Bool) { + let args = [ + DataKeys.IsValidKey: isValid, + DataKeys.FieldStatesParameterKey: _cardInputView.fieldStates.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnInputChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + func paymentCardDetailsViewFieldDidBeginEditing(with fieldStates: NSDictionary, field: OPCardField, isValid: Bool) { + let args = [ + DataKeys.FocusedFieldParameterKey: field.flutterBridgeValue(), + DataKeys.IsValidKey: isValid, + DataKeys.FieldStatesParameterKey: _cardInputView.fieldStates.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnFocusChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + func paymentCardDetailsViewDidEndEditing(with fieldStates: NSDictionary, isValid: Bool) { + let args = [ + DataKeys.FocusedFieldParameterKey: OPCardField.unknown.flutterBridgeValue(), + DataKeys.IsValidKey: isValid, + DataKeys.FieldStatesParameterKey: _cardInputView.fieldStates.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnFocusChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + func paymentCardDetailsViewIsValidChanged(with fieldStates: NSDictionary, isValid: Bool) { + let args = [ + DataKeys.IsValidKey: isValid, + DataKeys.FieldStatesParameterKey: _cardInputView.fieldStates.toDictionary() + ] as [String : Any] + + _methodChannel.invokeMethod(DataKeys.OnValidStateChangedEventHandlerKey, arguments: args) + emitErrorMessage() + } + + private func emitErrorMessage() { + _methodChannel.invokeMethod( + DataKeys.OnErrorMessageChangedEventHandlerKey, + arguments: getErrorMessage(true) + ) + } + + private func loadCustomArgs(args: Any?) { + guard let viewArgs = args as? Dictionary else { + return + } + + if let hints = viewArgs[DataKeys.HintsArgumentsKey] as? Dictionary { + loadHints(hints: hints) + } + + if let textStyles = viewArgs[DataKeys.TextStylesArgumentsKey] as? Dictionary, !textStyles.isEmpty { + loadTextStyles(textStyles: textStyles) + } + + if let backgroundStyles = viewArgs[DataKeys.BackgroundStylesArgumentsKey] as? Dictionary, !backgroundStyles.isEmpty { + loadBackgroundStyles(backgroundStyles: backgroundStyles) + } + + if let customErrorMessages = viewArgs[DataKeys.CustomErrorMessagesArgumentsKey] as? Dictionary, !customErrorMessages.isEmpty { + self.customErrorMessages = customErrorMessages + } else { + self.customErrorMessages = nil + } + + OPPaymentCardDetailsView.errorMessageHandler = errorMessageHandler(_:_:_:) + } + + private func loadHints(hints: Dictionary) { + if let numberHint = hints[OPCardField.number.flutterBridgeValue()] { + _cardInputView.numberPlaceholder = numberHint + } + + if let expirationHint = hints[OPCardField.expiration.flutterBridgeValue()] { + _cardInputView.expirationPlaceholder = expirationHint + } + + if let cvvHint = hints[OPCardField.cvv.flutterBridgeValue()] { + _cardInputView.cvvPlaceholder = cvvHint + } + + if let postalCodeHint = hints[OPCardField.postalCode.flutterBridgeValue()] { + _cardInputView.postalCodePlaceholder = postalCodeHint + } + } + + private func loadTextStyles(textStyles: Dictionary) { + if let textColor = textStyles[DataKeys.TextColorKey] as? String, !textColor.isEmpty { + _cardInputView.textColor = UIColor(hex: textColor) + } + + if let errorTextColor = textStyles[DataKeys.ErrorTextColorKey] as? String, !errorTextColor.isEmpty { + _cardInputView.textErrorColor = UIColor(hex: errorTextColor) + } + + if let cursorColor = textStyles[DataKeys.CursorColorKey] as? String, !cursorColor.isEmpty { + _cardInputView.cursorColor = UIColor(hex: cursorColor) + } + + if let hintTextColor = textStyles[DataKeys.HintTextColorKey] as? String, !hintTextColor.isEmpty { + _cardInputView.placeholderColor = UIColor(hex: hintTextColor) + } + + if let fontSize = textStyles[DataKeys.TextSizeKey] as? Float { + // We need a font size in order to set a font... thankfully we always send a font size from the + // Flutter side of things + let fontName = textStyles[DataKeys.FontNameKey] as? String + + var font = UIFont.systemFont(ofSize: CGFloat(fontSize)) + + if let fontAsset = textStyles[DataKeys.FontAssetKey] as? String { + do { + font = try UIFont.from(assetPath: fontAsset, with: fontName, size: fontSize) + } catch OloError.AssetNotFoundError { + os_log("Font asset not found: %@", log: _logger, fontAsset) + } catch OloError.FontLoadError { + os_log("Unable to load font: %@", log: _logger, fontAsset) + } catch OloError.FontNameError { + os_log("Font name not found: %@", log: _logger, fontName ?? "") + } catch { + os_log("Unexpected error loading font: %@", log: _logger, fontAsset) + } + } + + _cardInputView.font = UIFontMetrics.default.scaledFont(for: font) + } + } + + private func loadBackgroundStyles(backgroundStyles: Dictionary) { + if let backgroundColor = backgroundStyles[DataKeys.BackgroundColorKey] as? String, !backgroundColor.isEmpty { + _cardInputView.backgroundColor = UIColor(hex: backgroundColor) + } + + if let borderColor = backgroundStyles[DataKeys.BorderColorKey] as? String, !borderColor.isEmpty { + _cardInputView.borderColor = UIColor(hex: borderColor) + } + + if let borderWidth = backgroundStyles[DataKeys.BorderWidthKey] as? Double { + _cardInputView.borderWidth = CGFloat(borderWidth) + } + + if let borderRadius = backgroundStyles[DataKeys.BorderRadiusKey] as? Double { + _cardInputView.cornerRadius = CGFloat(borderRadius) + } + } +} diff --git a/ios/Classes/PaymentCardDetailsSingleLineView/PaymentCardDetailsSingleLineViewFactory.swift b/ios/Classes/PaymentCardDetailsSingleLineView/PaymentCardDetailsSingleLineViewFactory.swift new file mode 100644 index 0000000..1fe4653 --- /dev/null +++ b/ios/Classes/PaymentCardDetailsSingleLineView/PaymentCardDetailsSingleLineViewFactory.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// +// PaymentCardDetailsSingleLineViewFactory.swift +// olo_pay_sdk +// +// Created by Justin Anderson on 12/7/23. +// + +import Foundation +import Flutter + +class PaymentCardDetailsSingleLineViewFactory: NSObject, FlutterPlatformViewFactory { + private var messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + return PaymentCardDetailsSingleLineView( + withFrame: frame, + viewId: viewId, + args: args, + messenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} diff --git a/ios/olo_pay_sdk.podspec b/ios/olo_pay_sdk.podspec new file mode 100644 index 0000000..90be244 --- /dev/null +++ b/ios/olo_pay_sdk.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint olo_pay_sdk.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'olo_pay_sdk' + s.version = '1.2.0' + s.summary = 'Flutter Olo Pay SDK' + s.description = <<-DESC +Flutter Olo Pay SDK + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Olo, Inc' => 'developersupport@olo.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.dependency "OloPaySDK", "4.0.2" + s.platform = :ios, '13.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/lib/olo_pay_sdk.dart b/lib/olo_pay_sdk.dart new file mode 100644 index 0000000..19c3b8d --- /dev/null +++ b/lib/olo_pay_sdk.dart @@ -0,0 +1,24 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +/// This is the main library for the Olo Pay SDK and contains all classes, enums, and data types used by the SDK. +/// The main entry point for working with the SDK is the [OloPaySdk] class. +/// +/// To use this library in your code, import it as follows +/// ```dart +/// import 'package:olo_pay_sdk/olo_pay_sdk.dart'; +/// ``` +/// +/// For convenience in working with the documentation, additional sub-libraries are provided that break out the classes +/// into the following categories: +/// - [olo_pay_sdk_data_classes] lists all data classes and enums in the SDK +/// - [olo_pay_sdk_data_types] lists all custom data types defined in the SDK +/// - [olo_pay_sdk_widgets] lists all widget and controller classes defined in the SDK +library; + +import 'package:olo_pay_sdk/src/public/olo_pay_sdk.dart'; + +// EXPORT ALL FILES NEEDED BY THE SDK +export 'package:olo_pay_sdk/src/public/olo_pay_sdk.dart'; +export 'package:olo_pay_sdk/olo_pay_sdk_data_classes.dart'; +export 'package:olo_pay_sdk/olo_pay_sdk_widgets.dart'; +export 'package:olo_pay_sdk/olo_pay_sdk_data_types.dart'; diff --git a/lib/olo_pay_sdk_data_classes.dart b/lib/olo_pay_sdk_data_classes.dart new file mode 100644 index 0000000..729df2a --- /dev/null +++ b/lib/olo_pay_sdk_data_classes.dart @@ -0,0 +1,31 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +/// This library contains links to all data classes and enums in the Olo Pay SDK. +/// +/// For most use cases you will want to import the [olo_pay_sdk] library instead of this one since it contains all +/// classes defined by the SDK, but it can be imported as follows: +/// +/// ```dart +/// import 'package:olo_pay_sdk/olo_pay_sdk_data_classes.dart'; +/// ``` +library; + +// Export all data classes +export 'package:olo_pay_sdk/src/public/data_classes/apple_pay_setup_parameters.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/background_styles.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/card_field_state.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/card_type.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/digital_wallet_payment_parameters.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/error_codes.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/google_pay_setup_parameters.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/google_pay_vendor_parameters.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/hints.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/olo_pay_setup_parameters.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/padding_styles.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/payment_method.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/text_field_alignment.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/custom_error_messages.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/custom_field_errors.dart'; +export 'package:olo_pay_sdk/src/public/data_classes/cvv_update_token.dart'; diff --git a/lib/olo_pay_sdk_data_types.dart b/lib/olo_pay_sdk_data_types.dart new file mode 100644 index 0000000..8b5fe6b --- /dev/null +++ b/lib/olo_pay_sdk_data_types.dart @@ -0,0 +1,14 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +/// This library contains links to all custom data types in the Olo Pay SDK. +/// +/// For most use cases you will want to import the [olo_pay_sdk] library instead of this one since it contains all +/// classes defined by the SDK, but it can be imported as follows: +/// +/// ```dart +/// import 'package:olo_pay_sdk/olo_pay_sdk_data_types.dart'; +/// ``` +library; + +// Export data types file +export 'package:olo_pay_sdk/src/public/data_types.dart'; diff --git a/lib/olo_pay_sdk_widgets.dart b/lib/olo_pay_sdk_widgets.dart new file mode 100644 index 0000000..e555c6a --- /dev/null +++ b/lib/olo_pay_sdk_widgets.dart @@ -0,0 +1,21 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +/// This library contains links to all widgets and their controllers in the Olo Pay SDK +/// +/// For most use cases you will want to import the [olo_pay_sdk] library instead of this one since it contains all +/// classes defined by the SDK, but it can be imported as follows: +/// +/// ```dart +/// import 'package:olo_pay_sdk/olo_pay_sdk_widgets.dart'; +/// ``` +library; + +// Export all widget classes + +// Single Line Classes +export 'package:olo_pay_sdk/src/public/widgets/single_line/card_details_single_line_text_field.dart'; +export 'package:olo_pay_sdk/src/public/widgets/single_line/card_details_single_line_text_field_controller.dart'; + +// CVV Classes +export 'package:olo_pay_sdk/src/public/widgets/cvv/cvv_text_field.dart'; +export 'package:olo_pay_sdk/src/public/widgets/cvv/cvv_text_field_controller.dart'; diff --git a/lib/src/private/data/creation_params.dart b/lib/src/private/data/creation_params.dart new file mode 100644 index 0000000..a11cf2c --- /dev/null +++ b/lib/src/private/data/creation_params.dart @@ -0,0 +1,43 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:collection/collection.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/background_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_error_messages.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/hints.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/padding_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_field_alignment.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; + +class CreationParams { + final Hints hints; + final TextStyles textStyles; + final BackgroundStyles backgroundStyles; + final PaddingStyles paddingStyles; + final CustomErrorMessages? customErrorMessages; + final TextFieldAlignment? textAlignment; + + const CreationParams({ + required this.hints, + required this.textStyles, + required this.backgroundStyles, + required this.paddingStyles, + this.customErrorMessages, + this.textAlignment, + }); + + bool isEqualTo(CreationParams? other) { + return const DeepCollectionEquality().equals(toMap(), other?.toMap()); + } + + Map toMap() { + return { + DataKeys.hintsArgumentsKey: hints.toMap(), + DataKeys.textStylesArgumentsKey: textStyles.toMap(), + DataKeys.backgroundStylesArgumentsKey: backgroundStyles.toMap(), + DataKeys.paddingStylesArgumentsKey: paddingStyles.toMap(), + DataKeys.customErrorMessages: customErrorMessages?.toMap(), + DataKeys.textAlignmentKey: textAlignment?.toString(), + }; + } +} diff --git a/lib/src/private/data/data_keys.dart b/lib/src/private/data/data_keys.dart new file mode 100644 index 0000000..8fede4d --- /dev/null +++ b/lib/src/private/data/data_keys.dart @@ -0,0 +1,161 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/cupertino.dart'; + +/// @nodoc +@protected +class DataKeys { + // Prefix Keys + static const bridgePrefix = "com.olo.flutter.olo_pay_sdk"; + static const cvvViewType = "PaymentCardCvvView"; + static const singleLineViewType = "PaymentCardDetailsSingleLineView"; + + // Method Channel Keys + static const cvvBaseMethodChannelKey = '$bridgePrefix/$cvvViewType:'; + static const oloPaySdkMethodChannelKey = '$bridgePrefix/sdk'; + static const singleLineBaseMethodChannelKey = + '$bridgePrefix/$singleLineViewType:'; + + // View Registration Keys + static const cvvViewTypeKey = '$bridgePrefix/$cvvViewType'; + static const singleLineViewTypeKey = '$bridgePrefix/$singleLineViewType'; + + // Native Method Keys + static const initializeMethodKey = "initialize"; + static const initializeMetadataKey = "initializeMetadata"; + static const initializeGooglePayMethodKey = "initializeGooglePay"; + static const changeGooglePayVendorMethodKey = "changeGooglePayVendor"; + static const isInitializedMethodKey = "isInitialized"; + static const isDigitalWalletReadyMethodKey = "isDigitalWalletReady"; + static const createPaymentMethodKey = "createPaymentMethod"; + static const createCvvUpdateToken = "createCvvUpdateToken"; + static const createDigitalWalletPaymentMethodKey = + "createDigitalWalletPaymentMethod"; + static const getStateMethodKey = "getState"; + static const isValidMethodKey = "isValid"; + static const getCardTypeMethodKey = "getCardType"; + static const setEnabledMethodKey = "setEnabled"; + static const isEnabledMethodKey = "isEnabled"; + static const hasErrorMessageMethodKey = "hasErrorMessage"; + static const getErrorMessageMethodKey = "getErrorMessage"; + static const clearFieldsMethodKey = "clearFields"; + static const requestFocusMethodKey = "requestFocus"; + static const clearFocusMethodKey = "clearFocus"; + static const refreshUiMethod = "refreshUI"; + static const getFontNamesMethodKey = "getFontNames"; + + // Native Method Parameter Keys + static const enabledParameterKey = "enabled"; + static const ignoreUneditedFieldsParameterKey = "ignoreUneditedFields"; + static const digitalWalletAmountParameterKey = "amount"; + static const digitalWalletCurrencyCodeKey = "currencyCode"; + static const googlePayCurrencyMultiplierKey = "currencyMultiplier"; + static const applePayMerchantIdParameterKey = "merchantId"; + static const applePayCompanyLabelParameterKey = "companyLabel"; + static const digitalWalletCountryCodeParameterKey = "countryCode"; + static const googlePayMerchantNameParameterKey = "merchantName"; + static const googlePayProductionEnvironmentParameterKey = + "googlePayProductionEnvironment"; + static const googlePayFullAddressFormatParameterKey = "fullAddressFormat"; + static const googlePayExistingPaymentMethodRequiredParameterKey = + "existingPaymentMethodRequired"; + static const googlePayEmailRequiredParameterKey = "emailRequired"; + static const googlePayPhoneNumberRequiredParameterKey = "phoneNumberRequired"; + static const digitalWalletReadyParameterKey = "isReady"; + static const digitalWalletErrorMessageParameterKey = "errorMessage"; + static const digitalWalletTypeParameterKey = "digitalWalletType"; + static const googlePayErrorTypeParameterKey = "googlePayErrorType"; + static const creationParameters = "creationParams"; + static const fontAssetListKey = "fontAssetList"; + + // Initialize Olo Pay Options Keys + static const productionEnvironmentKey = "productionEnvironment"; + static const freshInstallKey = "freshInstall"; + static const applePaySetupArgsKey = "applePaySetup"; + static const version = "version"; + static const buildType = "buildType"; + + // Payment Method Keys + static const pmIdKey = "id"; + static const pmLast4Key = "last4"; + static const pmCardTypeKey = "cardType"; + static const pmExpirationMonthKey = "expMonth"; + static const pmExpirationYearKey = "expYear"; + static const pmPostalCodeKey = "postalCode"; + static const pmCountryCodeKey = "countryCode"; + static const pmIsDigitalWalletKey = "isDigitalWallet"; + + // Cvv Update Token Keys + static const cvvIdKey = "id"; + + // Event Handler Keys + static const onFocusChangedEventHandlerKey = "onFocusChanged"; + static const onInputChangedEventHandlerKey = "onInputChanged"; + static const onValidStateChangedEventHandlerKey = "onValidStateChanged"; + static const onErrorMessageChangedEventHandlerKey = "onErrorMessageChanged"; + static const digitalWalletReadyEventHandlerKey = "digitalWalletReadyEvent"; + + // EventHandler Parameter Keys + static const focusedFieldParameterKey = "focusedField"; + static const fieldStatesParameterKey = "fieldStates"; + + // CardFieldState Keys + static const isValidKey = "isValid"; + static const isFocusedKey = "isFocused"; + static const isEmptyKey = "isEmpty"; + static const wasEditedKey = "wasEdited"; + static const wasFocusedKey = "wasFocused"; + + // Card field values (Matches with Android enums) + static const cardNumberFieldValue = "CardNumber"; + static const expirationFieldValue = "Expiration"; + static const cvvFieldValue = "Cvv"; + static const postalCodeFieldValue = "PostalCode"; + + // Text Styles Keys + static const textColorKey = "textColor"; + static const errorTextColorKey = "errorTextColor"; + static const cursorColorKey = "cursorColor"; + static const hintTextColorKey = "hintTextColor"; + static const textSizeKey = "textSize"; + static const textAlignmentKey = "textAlignment"; + static const fontAssetKey = "fontAsset"; + static const fontNameKey = "fontName"; + + // Card type enum values + static const visaFieldValue = "Visa"; + static const amexFieldValue = "Amex"; + static const discoverFieldValue = "Discover"; + static const masterCardFieldValue = "Mastercard"; + static const unsupportedCardFieldValue = "Unsupported"; + static const unknownCardFieldValue = "Unknown"; + + // Background Style Keys + static const backgroundColorKey = "backgroundColor"; + static const borderColorKey = "borderColor"; + static const borderWidthKey = "borderWidth"; + static const borderRadiusKey = "borderRadius"; + + // Padding Style Keys + static const startPaddingKey = "startPadding"; + static const endPaddingKey = "endPadding"; + static const topPaddingKey = "topPadding"; + static const bottomPaddingKey = "bottomPadding"; + + // View Initializer Argument Keys + static const hintsArgumentsKey = "hints"; + static const textStylesArgumentsKey = "textStyles"; + static const backgroundStylesArgumentsKey = "backgroundStyles"; + static const paddingStylesArgumentsKey = "paddingStyles"; + static const customErrorMessages = "customErrorMessages"; + + // Custom Error Message Keys + static const emptyError = "emptyError"; + static const invalidError = "invalidError"; + static const unsupportedCardError = "unsupportedCardError"; + + // Alignment Keys + static const left = "left"; + static const right = "right"; + static const center = "center"; +} diff --git a/lib/src/private/data/internal_error_codes.dart b/lib/src/private/data/internal_error_codes.dart new file mode 100644 index 0000000..b32f00f --- /dev/null +++ b/lib/src/private/data/internal_error_codes.dart @@ -0,0 +1,9 @@ +class InternalErrorCodes { + static const missingParameter = "MissingParameter"; + static const unexpectedParameterType = "UnexpectedParameterType"; + + static const all = { + missingParameter, + unexpectedParameterType, + }; +} diff --git a/lib/src/private/data/olo_pubspec.dart b/lib/src/private/data/olo_pubspec.dart new file mode 100644 index 0000000..894833a --- /dev/null +++ b/lib/src/private/data/olo_pubspec.dart @@ -0,0 +1,24 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:checked_yaml/checked_yaml.dart'; + +class OloPubspec { + final String buildType; + final String version; + + OloPubspec({ + required this.buildType, + required this.version, + }); + + factory OloPubspec.parse(String yaml, + {Uri? sourceUrl, bool lenient = false}) => + checkedYamlDecode( + yaml, + (map) => OloPubspec( + buildType: map!["buildType"], + version: Pubspec.fromJson(map).version!.canonicalizedVersion), + sourceUrl: sourceUrl, + ); +} diff --git a/lib/src/private/data/strings.dart b/lib/src/private/data/strings.dart new file mode 100644 index 0000000..370c093 --- /dev/null +++ b/lib/src/private/data/strings.dart @@ -0,0 +1,16 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/cupertino.dart'; + +/// @ndooc +@protected +class Strings { + static const String unexpectedError = "An unexpected error occurred"; + static const String unexpectedNullValue = + "An unexpected null value was returned"; + + static const String defaultCardNumberHint = "4242 4242 4242 4242"; + static const String defaultExpirationHint = "MM/YY"; + static const String defaultCvvHint = "CVV"; + static const String defaultPostalCodeHint = "Postal Code"; +} diff --git a/lib/src/private/extensions/color_extensions.dart b/lib/src/private/extensions/color_extensions.dart new file mode 100644 index 0000000..360d0f2 --- /dev/null +++ b/lib/src/private/extensions/color_extensions.dart @@ -0,0 +1,13 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// Adapted from here: https://stackoverflow.com/questions/55147586/flutter-convert-color-to-hex-string +import 'package:flutter/material.dart'; + +/// @nodoc +extension HexColor on Color { + String toHex() => '#' + '${alpha.toRadixString(16).padLeft(2, '0')}' + '${red.toRadixString(16).padLeft(2, '0')}' + '${green.toRadixString(16).padLeft(2, '0')}' + '${blue.toRadixString(16).padLeft(2, '0')}'; +} diff --git a/lib/src/private/extensions/method_channel_extensions.dart b/lib/src/private/extensions/method_channel_extensions.dart new file mode 100644 index 0000000..177a57e --- /dev/null +++ b/lib/src/private/extensions/method_channel_extensions.dart @@ -0,0 +1,41 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/src/private/factories/platform_exception_factory.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field_state.dart'; + +/// @nodoc +@protected +extension OloMethodChannel on MethodChannel { + /// @nodoc + Future?> invokeOloMapMethod(String method, + [dynamic arguments]) async { + try { + return await invokeMapMethod(method, arguments); + } on MissingPluginException catch (e) { + throw PlatformExceptionFactory.createFromError(error: e); + } + } + + /// @nodoc + Future invokeOloMethod(String method, [dynamic arguments]) async { + try { + return await invokeMethod(method, arguments); + } on MissingPluginException catch (e) { + throw PlatformExceptionFactory.createFromError(error: e); + } + } +} + +/// @nodoc +@protected +extension CardFieldStateMap on Map { + Map toFieldStateMap() { + return map((key, value) => MapEntry( + CardField.fromStringValue(key)!, + CardFieldState.fromMap(value)!, + )); + } +} diff --git a/lib/src/private/factories/platform_exception_factory.dart b/lib/src/private/factories/platform_exception_factory.dart new file mode 100644 index 0000000..38b1bde --- /dev/null +++ b/lib/src/private/factories/platform_exception_factory.dart @@ -0,0 +1,80 @@ +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/src/private/data/internal_error_codes.dart'; +import 'package:olo_pay_sdk/src/private/data/strings.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/error_codes.dart'; + +class PlatformExceptionFactory { + static PlatformException create({ + required String errorDetails, + String errorCode = ErrorCodes.unexpectedError, + String userMessage = Strings.unexpectedError, + StackTrace? trace, + bool shouldAssert = true, + }) { + if (shouldAssert) { + assert(false, errorDetails); + } + + return PlatformException( + code: errorCode, + message: userMessage, + details: errorDetails, + stacktrace: + trace != null ? trace.toString() : StackTrace.current.toString(), + ); + } + + static PlatformException createFromError({ + required Object error, + String defaultErrorCode = ErrorCodes.unexpectedError, + String defaultUserMessage = Strings.unexpectedError, + StackTrace? trace, + }) { + if (error is PlatformException) { + return createFromException(exception: error); + } + + if (error is MissingPluginException) { + assert(false, error.message); + } + + return PlatformException( + code: defaultErrorCode, + message: defaultUserMessage, + details: error.toString(), + stacktrace: + trace != null ? trace.toString() : StackTrace.current.toString(), + ); + } + + static PlatformException createFromException({ + required PlatformException exception, + String defaultErrorCode = ErrorCodes.unexpectedError, + String defaultUserMessage = Strings.unexpectedError, + }) { + assertException(exception); + + if (InternalErrorCodes.all.contains(exception.code)) { + return PlatformException( + code: defaultErrorCode, + message: defaultUserMessage, + details: exception.details, + stacktrace: exception.stacktrace, + ); + } + + // If the error doesn't use internal error codes, + // just return the same instance that as passed in + return exception; + } + + static assertError(Object error) { + if (error is PlatformException) { + assertException(error); + } + } + + static assertException(PlatformException exception) { + assert(!InternalErrorCodes.all.contains(exception.code), exception.details); + } +} diff --git a/lib/src/private/olo_pay_sdk_method_channel.dart b/lib/src/private/olo_pay_sdk_method_channel.dart new file mode 100644 index 0000000..890bd15 --- /dev/null +++ b/lib/src/private/olo_pay_sdk_method_channel.dart @@ -0,0 +1,243 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/data/olo_pubspec.dart'; +import 'package:olo_pay_sdk/src/private/extensions/method_channel_extensions.dart'; +import 'package:olo_pay_sdk/src/private/factories/platform_exception_factory.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/apple_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_type.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/digital_wallet_payment_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/error_codes.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_vendor_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/olo_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/payment_method.dart'; + +import 'olo_pay_sdk_platform_interface.dart'; + +class MethodChannelOloPaySdk extends OloPaySdkPlatform { + final _methodChannel = + const MethodChannel(DataKeys.oloPaySdkMethodChannelKey); + + @override + Future initializeOloPay({ + required OloPaySetupParameters oloPayParams, + ApplePaySetupParameters? applePayParams, + GooglePaySetupParameters? googlePayParams, + }) async { + _methodChannel.setMethodCallHandler(methodChannelHandler); + + final fileContent = await rootBundle.loadString( + "packages/olo_pay_sdk/pubspec.yaml", + ); + + final oloPubspec = OloPubspec.parse(fileContent); + String version = oloPubspec.version; + String buildType = oloPubspec.buildType; + + try { + await _methodChannel.invokeOloMethod(DataKeys.initializeMetadataKey, { + DataKeys.version: version, + DataKeys.buildType: buildType, + }); + } catch (e) { + // Intentional silent failure in production + // Metadata failure should not impact functionality of SDK + PlatformExceptionFactory.assertError(e); + } + + try { + if (defaultTargetPlatform == TargetPlatform.android) { + return await initializeOloPayAndroid( + oloPayParams: oloPayParams, + googlePayParams: googlePayParams, + ); + } + + if (defaultTargetPlatform == TargetPlatform.iOS) { + return await initializeOloPayIos( + oloPayParams: oloPayParams, + applePayParams: applePayParams, + ); + } + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + + throw UnimplementedError( + 'initializeOloPay() has not been implemented for platform: $defaultTargetPlatform'); + } + + @override + Future isOloPayInitialized() async { + try { + return await _methodChannel + .invokeOloMethod(DataKeys.isInitializedMethodKey) ?? + false; + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + @override + Future isDigitalWalletReady() async { + try { + return await _methodChannel + .invokeOloMethod(DataKeys.isDigitalWalletReadyMethodKey) ?? + false; + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + @override + Future createDigitalWalletPaymentMethod( + DigitalWalletPaymentParameters paymentParams) async { + Map? result; + + try { + result = await _methodChannel.invokeOloMapMethod( + DataKeys.createDigitalWalletPaymentMethodKey, + paymentParams.toMap(), + ); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + + if (result == null) { + return null; // User cancelled digital wallet operation + } + + if (result.containsKey(DataKeys.pmIdKey)) { + return PaymentMethod( + id: result[DataKeys.pmIdKey], + last4: result[DataKeys.pmLast4Key], + cardType: CardType.fromStringValue(result[DataKeys.pmCardTypeKey]), + expirationMonth: result[DataKeys.pmExpirationMonthKey], + expirationYear: result[DataKeys.pmExpirationYearKey], + postalCode: result[DataKeys.pmPostalCodeKey], + country: result[DataKeys.pmCountryCodeKey], + isDigitalWallet: result[DataKeys.pmIsDigitalWalletKey], + productionEnvironment: result[DataKeys.productionEnvironmentKey], + ); + } + + //An error occurred + String digitalWalletType = result[DataKeys.digitalWalletTypeParameterKey]; + String errorMessage = "$digitalWalletType: ${[ + DataKeys.digitalWalletErrorMessageParameterKey + ]}"; + String? errorCode = result[DataKeys.googlePayErrorTypeParameterKey]; + + throw PlatformExceptionFactory.create( + errorDetails: errorMessage, + errorCode: errorCode ?? ErrorCodes.generalError, + userMessage: errorMessage, + shouldAssert: false, + ); + } + + @override + Future changeGooglePayVendor( + GooglePayVendorParameters vendorParams) async { + try { + await _methodChannel.invokeOloMethod( + DataKeys.changeGooglePayVendorMethodKey, + vendorParams.toMap(), + ); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + @override + Future>> getFontNames( + List fontAssets) async { + Map>? result; + + try { + result = await _methodChannel.invokeOloMapMethod( + DataKeys.getFontNamesMethodKey, + { + DataKeys.fontAssetListKey: fontAssets, + }, + ); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + + if (result == null) { + return {}; + } + + final resultMap = >{}; + result.forEach((key, value) { + resultMap[key] = value.map((name) => name.toString()).toList(); + }); + + return resultMap; + } + + Future initializeOloPayAndroid({ + required OloPaySetupParameters oloPayParams, + GooglePaySetupParameters? googlePayParams, + }) async { + await _methodChannel.invokeOloMethod( + DataKeys.initializeMethodKey, + { + DataKeys.productionEnvironmentKey: oloPayParams.productionEnvironment, + }, + ); + + if (googlePayParams == null) { + return; + } + + await _methodChannel.invokeOloMethod( + DataKeys.initializeGooglePayMethodKey, + googlePayParams.toMap(), + ); + } + + Future initializeOloPayIos({ + required OloPaySetupParameters oloPayParams, + ApplePaySetupParameters? applePayParams, + }) async { + var applePayArgs = applePayParams?.toMap(); + + await _methodChannel.invokeOloMethod( + DataKeys.initializeMethodKey, + { + DataKeys.productionEnvironmentKey: oloPayParams.productionEnvironment, + DataKeys.applePaySetupArgsKey: applePayArgs, + }, + ); + } + + Future methodChannelHandler(MethodCall call) async { + if (call.method == DataKeys.digitalWalletReadyEventHandlerKey) { + onDigitalWalletReady + ?.call(call.arguments[DataKeys.digitalWalletReadyParameterKey]); + } + } +} diff --git a/lib/src/private/olo_pay_sdk_platform_interface.dart b/lib/src/private/olo_pay_sdk_platform_interface.dart new file mode 100644 index 0000000..75488f7 --- /dev/null +++ b/lib/src/private/olo_pay_sdk_platform_interface.dart @@ -0,0 +1,63 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/public/data_classes/apple_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/digital_wallet_payment_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_vendor_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/olo_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/payment_method.dart'; +import 'package:olo_pay_sdk/src/public/data_types.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'olo_pay_sdk_method_channel.dart'; + +abstract class OloPaySdkPlatform extends PlatformInterface { + OloPaySdkPlatform() : super(token: _token); + + static final Object _token = Object(); + + static OloPaySdkPlatform _instance = MethodChannelOloPaySdk(); + + static OloPaySdkPlatform get instance => _instance; + + static set instance(OloPaySdkPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + DigitalWalletReadyChanged? onDigitalWalletReady; + + Future initializeOloPay({ + required OloPaySetupParameters oloPayParams, + ApplePaySetupParameters? applePayParams, + GooglePaySetupParameters? googlePayParams, + }) async { + throw UnimplementedError('initializeOloPay() has not been implemented.'); + } + + Future isOloPayInitialized() async { + throw UnimplementedError('isOloPayInitialized() has not been implemented.'); + } + + Future isDigitalWalletReady() async { + throw UnimplementedError( + 'isDigitalWalletReady() has not been implemented.'); + } + + Future createDigitalWalletPaymentMethod( + DigitalWalletPaymentParameters paymentParams) async { + throw UnimplementedError( + 'createDigitalWalletPaymentMethod() has not been implemented'); + } + + Future changeGooglePayVendor( + GooglePayVendorParameters vendorParams) async { + throw UnimplementedError( + 'changeGooglePayVendor() has not been implemented'); + } + + Future>> getFontNames( + List fontAssets) async { + throw UnimplementedError('getFontNames() has not been implemented'); + } +} diff --git a/lib/src/public/data_classes/apple_pay_setup_parameters.dart b/lib/src/public/data_classes/apple_pay_setup_parameters.dart new file mode 100644 index 0000000..358e26d --- /dev/null +++ b/lib/src/public/data_classes/apple_pay_setup_parameters.dart @@ -0,0 +1,26 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +/// Parameters for initializing Apple Pay +class ApplePaySetupParameters { + /// The merchant id registered with Apple for Apple Pay + final String merchantId; + + /// The company name that will be displayed on the Apple Pay payment sheet + final String companyLabel; + + /// Create a new instance of this class to configure Apple Pay + const ApplePaySetupParameters({ + required this.merchantId, + required this.companyLabel, + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.applePayCompanyLabelParameterKey: companyLabel, + DataKeys.applePayMerchantIdParameterKey: merchantId + }; + } +} diff --git a/lib/src/public/data_classes/background_styles.dart b/lib/src/public/data_classes/background_styles.dart new file mode 100644 index 0000000..864b560 --- /dev/null +++ b/lib/src/public/data_classes/background_styles.dart @@ -0,0 +1,127 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; + +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/extensions/color_extensions.dart'; + +/// Defines background and border styles for widgets in the SDK +/// +/// Widgets using this class will use default values from the app's theme, as defined in [BackgroundStyles.merge] +class BackgroundStyles { + /// Default border width + /// + /// **Important:** Default values are only used in conjunction with [BackgroundStyles.merge] + static const defaultBorderWidth = 1.0; + + /// Default border radius + /// + /// **Important:** Default values are only used in conjunction with [BackgroundStyles.merge] + static const defaultBorderRadius = 3.0; + + /// Default border color + /// + /// **Important:** Default values are only used in conjunction with [BackgroundStyles.merge] + static const defaultBorderColor = Color.fromRGBO(189, 188, 188, 1); + + /// Default background color for light theme + /// + /// **Important:** Default values are only used in conjunction with [BackgroundStyles.merge] + static const defaultLightThemeBackgroundColor = Colors.white; + + /// Default background color for dark theme + /// + /// **Important:** Default values are only used in conjunction with [BackgroundStyles.merge] + static const defaultDarkThemeBackgroundColor = Colors.black; + + /// The background color of the widget + final Color? backgroundColor; + + /// The color of the widget's border + /// + /// **Important:** If a border is defined it will outline all four sides of the widget + final Color? borderColor; + + /// The width of the widget's border + /// + /// To remove the border entirely provide a value of `0.0` + /// + /// **Important:** If a border is defined it will outline all four sides of the widget + final double? borderWidth; + + /// The corner radius of the widget's border + /// + /// **Important:** If a border is defined it will outline all four sides of the widget + final double? borderRadius; + + /// Define custom background styles by providing values for each property + const BackgroundStyles({ + required this.backgroundColor, + required this.borderColor, + required this.borderWidth, + required this.borderRadius, + }); + + /// Define custom background styles by providing values for only the properties you want to customize + const BackgroundStyles.only({ + this.backgroundColor, + this.borderColor, + this.borderWidth, + this.borderRadius, + }); + + /// Define custom background styles by merging [otherStyles] and values from [theme] + /// + /// Assigns values to all properties on this class, filling in `null` properties from [otherStyles] with values + /// from [theme], as follows: + /// - [BackgroundStyles.backgroundColor] + /// 1. Attempts to use `theme.inputDecorationTheme.fillColor` first + /// 1. Attempts to use `theme.colorScheme.background` second + /// 1. If the above values are `null` and [theme] has a brightness of [Brightness.dark], it falls back to [BackgroundStyles.defaultDarkThemeBackgroundColor] + /// 1. If the above values are `null` and [theme] is `null` or [theme] has a brightness of [Brightness.light], it falls back to [BackgroundStyles.defaultLightThemeBackgroundColor] + /// - [BackgroundStyles.borderColor] + /// 1. Attempts to use `theme.inputDecorationTheme.outlineBorder.color` first + /// 1. Attempts to use `theme.colorScheme.onBackground` second + /// 1. If [theme] or either of the above theme values are `null` it falls back to `Color.fromRGBO(189, 188, 188, 1)` + /// - [BackgroundStyles.borderWidth] + /// 1. Attempts to use `theme.inputDecorationTheme.outlineBorder.width` first + /// 1. If either [theme] or the above theme value are `null` it falls back to `1.0` + /// - [BackgroundStyles.borderRadius] + /// 1. If not defined by [otherStyles], defaults to `3.0` + /// + /// **Important:** This should not generally need to be called directly, as it is called by the widgets that take + /// [BackgroundStyles] as a parameter + factory BackgroundStyles.merge({ + BackgroundStyles? otherStyles, + required ThemeData? theme, + }) { + var defaultBackgroundColor = + (theme != null && theme.brightness == Brightness.dark) + ? defaultDarkThemeBackgroundColor + : defaultLightThemeBackgroundColor; + + return BackgroundStyles( + backgroundColor: otherStyles?.backgroundColor ?? + theme?.inputDecorationTheme.fillColor ?? + theme?.colorScheme.background ?? + defaultBackgroundColor, + borderColor: otherStyles?.borderColor ?? + theme?.inputDecorationTheme.outlineBorder?.color ?? + theme?.colorScheme.onBackground ?? + defaultBorderColor, + borderWidth: otherStyles?.borderWidth ?? + theme?.inputDecorationTheme.outlineBorder?.width ?? + defaultBorderWidth, + borderRadius: otherStyles?.borderRadius ?? defaultBorderRadius); + } + + /// @nodoc + Map toMap() { + return { + DataKeys.backgroundColorKey: backgroundColor?.toHex(), + DataKeys.borderColorKey: borderColor?.toHex(), + DataKeys.borderWidthKey: borderWidth, + DataKeys.borderRadiusKey: borderRadius, + }; + } +} diff --git a/lib/src/public/data_classes/card_field.dart b/lib/src/public/data_classes/card_field.dart new file mode 100644 index 0000000..3449e7c --- /dev/null +++ b/lib/src/public/data_classes/card_field.dart @@ -0,0 +1,46 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +/// Enum representing the different fields of a credit card +enum CardField { + /// The card number field + cardNumber(stringValue: DataKeys.cardNumberFieldValue), + + /// The expiration field + expiration(stringValue: DataKeys.expirationFieldValue), + + /// The cvv field + cvv(stringValue: DataKeys.cvvFieldValue), + + /// The postal code field + postalCode(stringValue: DataKeys.postalCodeFieldValue); + + /// @nodoc + const CardField({required this.stringValue}); + + /// The string value of this enum + final String stringValue; + + /// @nodoc + static CardField? fromStringValue(String value) { + switch (value) { + case DataKeys.cardNumberFieldValue: + return CardField.cardNumber; + case DataKeys.expirationFieldValue: + return CardField.expiration; + case DataKeys.cvvFieldValue: + return CardField.cvv; + case DataKeys.postalCodeFieldValue: + return CardField.postalCode; + default: + return null; + } + } + + /// @nodoc + @override + String toString() { + return stringValue; + } +} diff --git a/lib/src/public/data_classes/card_field_state.dart b/lib/src/public/data_classes/card_field_state.dart new file mode 100644 index 0000000..46c0107 --- /dev/null +++ b/lib/src/public/data_classes/card_field_state.dart @@ -0,0 +1,58 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; + +/// Class representing the state of a [CardField] +class CardFieldState { + /// The field is in a valid state + final bool isValid; + + /// The field is currently focused + final bool isFocused; + + /// The field is currently empty + final bool isEmpty; + + /// The field was previously edited + final bool wasEdited; + + /// The field was previously focused + final bool wasFocused; + + /// @nodoc + const CardFieldState({ + required this.isValid, + required this.isFocused, + required this.isEmpty, + required this.wasEdited, + required this.wasFocused, + }); + + /// @nodoc + static CardFieldState? fromMap(Map map) { + try { + return CardFieldState( + isValid: map[DataKeys.isValidKey], + isFocused: map[DataKeys.isFocusedKey], + isEmpty: map[DataKeys.isEmptyKey], + wasEdited: map[DataKeys.wasEditedKey], + wasFocused: map[DataKeys.wasFocusedKey], + ); + } catch (e) { + return null; + } + } + + /// @nodoc + @override + String toString() { + return ''' + isValid: $isValid + isFocused: $isFocused + isEmpty: $isEmpty + wasEdited: $wasEdited + wasFocused: $wasFocused + '''; + } +} diff --git a/lib/src/public/data_classes/card_type.dart b/lib/src/public/data_classes/card_type.dart new file mode 100644 index 0000000..2ebb267 --- /dev/null +++ b/lib/src/public/data_classes/card_type.dart @@ -0,0 +1,59 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +/// Enum representing credit card types +/// +/// **_Important:_** When submitting card details to Olo's ordering API +/// the card type value passed should be the [stringValue] of this enum. +enum CardType { + /// Visa + visa(stringValue: DataKeys.visaFieldValue), + + /// American Express + americanExpress(stringValue: DataKeys.amexFieldValue), + + /// Discover + discover(stringValue: DataKeys.discoverFieldValue), + + /// Mastercard + masterCard(stringValue: DataKeys.masterCardFieldValue), + + /// Unsupported: The card brand is not supported by Olo Pay + unsupported(stringValue: DataKeys.unsupportedCardFieldValue), + + /// Unknown: The card brand could not be determined + unknown(stringValue: DataKeys.unknownCardFieldValue); + + /// The string value of the enum. + /// + /// This is the value that should be used when submitting card data to Olo's Ordering API. + final String stringValue; + + /// @nodoc + const CardType({required this.stringValue}); + + /// @nodoc + static CardType fromStringValue(String? stringValue) { + switch (stringValue) { + case DataKeys.visaFieldValue: + return CardType.visa; + case DataKeys.amexFieldValue: + return CardType.americanExpress; + case DataKeys.discoverFieldValue: + return CardType.discover; + case DataKeys.masterCardFieldValue: + return CardType.masterCard; + case DataKeys.unsupportedCardFieldValue: + return CardType.unsupported; + default: + return CardType.unknown; + } + } + + /// @nodoc + @override + String toString() { + return stringValue; + } +} diff --git a/lib/src/public/data_classes/custom_error_messages.dart b/lib/src/public/data_classes/custom_error_messages.dart new file mode 100644 index 0000000..8f4f21f --- /dev/null +++ b/lib/src/public/data_classes/custom_error_messages.dart @@ -0,0 +1,54 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_field_errors.dart'; + +/// Defines custom error messages for the widget +class CustomErrorMessages { + /// Used to define empty and invalid error messages for the card number field + final CustomFieldErrors? number; + + /// Used to define empty and invalid error messages for the card expiration field + final CustomFieldErrors? expiration; + + /// Used to define empty and invalid error messages for the CVV field + final CustomFieldErrors? cvv; + + /// Used to define empty and invalid error messages for the postal code field + final CustomFieldErrors? postalCode; + + /// Custom error message for an unsupported card type + /// + /// Providing any string, including an empty string, will overwrite the default error. + /// If this value is `null`, the default error will be used. + final String? unsupportedCardError; + + /// Create custom error messages by providing values for each field + const CustomErrorMessages({ + required this.number, + required this.expiration, + required this.cvv, + required this.postalCode, + required this.unsupportedCardError, + }); + + /// Create custom error messages for only the desired fields + const CustomErrorMessages.only({ + this.number, + this.expiration, + this.cvv, + this.postalCode, + this.unsupportedCardError, + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.cardNumberFieldValue: number?.toMap(), + DataKeys.expirationFieldValue: expiration?.toMap(), + DataKeys.cvvFieldValue: cvv?.toMap(), + DataKeys.postalCodeFieldValue: postalCode?.toMap(), + DataKeys.unsupportedCardError: unsupportedCardError, + }; + } +} diff --git a/lib/src/public/data_classes/custom_field_errors.dart b/lib/src/public/data_classes/custom_field_errors.dart new file mode 100644 index 0000000..9ba48e9 --- /dev/null +++ b/lib/src/public/data_classes/custom_field_errors.dart @@ -0,0 +1,38 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +/// Defines custom error messages for different field error types +class CustomFieldErrors { + /// Custom error messge for when the field is empty + /// + /// Providing any string, including an empty string, will overwrite the default error. + /// If this value is `null`, the default error will be used. + final String? emptyError; + + /// Custom error message for when the field is invalid + /// + /// Providing any string, including an empty string, will overwrite the default error. + /// If this value is `null`, the default error will be used. + final String? invalidError; + + /// Create custom error messages by providing values for each error type + const CustomFieldErrors({ + required this.emptyError, + required this.invalidError, + }); + + /// Create custom error messages by providing values for only the desired error type(s) + const CustomFieldErrors.only({ + this.emptyError, + this.invalidError, + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.emptyError: emptyError, + DataKeys.invalidError: invalidError, + }; + } +} diff --git a/lib/src/public/data_classes/cvv_update_token.dart b/lib/src/public/data_classes/cvv_update_token.dart new file mode 100644 index 0000000..bd26551 --- /dev/null +++ b/lib/src/public/data_classes/cvv_update_token.dart @@ -0,0 +1,29 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +/// Represents a CVV update token containing all the information needed to submit a baske via Olo's Ordering API +class CvvUpdateToken { + /// The CVV update token id. + /// + /// This should be set to the token field when submiting a basket + final String id; + + /// Whether or not this CVV update token was created in the production environment + final bool productionEnvironment; + + /// Creates an instance of a CVV update token. + /// + /// **_Important:_** Other than for testing purposes, there should generally be no reason to create an insance of this class. + const CvvUpdateToken({ + required this.id, + required this.productionEnvironment, + }); + + /// @nodoc + @override + String toString() { + return ''' + id: $id + productionEnvironment: $productionEnvironment + '''; + } +} diff --git a/lib/src/public/data_classes/digital_wallet_payment_parameters.dart b/lib/src/public/data_classes/digital_wallet_payment_parameters.dart new file mode 100644 index 0000000..311fc62 --- /dev/null +++ b/lib/src/public/data_classes/digital_wallet_payment_parameters.dart @@ -0,0 +1,61 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/public/olo_pay_sdk.dart'; + +/// Parameters used to create a digital wallet payment method +/// +/// **Important:** Some properties are platform-specific, but it is safe to pass them to both platforms. Refer to each +/// property to ensure these parameters are set up properly for your use case. +class DigitalWalletPaymentParameters { + /// The amount to be charged + final double amount; + + /// **_(Android Only)_** Multiplier to convert the amount to the currency's smallest unit + /// + /// Google Pay requires the amount to be specified in terms of the currency's smallest unit (e.g. pennies for USD). + /// The Olo Pay SDK does that calculation for you. In most cases the currency multiplier is going to be 100, and + /// is the default value if this property is not specified in the constructor. + /// + /// Example: $2.34 * 100 = 234 cents. + /// + /// **Important:** This property is ignored on iOS + final int currencyMultiplier; + + /// A three character currency code for the transaction (e.g. "USD") + /// + /// If this property is not specified in the contstructor, "USD" will be used as the default + final String currencyCode; + + /// **_(iOS Only)_** A two character country code representing the country of the vendor + /// + /// This property is only needed for iOS because it is provided for Android when Google Pay is initialized. To change + /// the country code used by Google Pay, please see [OloPaySdk.changeGooglePayVendor]. If not specified in the + /// constructor, "US" will be used as the default. + /// + /// **Important:** This property is ignored on Android + final String countryCode; + + /// Create digital wallet payment parameters + /// + /// Optional parameters will result in the following default values being used if not specified: + /// - [currencyCode] : "USD" + /// - [currencyMultiplier] : 100 + /// - [countryCode] : "US" + const DigitalWalletPaymentParameters({ + required this.amount, + this.currencyCode = "USD", + this.currencyMultiplier = 100, + this.countryCode = "US", + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.digitalWalletAmountParameterKey: amount, + DataKeys.digitalWalletCountryCodeParameterKey: countryCode, + DataKeys.digitalWalletCurrencyCodeKey: currencyCode, + DataKeys.googlePayCurrencyMultiplierKey: currencyMultiplier + }; + } +} diff --git a/lib/src/public/data_classes/error_codes.dart b/lib/src/public/data_classes/error_codes.dart new file mode 100644 index 0000000..1788cf9 --- /dev/null +++ b/lib/src/public/data_classes/error_codes.dart @@ -0,0 +1,119 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/services.dart'; + +/// A list of all possible error codes that can be used in conjunction with [PlatformException] +/// +/// Olo Pay methods can throw a [PlatformException]. If they do, they will contain +/// a `code` property indicating the cause for the exception. The possible error codes are +/// defined in this class. +class ErrorCodes { + /// An operation was attempted without first initializing the Olo Pay SDK + static const uninitializedSdk = "SdkUninitialized"; + + /// @Deprecated('All previous usages of this parameter have been changed to use [invalidParameter]) + static const missingParameter = "MissingParameter"; + + /// A parameter is invalid + /// + /// This can occur if passing a parameter with an incorrect value (e.g. a negative payment amount or an empty string) + static const invalidParameter = "InvalidParameter"; + + /// Something really unexpected happened + /// + /// This error is not common and would usually indicate a problem with the state of the Flutter plugin outside + /// of our control + static const unexpectedError = "UnexpectedError"; + + /// A general-purpose error occured + static const generalError = "generalError"; + + /// A general-purpose API error + /// + /// This error is not common and usually indicates a server-side problem + static const apiError = "ApiError"; + + /// A request has invalid parameters + /// + /// This error is not common. It could indicate a server-side problem or + /// a change is needed to the Olo Pay SDK. + static const invalidRequest = "InvalidRequest"; + + /// An error occurred connecting to servers + /// + /// This error is not common and usually indicates a server-side problem. + static const connection = "ConnectionError"; + + /// The operation was cancelled + /// + /// This error is not common and indicates a request was cancelled. + static const cancellation = "CancellationError"; + + /// There was a problem with authentication + /// + /// This error is not common and indicates a change may be needed + /// to the Olo Pay SDK. + static const authentication = "AuthenticationError"; + + /// A rate-limiting error occurred + /// + /// This error is not common. + static const rateLimit = "RateLimitError"; + + /// The card details are invalid + /// + /// This error is not common. The most common card errors indicate what the problem is with the card, such as + /// [invalidNumber], [invalidExpiration], [invalidCvv], or [invalidPostalCode]. + static const invalidCardDetails = "InvalidCardDetails"; + + /// The card number is invalid + static const invalidNumber = "InvalidNumber"; + + /// The expiration date is invalid + static const invalidExpiration = "InvalidExpiration"; + + /// The cvv field is invalid + static const invalidCvv = "InvalidCVV"; + + /// The postal code field is invalid + static const invalidPostalCode = "InvalidPostalCode"; + + /// The card is expired + static const expiredCard = "ExpiredCard"; + + /// The card was declined. + static const cardDeclined = "CardDeclined"; + + /// An error occurred while processing the card details + static const processingError = "ProcessingError"; + + /// An unknown card error occurred + static const unknownCard = "UnknownCardError"; + + /// An Apple Pay operation was attempted on a device that doesn't support Apple Pay + static const applePayUnsupported = "ApplePayUnsupported"; + + /// The Application is not properly configured to use Google Pay + static const invalidGooglePaySetup = "InvalidGooglePaySetup"; + + /// A Google Pay operation was attempted when it wasn't ready yet + static const googlePayNotReady = "GooglePayNotReady"; + + /// A Google Pay operation was attempted prior to it being initialized + static const googlePayUninitialized = "GooglePayUninitialized"; + + /// An internal Google Pay error occurred + static const googlePayInternalError = "InternalError"; + + /// An error occurred because the Google Pay was not configured correctly + static const googlePayDeveloperError = "DeveloperError"; + + /// A network error occurred while communicating with Google Pay servers + static const googlePayNetworkError = "NetworkError"; + + /// The font asset could not be found. Ensure the asset is defined in pubspec.yaml + static const assetNotFound = "AssetNotFoundError"; + + /// The font could not be loaded. The file is either corrupted or unsupported + static const fontLoadError = "FontLoadError"; +} diff --git a/lib/src/public/data_classes/google_pay_setup_parameters.dart b/lib/src/public/data_classes/google_pay_setup_parameters.dart new file mode 100644 index 0000000..4314781 --- /dev/null +++ b/lib/src/public/data_classes/google_pay_setup_parameters.dart @@ -0,0 +1,81 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/public/olo_pay_sdk.dart'; + +/// Parameters for initializing Google Pay +class GooglePaySetupParameters { + /// A two character country code for the vendor that will be processing the payment + /// + /// This can be changed later via [OloPaySdk.changeGooglePayVendor] + final String countryCode; + + /// The merchant/vendor display name + /// + /// This can be changed later via [OloPaySdk.changeGooglePayVendor] + final String merchantName; + + /// Whether or not Google Pay will use the production environment + /// + /// Set to `true` to use the Google Pay production environment, `false` for the test environment. Defaults to `true` + /// if not specified in the constructor. + final bool productionEnvironment; + + /// Specify what fields are required to complete a Google Pay transaction + /// + /// `true` indicates all fields are required, which includes the following: name, street address, locality, region, + /// country code, and postal code + /// + /// `false` includes only name, country code, and postal code + /// + /// Defaults to `false` if not specified in the constructor + final bool fullAddressFormat; + + /// Whether or not an existing saved payment method is required for Google Pay to be considered ready + /// + /// Defaults to `true` if not specified in the constructor + final bool existingPaymentMethodRequired; + + /// Whether Google Pay collects an email address when processing payments + /// + /// Defaults to `false` if not specified in the constructor + final bool emailRequired; + + /// Whether Google Pay collects a phone number when processing payments + /// + /// Defaults to `false` if not specified in the constructor + final bool phoneNumberRequired; + + /// Create a new instance of this class to configure Google Pay + /// + /// Optional parameters will result in the following default values being used if not specified: + /// - [productionEnvironment] : `true` + /// - [fullAddressFormat] : `false` + /// - [existingPaymentMethodRequired] : `true` + /// - [emailRequired] : `false` + /// - [phoneNumberRequired] : `false` + const GooglePaySetupParameters({ + required this.countryCode, + required this.merchantName, + this.productionEnvironment = true, + this.fullAddressFormat = false, + this.existingPaymentMethodRequired = true, + this.emailRequired = false, + this.phoneNumberRequired = false, + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.digitalWalletCountryCodeParameterKey: countryCode, + DataKeys.googlePayMerchantNameParameterKey: merchantName, + DataKeys.googlePayProductionEnvironmentParameterKey: + productionEnvironment, + DataKeys.googlePayFullAddressFormatParameterKey: fullAddressFormat, + DataKeys.googlePayExistingPaymentMethodRequiredParameterKey: + existingPaymentMethodRequired, + DataKeys.googlePayEmailRequiredParameterKey: emailRequired, + DataKeys.googlePayPhoneNumberRequiredParameterKey: phoneNumberRequired, + }; + } +} diff --git a/lib/src/public/data_classes/google_pay_vendor_parameters.dart b/lib/src/public/data_classes/google_pay_vendor_parameters.dart new file mode 100644 index 0000000..d8ae600 --- /dev/null +++ b/lib/src/public/data_classes/google_pay_vendor_parameters.dart @@ -0,0 +1,26 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +/// Parameters for specificying a new vendor for Google Pay +class GooglePayVendorParameters { + /// A two character country code representing the country of the vendor + final String countryCode; + + /// The merchant/vendor display name + final String merchantName; + + /// Create a new instance of this class to specify new Google Pay vendor settings + const GooglePayVendorParameters({ + required this.countryCode, + required this.merchantName, + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.digitalWalletCountryCodeParameterKey: countryCode, + DataKeys.googlePayMerchantNameParameterKey: merchantName, + }; + } +} diff --git a/lib/src/public/data_classes/hints.dart b/lib/src/public/data_classes/hints.dart new file mode 100644 index 0000000..1549d59 --- /dev/null +++ b/lib/src/public/data_classes/hints.dart @@ -0,0 +1,63 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/strings.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; + +/// Defines the hint text for card input widget fields +class Hints { + /// Hint for the card number field + final String cardNumber; + + /// Hint for the expiration field + final String expiration; + + /// Hint for the CVV field + final String cvv; + + /// Hint for the postal code field + final String postalCode; + + /// Convenience property for getting a [Hints] instance with all default values + /// + /// The defaults values are defined as follows: + /// - [cardNumber] : "4242 4242 4242 4242" + /// - [expiration] : "MM/YY" + /// - [cvv] : "CVV" + /// - [postalCode] : "Postal Code" + static const Hints defaults = Hints.only(); + + /// Create custom hints by providing values for each field + const Hints({ + required this.cardNumber, + required this.expiration, + required this.cvv, + required this.postalCode, + }); + + /// Create custom hints by providing values for only the fields you want to customize + const Hints.only({ + this.cardNumber = Strings.defaultCardNumberHint, + this.expiration = Strings.defaultExpirationHint, + this.cvv = Strings.defaultCvvHint, + this.postalCode = Strings.defaultPostalCodeHint, + }); + + /// @nodoc + Map toMap() { + return { + CardField.cardNumber.stringValue: cardNumber, + CardField.expiration.stringValue: expiration, + CardField.cvv.stringValue: cvv, + CardField.postalCode.stringValue: postalCode, + }; + } + + /// @nodoc + bool isEqualTo(Hints? other) { + return other != null && + cardNumber == other.cardNumber && + expiration == other.expiration && + cvv == other.cvv && + postalCode == other.postalCode; + } +} diff --git a/lib/src/public/data_classes/olo_pay_setup_parameters.dart b/lib/src/public/data_classes/olo_pay_setup_parameters.dart new file mode 100644 index 0000000..5ed4433 --- /dev/null +++ b/lib/src/public/data_classes/olo_pay_setup_parameters.dart @@ -0,0 +1,18 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) + +/// Parameters for initializing Olo Pay +class OloPaySetupParameters { + /// Whether or not the production environment should be used. + /// + /// Set to `true` for the production environment, `false` for the test environment. + final bool productionEnvironment; + + /// Setup parameters used to initialize the Olo Pay SDK + /// + /// [productionEnvironment] determines whether the SDK is initialized to use the production or test environment. This + /// defaults to `true` + const OloPaySetupParameters({ + this.productionEnvironment = true, + }); +} diff --git a/lib/src/public/data_classes/padding_styles.dart b/lib/src/public/data_classes/padding_styles.dart new file mode 100644 index 0000000..b83481b --- /dev/null +++ b/lib/src/public/data_classes/padding_styles.dart @@ -0,0 +1,63 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +/// Defines padding that is applied to a widget +class PaddingStyles { + /// Default horizontal padding + static const defaultHorizontalPadding = 8.0; + + /// Default vertical padding + static const defaultVerticalPadding = 0.0; + + /// Convenience property for getting a [PaddingStyles] instance with all default values + static const PaddingStyles defaults = PaddingStyles.only(); + + /// The padding at the start (left) of the card input widget + /// + /// Defaults to a value of `8.0` + final double startPadding; + + /// The padding at the end (right) of the card input widget + /// + /// Defaults to a value of `8.0` + final double endPadding; + + /// The padding at the top of the card input widget + /// + /// Defaults to a value of `0.0` + final double topPadding; + + /// The padding at the bottom of the card input widget + /// + /// Defaults to a value of `0.0` + final double bottomPadding; + + /// Define custom padding by providing values for each property + const PaddingStyles({ + required this.startPadding, + required this.endPadding, + required this.topPadding, + required this.bottomPadding, + }); + + /// Define custom padding by providing values for only the properties you want to customize + /// + /// Values that aren't specified will default to [PaddingStyles.defaultHorizontalPadding] and [PaddingStyles.defaultVerticalPadding] + const PaddingStyles.only({ + this.startPadding = defaultHorizontalPadding, + this.endPadding = defaultHorizontalPadding, + this.topPadding = defaultVerticalPadding, + this.bottomPadding = defaultVerticalPadding, + }); + + /// @nodoc + Map toMap() { + return { + DataKeys.startPaddingKey: startPadding, + DataKeys.endPaddingKey: endPadding, + DataKeys.topPaddingKey: topPadding, + DataKeys.bottomPaddingKey: bottomPadding, + }; + } +} diff --git a/lib/src/public/data_classes/payment_method.dart b/lib/src/public/data_classes/payment_method.dart new file mode 100644 index 0000000..0655d88 --- /dev/null +++ b/lib/src/public/data_classes/payment_method.dart @@ -0,0 +1,69 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/public/data_classes/card_type.dart'; + +/// Represents a payment method containing all information needed to submit a basket via Olo's Ordering API +class PaymentMethod { + /// The payment method id. + /// + /// This should be set to the token field when submitting a basket + final String id; + + /// The last four digits of the card + final String last4; + + /// The issuer of the card + /// + /// **Important: When submitting this data to Olo's Ordering API, it is important to use [CardType.stringValue]. + /// Additionally, submitting a value of [CardType.unknown] or [CardType.unsupported] to Olo's ordering API will result in an error. + final CardType cardType; + + /// The expiration month of the card + final int expirationMonth; + + /// The expiration year of the card + final int expirationYear; + + /// The zip/postal code of the card + final String postalCode; + + /// The country associated with the card + final String country; + + /// Whether or not this payment method is associated with a digital wallet + final bool isDigitalWallet; + + /// Whether or not this payment method was created in the production environment + final bool productionEnvironment; + + /// Create an instance of a payment method. + /// + /// **_Important:_** Other than for testing purposes, there should generally be no reason to create an instance of this class. + const PaymentMethod({ + required this.id, + required this.last4, + required this.cardType, + required this.expirationMonth, + required this.expirationYear, + required this.postalCode, + required this.country, + required this.isDigitalWallet, + required this.productionEnvironment, + }); + + /// @nodoc + @override + String toString() { + return ''' + id: $id + last4: $last4 + cardType: $cardType + expirationMonth: $expirationMonth + expirationYear: $expirationYear + postalCode: $postalCode + country: $country + isDigitalWallet: $isDigitalWallet + productionEnvironment: $productionEnvironment + '''; + } +} diff --git a/lib/src/public/data_classes/text_field_alignment.dart b/lib/src/public/data_classes/text_field_alignment.dart new file mode 100644 index 0000000..82f0c9f --- /dev/null +++ b/lib/src/public/data_classes/text_field_alignment.dart @@ -0,0 +1,18 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; + +enum TextFieldAlignment { + left(stringValue: DataKeys.left), + right(stringValue: DataKeys.right), + center(stringValue: DataKeys.center); + + const TextFieldAlignment({required this.stringValue}); + + final String stringValue; + + @override + String toString() { + return stringValue; + } +} diff --git a/lib/src/public/data_classes/text_styles.dart b/lib/src/public/data_classes/text_styles.dart new file mode 100644 index 0000000..68029eb --- /dev/null +++ b/lib/src/public/data_classes/text_styles.dart @@ -0,0 +1,205 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/extensions/color_extensions.dart'; +import 'package:olo_pay_sdk/src/public/olo_pay_sdk.dart'; + +/// Defines text styles for widgets in the SDK +/// +/// Widgets using this class will use default values from the app's theme, as defined in [TextStyles.merge] +class TextStyles { + /// Default font size + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultFontSize = 14.0; + + /// Default hint text color + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultHintTextColor = Color.fromRGBO(91, 89, 89, 1); + + /// Default cursor color + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultCursorColor = Colors.grey; + + /// Default error color for a light theme + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultLightThemeErrorTextColor = Color.fromRGBO(196, 45, 50, 1); + + /// Default error color for a dark theme + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultDarkThemeErrorTextColor = + Color.fromRGBO(219, 129, 132, 1); + + /// Default text color for a light theme + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultLightThemeTextColor = Color.fromRGBO(20, 20, 20, 1); + + /// Default text color for a dark theme + /// + /// **Important:** Default values are only used in conjunction with [TextStyles.merge] + static const defaultDarkThemeTextColor = Color.fromRGBO(251, 251, 251, 1); + + /// The color for user-entered text on an input field + /// + /// **Important:** Due to the conversion process from Flutter colors to native Android colors, this property + /// will only have effect on devices running Android API 27+ + final Color? textColor; + + /// The color of user-entered text when it is in an error state + /// + /// For a consistent look and feel, this color should generally be the same color used by your Text widget that + /// displays error messages + /// + /// **Important:** Due to the conversion process from Flutter colors to native Android colors, this property + /// will only have effect on devices running Android API 27+ + final Color? errorTextColor; + + /// Color of the cursor + /// + /// **Important:** On Android, this property was introduced in API 29 so it will only have effect on Android + /// devices running Android API 29+ + final Color? cursorColor; + + /// Color of the placeholder for each input field + /// + /// **Important:** Due to the conversion process from Flutter colors to native Android colors, this property + /// will only have effect on Android devices running API 27+ + final Color? hintTextColor; + + /// Size of the text within the card input widget + final double? textSize; + + /// The path to a font asset defined in pubspec.yaml. If not specified, default platform-specific system fonts + /// will be used. + /// + /// For help with determining possible causes for fonts not loading on iOS, see [OloPaySdk.getFontNames] + final String? fontAsset; + + /// The name used by iOS to identify the font associated with [fontAsset]. + /// + /// Most of the time the font name can be determined programmatically at run-time and this is not needed. There + /// may be times when this value is needed such as: + /// - Multiple font names are associated with a font and the loaded font name is different from the desired one. + /// - The font doesn't properly specify font names and there isn't a way to programmatically load the font from just + /// a font file. + /// + /// For help with determining appropriate font names that can be used, see [OloPaySdk.getFontNames] + final String? iOSFontName; + + /// Define custom text styles by providing values for each property + const TextStyles( + {required this.textColor, + required this.errorTextColor, + required this.cursorColor, + required this.hintTextColor, + required this.textSize, + this.fontAsset, + this.iOSFontName}); + + /// Define custom text styles by providing values for only the properties you want to customize + const TextStyles.only( + {this.textColor, + this.errorTextColor, + this.cursorColor, + this.hintTextColor, + this.textSize, + this.fontAsset, + this.iOSFontName}); + + /// Define custom text styles by merging [otherStyles] and values from [theme] + /// + /// Assigns values to all properties on this class, filling in `null` properties from [otherStyles] with values + /// from [theme], as follows: + /// - [TextStyles.textColor] + /// 1. Attempts to use `theme.textTheme.bodyMedium.color` first + /// 1. Attempts to use `theme.colorScheme.onBackground` second + /// 1. If the above values are `null` and [theme] has a brightness of [Brightness.dark], it falls back to [TextStyles.defaultDarkThemeTextColor] + /// 1. If the above values are `null` and [theme] is `null` or [theme] has a brightness of [Brightness.light], it falls back to [TextStyles.defaultLightThemeTextColor] + /// - [TextStyles.errorTextColor] + /// 1. Attempts to use `theme.inputDecorationTheme.errorStyle.color` first + /// 1. Attempts to use `theme.colorScheme.onBackground` second + /// 1. If the above values are `null` and [theme] has a brightness of [Brightness.dark], it falls back to [TextStyles.defaultDarkThemeErrorTextColor] + /// 1. If the above values are `null` and [theme] is `null` or [theme] has a brightness of [Brightness.light], it falls back to [TextStyles.defaultLightThemeErrorTextColor] + /// - [TextStyles.cursorColor] + /// 1. Attempts to use `theme.textSelectionTheme.cursorColor` first + /// 1. Attempts to use `theme.colorScheme.primary` second + /// 1. If [theme] or either of the above theme values are `null` it falls back to [Colors.grey] + /// - [TextStyles.hintTextColor] + /// 1. Attempts to use `theme.inputDecorationTheme.hintStyle.color` first + /// 1. Attempts to use `theme.hintColor` second + /// 1. If [theme] or either of the above theme values are `null` it falls back to [TextStyles.defaultHintTextColor] + /// - [TextStyles.textSize] + /// 1. Attempts to use `theme.textTheme.bodyMedium.fontSize` first + /// 1. If either [theme] or the above theme value are `null` it falls back to a size of `14` + /// + /// **Important:** This should not generally need to be called directly, as it is called by the widgets that take + /// [TextStyles] as a parameter + factory TextStyles.merge({ + TextStyles? otherStyles, + required ThemeData? theme, + }) { + var defaultTextColor = + (theme != null && theme.brightness == Brightness.dark) + ? defaultDarkThemeTextColor + : defaultLightThemeTextColor; + + var defaultErrorTextColor = + (theme != null && theme.brightness == Brightness.dark) + ? defaultDarkThemeErrorTextColor + : defaultLightThemeErrorTextColor; + + return TextStyles( + textColor: otherStyles?.textColor ?? + theme?.textTheme.bodyMedium?.color ?? + theme?.colorScheme.onBackground ?? + defaultTextColor, + errorTextColor: otherStyles?.errorTextColor ?? + theme?.inputDecorationTheme.errorStyle?.color ?? + theme?.colorScheme.error ?? + defaultErrorTextColor, + cursorColor: otherStyles?.cursorColor ?? + theme?.textSelectionTheme.cursorColor ?? + theme?.colorScheme.primary ?? + defaultCursorColor, + hintTextColor: otherStyles?.hintTextColor ?? + theme?.inputDecorationTheme.hintStyle?.color ?? + theme?.hintColor ?? + defaultHintTextColor, + textSize: otherStyles?.textSize ?? + theme?.textTheme.bodyMedium?.fontSize ?? + defaultFontSize, + fontAsset: otherStyles?.fontAsset, + iOSFontName: otherStyles?.iOSFontName, + ); + } + + static Color getThemeAwareDefaultErrorTextColor(ThemeData? theme) { + var defaultErrorTextColor = + (theme != null && theme.brightness == Brightness.dark) + ? defaultDarkThemeErrorTextColor + : defaultLightThemeErrorTextColor; + + return theme?.inputDecorationTheme.errorStyle?.color ?? + theme?.colorScheme.error ?? + defaultErrorTextColor; + } + + /// @nodoc + Map toMap() { + return { + DataKeys.textColorKey: textColor?.toHex(), + DataKeys.errorTextColorKey: errorTextColor?.toHex(), + DataKeys.cursorColorKey: cursorColor?.toHex(), + DataKeys.hintTextColorKey: hintTextColor?.toHex(), + DataKeys.textSizeKey: textSize, + DataKeys.fontAssetKey: fontAsset, + DataKeys.fontNameKey: iOSFontName, + }; + } +} diff --git a/lib/src/public/data_types.dart b/lib/src/public/data_types.dart new file mode 100644 index 0000000..4266b9a --- /dev/null +++ b/lib/src/public/data_types.dart @@ -0,0 +1,62 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field_state.dart'; +import 'package:olo_pay_sdk/src/public/widgets/single_line/card_details_single_line_text_field_controller.dart'; +import 'package:olo_pay_sdk/src/public/widgets/single_line/card_details_single_line_text_field.dart'; +import 'package:olo_pay_sdk/src/public/widgets/cvv/cvv_text_field_controller.dart'; +import 'package:olo_pay_sdk/src/public/widgets/cvv/cvv_text_field.dart'; + +// Single Line Text Field +/// Callback signature for when the ready status of digital wallets changes +/// +/// [isReady] determines whether or not digital wallets are ready to process payments +typedef DigitalWalletReadyChanged = void Function(bool isReady); + +/// Callback signature for when a [CardDetailsSingleLineTextField]'s controller has been created and is ready to be used +typedef CardDetailsSingleLineTextFieldControllerCreated = void Function( + CardDetailsSingleLineTextFieldController controller); + +/// Callback signature for when a [CardDetailsSingleLineTextField]'s error message changes +typedef CardDetailsErrorMessageChanged = void Function(String errorMessage); + +/// Callback signature for when a [CardDetailsSingleLineTextField]'s state changes due to user-entered input +typedef CardDetailsInputChanged = void Function( + bool isValid, + Map fieldStates, +); + +/// Callback signature for when a [CardDetailsSingleLineTextField]'s valid state changes +typedef CardDetailsValidStateChanged = void Function( + bool isValid, + Map fieldStates, +); + +/// Callback signature for when a [CardDetailsSingleLineTextField]'s +/// focused state changes +/// +/// If [focusedField] is null that means the widget itself lost focus, otherwise +/// it represents the field that currently has focus and is being edited by the +/// user +typedef CardDetailsFocusChanged = void Function( + CardField? focusedField, + bool isValid, + Map fieldStates, +); + +// Cvv Text Field +/// Callback signature for when a [CvvTextField]'s controller has been created and is ready to be used +typedef CvvTextFieldControllerCreated = void Function( + CvvTextFieldController controller); + +/// Callback signature for when a [CvvTextField]'s error message changes +typedef CvvErrorMessageChanged = void Function(String errorMessage); + +/// Callback signature for when a [CvvTextField]'s state changes due to user-entered input +typedef CvvInputChanged = void Function(CardFieldState fieldStates); + +/// Callback signature for when a [CvvTextField]'s valid state changes +typedef CvvValidStateChanged = void Function(CardFieldState fieldState); + +/// Callback signature for when a [CvvTextField]'s focused state changes +typedef CvvFocusChanged = void Function(CardFieldState fieldStates); diff --git a/lib/src/public/olo_pay_sdk.dart b/lib/src/public/olo_pay_sdk.dart new file mode 100644 index 0000000..f85bd04 --- /dev/null +++ b/lib/src/public/olo_pay_sdk.dart @@ -0,0 +1,166 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:olo_pay_sdk/src/private/olo_pay_sdk_platform_interface.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/error_codes.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/apple_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/digital_wallet_payment_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_vendor_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/olo_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/payment_method.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_types.dart'; +import 'package:flutter/services.dart'; + +/// The main entry point into the Flutter Olo Pay SDK. +/// +/// This class is responsible for initializing the SDK (see [initializeOloPay]) and creating [PaymentMethod] instances +/// via digital wallets (see [createDigitalWalletPaymentMethod]). +/// +/// **Important:** Attempting to create a [PaymentMethod] prior to initializing the SDK will result in errors. +class OloPaySdk { + /// Sets a callback to know when digital wallets are ready to process payments + /// + /// Attempting to create a [PaymentMethod] via [createDigitalWalletPaymentMethod] before digital wallets are ready will + /// result in errors. + /// + /// #### **iOS Specific Behavior** + /// + /// If a device supports Apple Pay, it will be ready (and this callback will get called) as soon as the + /// SDK is initialized. Once this callback indicates it's ready, it will always be ready. + /// + /// #### **Android Specific Behavior** + /// + /// It may take a noticeably longer time to initialize Google Pay than Apple Pay. Additionally, certain method calls + /// (see [changeGooglePayVendor]) and other internal state changes can cause Google Pay to reinitialize, resulting in + /// this callback getting called multiple times whenever the ready state changes. + /// + /// **Important:** It is recommended to keep this callback active and update your UI accordingly whenever the app + /// is displaying digital wallet UIs. + set onDigitalWalletReady(DigitalWalletReadyChanged? onDigitalWalletReady) { + OloPaySdkPlatform.instance.onDigitalWalletReady = onDigitalWalletReady; + } + + /// Initializes the Olo Pay SDK. + /// + /// **Important:** The SDK only needs to be initialized once, even if multiple instances of [OloPaySdk] + /// are created/used. + /// + /// Use [oloPayParams] to specify how Olo Pay gets initialized. + /// + /// In order to enable Apple Pay, a non-`null` [applePayParams] must be supplied. + /// + /// Similarly, a non-`null` [googlePayParams] must be supplied to enable Google Pay. Additionally, the Android app's + /// main activity must inherit from `FlutterFragmentActivity`. For details about this restriction, refer to the + /// [Android-Specific Setup Steps](https://pub.dev/documentation/olo_pay_sdk/latest/index.html#android-specific-setup-steps) + /// in the main ReadMe. + /// + /// **_Important:_** The Olo Pay SDK will be fully initialized when this method completes. However, digital wallets + /// have an asynchronous callback to indicate when they are ready to be used. See [onDigitalWalletReady] for more + /// information + /// + /// If an error occurs while initializing the SDK a [PlatformException] will be thrown. The `code` property on the + /// exception can be used to determine what went wrong and take appropriate action. The `message` property can be used + /// to get more information about what went wrong. + /// + /// When a [PlatformException] is thrown, the `code` property will be one of the following: + /// - [ErrorCodes.invalidGooglePaySetup] + /// - [ErrorCodes.invalidParameter] + /// - [ErrorCodes.unexpectedError] + Future initializeOloPay({ + required OloPaySetupParameters oloPayParams, + ApplePaySetupParameters? applePayParams, + GooglePaySetupParameters? googlePayParams, + }) async { + return await OloPaySdkPlatform.instance.initializeOloPay( + oloPayParams: oloPayParams, + applePayParams: applePayParams, + googlePayParams: googlePayParams, + ); + } + + /// Returns `true` if Olo Pay has been initialized, otherwise `false` + Future isOloPayInitialized() async { + return await OloPaySdkPlatform.instance.isOloPayInitialized(); + } + + /// Returns `true` if digital wallets are ready to be used, otherwise `false` + /// + /// **Note:** In most cases it is preferable to use [onDigitalWalletReady] + Future isDigitalWalletReady() async { + return await OloPaySdkPlatform.instance.isDigitalWalletReady(); + } + + /// Create a payment method via Apple Pay or Google Pay + /// + /// If the digital wallet flow is successful, a [PaymentMethod] will be returned. If the return value is `null` the + /// user canceled the operation. + /// + /// If an error occurs a [PlatformException] will be thrown. The `code` property on the exception can be used to determine + /// what went wrong and take appropriate action. The `message` property can be used to get more information about what + /// went wrong + /// + /// When a [PlatformException] is thrown, the `code` property will be one of the following: + /// - [ErrorCodes.generalError] + /// - [ErrorCodes.uninitializedSdk] + /// - [ErrorCodes.invalidParameter] + /// - [ErrorCodes.applePayUnsupported] + /// - [ErrorCodes.googlePayNotReady] + /// - [ErrorCodes.googlePayUninitialized] + /// - [ErrorCodes.googlePayInternalError] + /// - [ErrorCodes.googlePayDeveloperError] + /// - [ErrorCodes.googlePayNetworkError] + /// - [ErrorCodes.unexpectedError] + Future createDigitalWalletPaymentMethod( + DigitalWalletPaymentParameters paymentParams) async { + return await OloPaySdkPlatform.instance + .createDigitalWalletPaymentMethod(paymentParams); + } + + /// Change the vendor information to be used by Google Pay + /// + /// This can be used to change the country code and/or vendor name that will be used for Google Pay. Calling this + /// method will immediately invalidate Google Pay's ready status and will call [onDigitalWalletReady] with a value of + /// `false` + /// + /// Once the new vendor information is ready to be used with Google Pay, [onDigitalWalletReady] will be called again + /// with a value of `true`. + /// + /// If an error occurs a [PlatformException] will be thrown. The `code` property on the exception can be used to determine + /// what went wrong and take appropriate action. The `message` property can be used to get more information about what + /// went wrong + /// + /// When a [PlatformException] is thrown, the `code` property will be one of the following: + /// - [ErrorCodes.uninitializedSdk] + /// - [ErrorCodes.invalidParameter] + /// - [ErrorCodes.googlePayUninitialized] + /// - [ErrorCodes.unexpectedError] + Future changeGooglePayVendor( + GooglePayVendorParameters vendorParams) async { + return await OloPaySdkPlatform.instance.changeGooglePayVendor(vendorParams); + } + + /// **_(iOS Only)_** Get a list of available font names + /// + /// This method is intended for debugging and troubleshooting font issues on iOS and should not be used in production. + /// + /// Registers all the fonts defined in [fontAssets] with iOS and returns a map of all font names installed on the iOS + /// device. The map's key is the font family. The value for each key is a list of font names associated with the font + /// family. + /// + /// The font names assocaited with custom font assets can be used with [TextStyles.iOSFontName] if they don't + /// load properly or if the default font that loads is not the desired font name to be used. + /// + /// **_Important:_** For consistent behavior across devices, only use font names returned from this function that are + /// associated with [fontAssets]. + /// + /// When a [PlatformException] is thrown, the `code` property will be one of the following: + /// - [ErrorCodes.unexpectedError] + /// - [ErrorCodes.fontLoadError] + /// - [ErrorCodes.assetNotFound] + /// - [ErrorCodes.invalidParameter] + Future>> getFontNames( + List fontAssets) async { + return await OloPaySdkPlatform.instance.getFontNames(fontAssets); + } +} diff --git a/lib/src/public/widgets/cvv/cvv_text_field.dart b/lib/src/public/widgets/cvv/cvv_text_field.dart new file mode 100644 index 0000000..a280a9a --- /dev/null +++ b/lib/src/public/widgets/cvv/cvv_text_field.dart @@ -0,0 +1,364 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/src/private/data/creation_params.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/data/strings.dart'; +import 'package:olo_pay_sdk/src/private/extensions/method_channel_extensions.dart'; +import 'package:olo_pay_sdk/src/private/factories/platform_exception_factory.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/background_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field_state.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_field_errors.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_error_messages.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/hints.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/padding_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_field_alignment.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_types.dart'; +import 'package:olo_pay_sdk/src/public/widgets/cvv/cvv_text_field_controller.dart'; + +/// TODO: ADD DOCUMENTATION AND EXAMPLE AFTER CONTROLLER CODE IS IMPLEMENTED/MERGED +class CvvTextField extends StatefulWidget { + /// [onControllerCreated] allows a way to provide a callback to get an instance of the controller + /// associated with this widget. + /// + /// [onErrorMessageChanged] allows a way to get error messages to display to the user as they enter card details. + // + /// [onInputChanged] provides the state of the widget any time the input values change. + /// + /// [onValidStateChanged] provides the state of the widget any time the state toggles between invalid and valid. + /// + /// [onFocusChanged] provides the state of the widget any time focus changes on the widget./ + /// + /// The [constraints] propety can be used to set the height and width of the native view. + /// + /// Set [customErrorMessages] to override error messages used by this widget. This can also be used to provide support for localization. + /// + /// Use [displayErrorMessages] to hide the built-in error label. + /// + /// Use the [errorAlignment] property to set the alignment of the built-in error message. + /// + /// The error message can be styled with the [errorStyles] property. + /// + /// The margin between the input and the error message can be set with [errorMarginTop]. + /// + /// Hint text can be changed with the [hint] property. + /// + /// To customize the text within the widget use [textStyles]. + /// + /// Give the widget a custom background using [backgroundStyles]. + /// + /// Use [paddingStyles] to customize the padding within the widget (this only affects Android). + /// + /// Creates a new instance of this widget + const CvvTextField({ + super.key, + required this.onControllerCreated, + this.onErrorMessageChanged, + this.onInputChanged, + this.onValidStateChanged, + this.onFocusChanged, + this.constraints = const BoxConstraints(maxHeight: 45), + this.customErrorMessages, + this.displayErrorMessages = true, + this.errorAlignment = Alignment.center, + this.errorStyles, + this.errorMarginTop = _defaultErrorMarginTop, + this.hint = Strings.defaultCvvHint, + this.textStyles, + this.textAlignment = TextFieldAlignment.left, + this.backgroundStyles, + this.paddingStyles = PaddingStyles.defaults, + }); + + /// @nodoc + static const double _defaultErrorMarginTop = 8.0; + + /// A callback function to be notified when the controller associated with this widget is ready + final CvvTextFieldControllerCreated onControllerCreated; + + /// A callback function to be notified when the error message associated with this widget changes + final CvvErrorMessageChanged? onErrorMessageChanged; + + /// A callback function to be notified when user-entered input changes + final CvvInputChanged? onInputChanged; + + /// A callback function to be notified when the valid state of this widget changes + final CvvValidStateChanged? onValidStateChanged; + + /// A callback function to be notified when the focused state of this widget changes + final CvvFocusChanged? onFocusChanged; + + /// Property to set the native view's height and width. Default is `BoxConstraints(maxHeight: 45)` + final BoxConstraints constraints; + + /// Provide custom error messages + final CustomFieldErrors? customErrorMessages; + + /// Determines if error messages are displayed. Default is `true` + final bool displayErrorMessages; + + /// Customize the look and feel of the built-in error message + final TextStyle? errorStyles; + + /// Alignment of the built-in error message. Default is `Alignment.center` + final Alignment errorAlignment; + + /// Set the margin between the input widget and the error message. Default is `8.0` + final double errorMarginTop; + + /// Provide a custom hint for the text field. Defaults to [Strings.defaultCvvHint] + final String hint; + + /// An object to provide custom text styles for the input widget + /// + /// Any properties that are `null` will be populated with values from the current theme, + /// as defined by [TextStyles.merge] + final TextStyles? textStyles; + + /// Alignment of the text within the field. Default is `TextFieldAlignment.left` + final TextFieldAlignment textAlignment; + + /// An object to provide custom background styles for the input widget. + /// + /// **Important:** On Android, this property will only have an effect on Android API 27 and newer + final BackgroundStyles? backgroundStyles; + + /// An object to provide custom padding for the input widget _**(Android Only)**_ + /// + /// **Important:** When setting [PaddingStyles.topPadding] and [PaddingStyles.bottomPadding] it is important to note + /// that a value of `0.0` may not necessarily mean the text touches the top and bottom border of the view. This is largely + /// due to Flutter's requirements for providing a specific height for native views. See [CvvTextField] for more + /// information on height/sizing guidelines. + final PaddingStyles paddingStyles; + + @override + State createState() => _CvvTextFieldState(); +} + +class _CvvTextFieldState extends State { + late CvvTextFieldController _controller; + MethodChannel? _channel; + bool prematureRefresh = false; + ThemeData? _themeData; + String _editedFieldsErrorMessage = ""; + + void onControllerError(String errorMessage) { + errorMessageChangeHandler(errorMessage); + } + + void errorMessageChangeHandler(String errorMessage) { + if (_editedFieldsErrorMessage != errorMessage) { + setState(() { + _editedFieldsErrorMessage = errorMessage; + }); + widget.onErrorMessageChanged?.call(_editedFieldsErrorMessage); + } + } + + void inputChangedHandler(MethodCall call) { + final args = call.arguments as Map; + final CardFieldState? fieldState = + CardFieldState.fromMap(args[DataKeys.fieldStatesParameterKey]); + + if (fieldState == null) { + assert(false, + "CvvFieldText.inputChangedHandler(): fieldState was null when it should not be"); + return; + } + + widget.onInputChanged?.call(fieldState); + } + + void validStateChangedHandler(MethodCall call) { + final args = call.arguments as Map; + final CardFieldState? fieldState = + CardFieldState.fromMap(args[DataKeys.fieldStatesParameterKey]); + + if (fieldState == null) { + assert(false, + "CvvFieldtext.validStateChangedHandler(): fieldState was null when it should not be"); + return; + } + + widget.onValidStateChanged?.call(fieldState); + } + + void focusChangedHandler(MethodCall call) { + final args = call.arguments as Map; + final CardFieldState? fieldState = + CardFieldState.fromMap(args[DataKeys.fieldStatesParameterKey]); + + if (fieldState == null) { + assert(false, + "CvvTextField.focusChangedHandler(): fieldState was null when it should not be"); + return; + } + + widget.onFocusChanged?.call(fieldState); + } + + Future platformViewCreatedCallback(id) async { + _channel = MethodChannel('${DataKeys.cvvBaseMethodChannelKey}$id'); + _channel!.setMethodCallHandler(onMethodCall); + + if (prematureRefresh) { + prematureRefresh = false; + refreshUI(CreationParams( + hints: Hints.only(cvv: widget.hint), + textStyles: + TextStyles.merge(otherStyles: widget.textStyles, theme: _themeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: widget.backgroundStyles, theme: _themeData), + paddingStyles: widget.paddingStyles, + customErrorMessages: + CustomErrorMessages.only(cvv: widget.customErrorMessages), + textAlignment: widget.textAlignment, + )); + } + _controller = CvvTextFieldController(_channel!, onControllerError); + widget.onControllerCreated.call(_controller); + } + + Future onMethodCall(MethodCall call) async { + switch (call.method) { + case DataKeys.onErrorMessageChangedEventHandlerKey: + return errorMessageChangeHandler(call.arguments as String); + case DataKeys.onInputChangedEventHandlerKey: + return inputChangedHandler(call); + case DataKeys.onValidStateChangedEventHandlerKey: + return validStateChangedHandler(call); + case DataKeys.onFocusChangedEventHandlerKey: + return focusChangedHandler(call); + default: + assert(false, "No method name keys matched, no event callbacks called"); + return; + } + } + + Future refreshUI(CreationParams params) async { + try { + if (_channel == null) { + prematureRefresh = true; + return; + } + + return await _channel!.invokeOloMethod(DataKeys.refreshUiMethod, { + DataKeys.creationParameters: params.toMap(), + }); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + @override + void didUpdateWidget(covariant CvvTextField oldWidget) { + super.didUpdateWidget(oldWidget); + var newThemeData = Theme.of(context); + + final oldParams = CreationParams( + textStyles: TextStyles.merge( + otherStyles: oldWidget.textStyles, theme: _themeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: oldWidget.backgroundStyles, theme: _themeData), + hints: Hints.only(cvv: oldWidget.hint), + paddingStyles: oldWidget.paddingStyles, + customErrorMessages: + CustomErrorMessages.only(cvv: oldWidget.customErrorMessages), + textAlignment: oldWidget.textAlignment, + ); + + final newParams = CreationParams( + textStyles: + TextStyles.merge(otherStyles: widget.textStyles, theme: newThemeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: widget.backgroundStyles, theme: newThemeData), + hints: Hints.only(cvv: widget.hint), + paddingStyles: widget.paddingStyles, + customErrorMessages: + CustomErrorMessages.only(cvv: widget.customErrorMessages), + textAlignment: widget.textAlignment, + ); + + if (!newParams.isEqualTo(oldParams)) { + _themeData = newThemeData; + refreshUI(newParams); + } + } + + @override + Widget build(BuildContext context) { + _themeData = Theme.of(context); + const String viewType = DataKeys.cvvViewTypeKey; + + final params = CreationParams( + textStyles: + TextStyles.merge(otherStyles: widget.textStyles, theme: _themeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: widget.backgroundStyles, theme: _themeData), + hints: Hints.only(cvv: widget.hint), + paddingStyles: PaddingStyles.defaults, + customErrorMessages: + CustomErrorMessages.only(cvv: widget.customErrorMessages), + textAlignment: widget.textAlignment, + ).toMap(); + + final defaultErrorStyles = TextStyle( + color: TextStyles.getThemeAwareDefaultErrorTextColor(_themeData), + ); + + return Column( + children: [ + ConstrainedBox( + constraints: widget.constraints, + child: Container( + // Android has a thinner right border than left due to clipping for an unknown reason. Adding this margin + // fixes the visual difference but also will cause misalignment by ~1px on the right side of this widget. + // Example: Sibling widgets wrapped in Padding + margin: Platform.isAndroid ? const EdgeInsets.only(right: 1) : null, + child: Builder( + builder: (context) { + if (Platform.isAndroid) { + return AndroidView( + viewType: viewType, + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: params, + onPlatformViewCreated: platformViewCreatedCallback, + ); + } else { + return UiKitView( + viewType: viewType, + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: params, + onPlatformViewCreated: platformViewCreatedCallback, + ); + } + }, + ), + ), + ), + if (widget.displayErrorMessages) + Column( + children: [ + SizedBox(height: widget.errorMarginTop), + Align( + alignment: widget.errorAlignment, + child: Text( + _editedFieldsErrorMessage, + style: widget.errorStyles ?? defaultErrorStyles, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/public/widgets/cvv/cvv_text_field_controller.dart b/lib/src/public/widgets/cvv/cvv_text_field_controller.dart new file mode 100644 index 0000000..5eb7419 --- /dev/null +++ b/lib/src/public/widgets/cvv/cvv_text_field_controller.dart @@ -0,0 +1,248 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk_data_classes.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk_data_types.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/data/strings.dart'; +import 'package:olo_pay_sdk/src/private/extensions/method_channel_extensions.dart'; +import 'package:olo_pay_sdk/src/private/factories/platform_exception_factory.dart'; + +/// A controller for [CvvTextField] +/// +/// You can get an instance of this class using [CvvTextField.onControllerCreated]. +class CvvTextFieldController extends ChangeNotifier { + late MethodChannel _channel; + late CvvErrorMessageChanged? _errorHandler; + + /// @nodoc + @protected + CvvTextFieldController( + MethodChannel channel, CvvErrorMessageChanged? errorHandler) { + _channel = channel; + _errorHandler = errorHandler; + } + + /// Attempt to create a CVV update token based on the CVV details entered by the user + /// + /// Returns a [CvvUpdateToken] if the CVV value is in a valid format. + /// + /// If an error occurs a [PlatformException] will be thrown. The `code` property on the exception can be used to determine + /// what went wrong and take appropriate action. The `message` property can be used to get more information about what + /// went wrong + /// + /// When a [PlatformException] is thrown, the `code` property will be one of the following: + /// - [ErrorCodes.invalidCvv] + /// - [ErrorCodes.apiError] + /// - [ErrorCodes.invalidRequest] + /// - [ErrorCodes.connection] + /// - [ErrorCodes.rateLimit] + /// - [ErrorCodes.authentication] + /// - [ErrorCodes.unexpectedError] + /// - [ErrorCodes.unknownCard] + /// - [ErrorCodes.generalError] + Future createCvvUpdateToken() async { + try { + final Map? result = + await _channel.invokeOloMapMethod(DataKeys.createCvvUpdateToken); + + if (result == null) { + throw PlatformExceptionFactory.create( + errorDetails: Strings.unexpectedNullValue, + ); + } + + return CvvUpdateToken( + id: result[DataKeys.cvvIdKey], + productionEnvironment: result[DataKeys.productionEnvironmentKey], + ); + } on PlatformException catch (e) { + _errorHandler?.call(e.message!); + rethrow; + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Returns the current state of the widget + /// + /// **NOTE:** It may not be necessary to use this method, as this state data can also be accessed via the + /// following callbacks: + /// - [CvvTextField.onInputChanged] + /// - [CvvTextField.onValidStateChanged] + /// - [CvvTextField.onFocusChanged] + /// + /// This state can be useful when implementing custom behavior of the widget (e.g. providing custom error messages + /// with custom logic determining when to show the errors). + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future getState() async { + try { + final Map? result = + await _channel.invokeOloMapMethod(DataKeys.getStateMethodKey); + + // This should never happen + if (result == null) { + throw PlatformExceptionFactory.create( + errorDetails: Strings.unexpectedNullValue, + ); + } + + CardFieldState? state = CardFieldState.fromMap(result); + + if (state == null) { + throw PlatformExceptionFactory.create( + errorDetails: Strings.unexpectedNullValue, + ); + } + + return state; + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Convenience method for to query whether the user-entered CVV value is in a valid format + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future isValid() async { + try { + return await _channel.invokeOloMethod(DataKeys.isValidMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Enable or disable user-interaction with the widget + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future setEnabled(bool enabled) async { + try { + return await _channel.invokeOloMethod(DataKeys.setEnabledMethodKey, + {DataKeys.enabledParameterKey: enabled}); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Clear user-entered data in the widget's field and reset the widget to its initial state + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future clear() async { + try { + return await _channel.invokeOloMethod(DataKeys.clearFieldsMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Check if the widget is currently enabled (i.e. able to response to user input) + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future isEnabled() async { + try { + return await _channel.invokeOloMethod(DataKeys.isEnabledMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Check if there are error messages that can be displayed to the user + /// + /// **NOTE:** Unless implementing custom business logic there is generally no need to use this method. Error messages + /// can be obtained using the [CvvTextField.onErrorMessageChanged] callback. + /// + /// If [ignoreUneditedField] is `true` (the default) the field will only be considered if it has been "edited" by + /// the user. If `false` then the this will check the field for possible errors regardless of the current state. + /// + /// For purposes of this method, "edited" means the user has entered text and focus has changed to another widget while + /// not empty. Once the field is considered "edited", it stays edited even if empty, unless [clear] is called to reset + /// the state. + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future hasErrorMessage({bool ignoreUneditedField = true}) async { + try { + return await _channel.invokeOloMethod(DataKeys.hasErrorMessageMethodKey, { + DataKeys.ignoreUneditedFieldsParameterKey: ignoreUneditedField, + }); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Get an error message that can be displayed to the user + /// + /// **Note:** Unless implementing custom business logic there is generally no need to use this method. Error messages + /// can be obtained using the [CvvTextField.onErrorMessageChanged] callback. + /// + /// If [ignoreUneditedField] is `true` (the default) the field will only be considered if it has been "edited" by + /// the user. If `false` then the this will check the field for possible errors regardless of the current state. + /// + /// For purposes of this method, "edited" means the user has entered text and focus has changed to another widget while + /// not empty. Once a field is considered "edited", it stays edited even if empty, unless [clear] is called to reset + /// the state. + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future getErrorMessage({bool ignoreUneditedField = true}) async { + try { + return await _channel.invokeOloMethod(DataKeys.getErrorMessageMethodKey, { + DataKeys.ignoreUneditedFieldsParameterKey: ignoreUneditedField, + }); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Puts focus on the widget and displays the keyboard + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future requestFocus() async { + try { + return await _channel.invokeOloMethod(DataKeys.requestFocusMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Clears focus from the widget and dismisses the keyboard + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future clearFocus() async { + try { + return await _channel.invokeOloMethod(DataKeys.clearFocusMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } +} diff --git a/lib/src/public/widgets/single_line/card_details_single_line_text_field.dart b/lib/src/public/widgets/single_line/card_details_single_line_text_field.dart new file mode 100644 index 0000000..678a947 --- /dev/null +++ b/lib/src/public/widgets/single_line/card_details_single_line_text_field.dart @@ -0,0 +1,407 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/src/private/data/creation_params.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/extensions/method_channel_extensions.dart'; +import 'package:olo_pay_sdk/src/private/factories/platform_exception_factory.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/background_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_error_messages.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/hints.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/padding_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_types.dart'; +import 'package:olo_pay_sdk/src/public/widgets/single_line/card_details_single_line_text_field_controller.dart'; + +/// Widget responsible for displaying a credit card input field +/// +/// This widget displays all credit card input details in a single input field +/// +/// **_Important:_** Card details are intentionally restricted for PCI compliance +/// +/// ### Android-Specific Details: +/// +/// To display this widget, the Android app's main activity must inherit from `FlutterFragmentActivity`. For details +/// about this restriction, refer to the [Android-Specific Setup Steps](https://pub.dev/documentation/olo_pay_sdk/latest/index.html#android-specific-setup-steps) +/// in the main ReadMe. +/// +/// ### Sizing Considerations: +/// +/// This widget hosts native Android and iOS views and an error label contained within a [Column]. +/// The native view is wrapped by a [ConstrainedBox] with default `constraints` set to `BoxConstraints(maxHeight: 45)`. +/// This can be customized using the [constraints] property. +/// +/// ### Sizing Behavior Prior to v1.2.0 +/// +/// Prior to v1.2.0 Android and iOS had different widget sizing behavior. The iOS native view +/// would properly resize to fit the size specified by its Flutter container widget. +/// +/// The Android native view, however, would only take up as much space as it needed, according to its intrinsic content size. +/// If the size specified for its Flutter container widget's height was larger than the space it required, this would +/// lead to extra empty space below the Android native view. +/// +/// This has been fixed in v1.2.0, and the Android native view now properly resizes to fit the size +/// specified by its Flutter container widget. +/// +/// ### Example Implementation +/// +/// ```dart +/// CardDetailsSingleLineTextFieldController? _cardInputController; +/// +/// void onSingleLineControllerCreated(CardDetailsSingleLineTextFieldController controller) { +/// _cardInputController = controller; +/// } +/// +/// Future createPaymentMethod() async { +/// try { +/// var paymentMethod = await _cardInputController?.createPaymentMethod(); +/// if (paymentMethod != null) { +/// // Use paymentMethod to submit an order to [Olo's Ordering API](https://developer.olo.com/docs/load/olopay#section/Submitting-a-Basket-via-the-Ordering-API) +/// } +/// } on PlatformException catch (e) { +/// // Handle errors +/// } +/// } +/// +/// @override +/// Widget build(BuildContext context) { +/// return Center( +/// child: Column( +/// children: [ +/// CardDetailsSingleLineTextField( +/// onControllerCreated: onSingleLineControllerCreated, +/// ), +/// ElevatedButton( +/// onPressed: createPaymentMethod, +/// child: const Text("Submit Card Details"), +/// ), +/// ], +/// ), +/// ), +/// } +/// ``` +class CardDetailsSingleLineTextField extends StatefulWidget { + /// [onControllerCreated] allows a way to provide a callback to get an instance of the controller + /// associated with this widget. + /// + /// [onErrorMessageChanged] allows a way to get error messages to display to the user as they enter card details. + /// + /// [onInputChanged] provides the state of the widget any time the input values change. + /// + /// [onValidStateChanged] provides the state of the widget any time the state toggles between invalid and valid. + /// + /// [onFocusChanged] provides the state of the widget any time focus changes within the widget. + /// + /// The [constraints] propety can be used to set the height of the native views. + /// + /// Set [customErrorMessages] to override error messages used by this widget. This can also be used to provide support for localization. + /// + /// Use [displayErrorMessages] to hide the built-in error label. + /// + /// Use the [errorAlignment] property to set the alignment of the built-in error message. + /// + /// The error message can be styled with the [errorStyles] property. + /// + /// The margin between the card input and the error message can be set with [errorMarginTop]. + /// + /// Hint text can be changed with the [hints] property. + /// + /// To customize the text within the widget use [textStyles]. + /// + /// Give the widget a custom background using [backgroundStyles]. + /// + /// Use [paddingStyles] to customize the padding within the widget (this only affects Android). + /// + /// Creates a new instance of this widget + const CardDetailsSingleLineTextField({ + super.key, + required this.onControllerCreated, + this.onErrorMessageChanged, + this.onInputChanged, + this.onValidStateChanged, + this.onFocusChanged, + this.constraints = const BoxConstraints(maxHeight: 45), + this.customErrorMessages, + this.displayErrorMessages = true, + this.errorAlignment = Alignment.center, + this.errorStyles, + this.errorMarginTop = _defaultErrorMarginTop, + this.hints = Hints.defaults, + this.textStyles, + this.backgroundStyles, + this.paddingStyles = PaddingStyles.defaults, + }); + + /// @nodoc + static const double _defaultErrorMarginTop = 8.0; + + /// A callback function to be notified when the controller associated with this widget is ready + final CardDetailsSingleLineTextFieldControllerCreated onControllerCreated; + + /// A callback function to be notified when the error message associated with this widget changes + final CardDetailsErrorMessageChanged? onErrorMessageChanged; + + /// A callback function to be notified when user-entered input changes + final CardDetailsInputChanged? onInputChanged; + + /// A callback function to be notified when the valid state of this widget changes + final CardDetailsValidStateChanged? onValidStateChanged; + + /// A callback function to be notified when the focused state of this widget changes + final CardDetailsFocusChanged? onFocusChanged; + + /// Property to set the native view's height and width. Default is `BoxConstraints(maxHeight: 45)` + final BoxConstraints constraints; + + /// Provide custom error messages + final CustomErrorMessages? customErrorMessages; + + /// Determines if error messages are displayed. Default is `true` + final bool displayErrorMessages; + + /// Alignment of the built-in error message. Default is `center` + final Alignment errorAlignment; + + /// Customize the look and feel of the built-in error message + final TextStyle? errorStyles; + + /// Set the margin between the input widget and the error message. Default is `8.0` + final double errorMarginTop; + + /// An object to provide custom hints for fields of this widget + final Hints hints; + + /// An object to provide custom text styles for the input widget + /// + /// Any properties that are `null` will be populated with values from the current theme, + /// as defined by [TextStyles.merge] + final TextStyles? textStyles; + + /// An object to provide custom background styles for the input widget + /// + /// **Important:** On Android, this property will only have an effect on Android API 27 and newer + final BackgroundStyles? backgroundStyles; + + /// An object to provide custom padding for the input widget _**(Android Only)**_ + /// + /// **Important:** When setting [PaddingStyles.topPadding] and [PaddingStyles.bottomPadding] it is important to note + /// that a value of `0.0` may not necessarily mean the text touches the top and bottom border of the view. This is largely + /// due to Flutter's requirements for providing a specific height for native views. See [CardDetailsSingleLineTextField] + /// for more information on height/sizing guidelines. + final PaddingStyles paddingStyles; + + /// @nodoc + @override + State createState() => + _CardDetailsSingleLineTextFieldState(); +} + +class _CardDetailsSingleLineTextFieldState + extends State { + late CardDetailsSingleLineTextFieldController _controller; + MethodChannel? _channel; + bool prematureRefresh = false; + String _editedFieldsErrorMessage = ""; + ThemeData? _themeData; + + Future platformViewCreatedCallback(id) async { + _channel = MethodChannel('${DataKeys.singleLineBaseMethodChannelKey}$id'); + _channel!.setMethodCallHandler(onMethodCall); + + _controller = + CardDetailsSingleLineTextFieldController(_channel!, onControllerError); + widget.onControllerCreated.call(_controller); + + if (prematureRefresh) { + prematureRefresh = false; + refreshUI(CreationParams( + hints: widget.hints, + textStyles: + TextStyles.merge(otherStyles: widget.textStyles, theme: _themeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: widget.backgroundStyles, theme: _themeData), + paddingStyles: widget.paddingStyles, + customErrorMessages: widget.customErrorMessages, + )); + } + } + + Future onMethodCall(MethodCall call) async { + switch (call.method) { + case DataKeys.onErrorMessageChangedEventHandlerKey: + return errorMessageChangeHandler(call.arguments as String); + case DataKeys.onInputChangedEventHandlerKey: + return inputChangedHandler(call); + case DataKeys.onValidStateChangedEventHandlerKey: + return validStateChangedHandler(call); + case DataKeys.onFocusChangedEventHandlerKey: + return focusChangedHandler(call); + default: + return; + } + } + + void onControllerError(String errorMessage) { + errorMessageChangeHandler(errorMessage); + } + + void errorMessageChangeHandler(String errorMessage) { + if (_editedFieldsErrorMessage != errorMessage) { + setState(() { + _editedFieldsErrorMessage = errorMessage; + }); + widget.onErrorMessageChanged?.call(_editedFieldsErrorMessage); + } + } + + void inputChangedHandler(MethodCall call) { + final args = call.arguments as Map; + final bool isValid = args[DataKeys.isValidKey]; + final Map fieldStateArgs = + args[DataKeys.fieldStatesParameterKey]; + + widget.onInputChanged?.call(isValid, fieldStateArgs.toFieldStateMap()); + } + + void validStateChangedHandler(MethodCall call) { + final args = call.arguments as Map; + final bool isValid = args[DataKeys.isValidKey]; + final Map fieldStateArgs = + args[DataKeys.fieldStatesParameterKey]; + + widget.onValidStateChanged?.call(isValid, fieldStateArgs.toFieldStateMap()); + } + + void focusChangedHandler(MethodCall call) { + final args = call.arguments as Map; + final bool isValid = args[DataKeys.isValidKey]; + final CardField? focusedField = + CardField.fromStringValue(args[DataKeys.focusedFieldParameterKey]); + final Map fieldStateArgs = + args[DataKeys.fieldStatesParameterKey]; + + widget.onFocusChanged + ?.call(focusedField, isValid, fieldStateArgs.toFieldStateMap()); + } + + Future refreshUI(CreationParams params) async { + try { + if (_channel == null) { + prematureRefresh = true; + return; + } + + return await _channel!.invokeOloMethod(DataKeys.refreshUiMethod, { + DataKeys.creationParameters: params.toMap(), + }); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + @override + void didUpdateWidget(covariant CardDetailsSingleLineTextField oldWidget) { + super.didUpdateWidget(oldWidget); + var newThemeData = Theme.of(context); + + final oldParams = CreationParams( + textStyles: TextStyles.merge( + otherStyles: oldWidget.textStyles, theme: _themeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: oldWidget.backgroundStyles, theme: _themeData), + hints: oldWidget.hints, + paddingStyles: oldWidget.paddingStyles, + customErrorMessages: oldWidget.customErrorMessages, + ); + + final newParams = CreationParams( + textStyles: + TextStyles.merge(otherStyles: widget.textStyles, theme: newThemeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: widget.backgroundStyles, theme: newThemeData), + hints: widget.hints, + paddingStyles: widget.paddingStyles, + customErrorMessages: widget.customErrorMessages, + ); + + if (!newParams.isEqualTo(oldParams)) { + _themeData = newThemeData; + refreshUI(newParams); + } + } + + @override + Widget build(BuildContext context) { + _themeData = Theme.of(context); + const String viewType = DataKeys.singleLineViewTypeKey; + + final params = CreationParams( + hints: widget.hints, + textStyles: + TextStyles.merge(otherStyles: widget.textStyles, theme: _themeData), + backgroundStyles: BackgroundStyles.merge( + otherStyles: widget.backgroundStyles, theme: _themeData), + paddingStyles: widget.paddingStyles, + customErrorMessages: widget.customErrorMessages, + ).toMap(); + + final defaultErrorStyles = TextStyle( + color: TextStyles.getThemeAwareDefaultErrorTextColor(_themeData), + ); + + return Column( + children: [ + ConstrainedBox( + constraints: widget.constraints, + child: Container( + // Android has a thinner right border than left due to clipping for an unknown reason. Adding this margin + // fixes the visual difference but also will cause misalignment by ~1px on the right side of this widget. + // Example: sibling widgets wrapped in Padding + margin: Platform.isAndroid ? const EdgeInsets.only(right: 1) : null, + child: Builder( + builder: (context) { + if (Platform.isAndroid) { + return AndroidView( + viewType: viewType, + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: params, + onPlatformViewCreated: platformViewCreatedCallback, + ); + } else { + return UiKitView( + viewType: viewType, + layoutDirection: TextDirection.ltr, + creationParamsCodec: const StandardMessageCodec(), + creationParams: params, + onPlatformViewCreated: platformViewCreatedCallback, + ); + } + }, + ), + ), + ), + if (widget.displayErrorMessages) + Column( + children: [ + SizedBox(height: widget.errorMarginTop), + Align( + alignment: widget.errorAlignment, + child: Text( + _editedFieldsErrorMessage, + style: widget.errorStyles ?? defaultErrorStyles, + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/public/widgets/single_line/card_details_single_line_text_field_controller.dart b/lib/src/public/widgets/single_line/card_details_single_line_text_field_controller.dart new file mode 100644 index 0000000..a92de3e --- /dev/null +++ b/lib/src/public/widgets/single_line/card_details_single_line_text_field_controller.dart @@ -0,0 +1,282 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:olo_pay_sdk/src/private/data/data_keys.dart'; +import 'package:olo_pay_sdk/src/private/data/strings.dart'; +import 'package:olo_pay_sdk/src/private/extensions/method_channel_extensions.dart'; +import 'package:olo_pay_sdk/src/private/factories/platform_exception_factory.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field_state.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_type.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/error_codes.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/payment_method.dart'; +import 'package:olo_pay_sdk/src/public/widgets/single_line/card_details_single_line_text_field.dart'; +import 'package:olo_pay_sdk/src/public/data_types.dart'; + +/// A controller for [CardDetailsSingleLineTextField] +/// +/// You can get an instance of this class using [CardDetailsSingleLineTextField.onControllerCreated]. +class CardDetailsSingleLineTextFieldController extends ChangeNotifier { + late MethodChannel _channel; + late CardDetailsErrorMessageChanged? _errorHandler; + + /// @nodoc + @protected + CardDetailsSingleLineTextFieldController( + MethodChannel channel, CardDetailsErrorMessageChanged? errorHandler) { + _channel = channel; + _errorHandler = errorHandler; + } + + /// Attempt to create a payment method based on the card details entered by the user + /// + /// Returns a [PaymentMethod] if the card details are valid. + /// + /// If an error occurs a [PlatformException] will be thrown. The `code` property on the exception can be used to determine + /// what went wrong and take appropriate action. The `message` property can be used to get more information about what + /// went wrong + /// + /// When a [PlatformException] is thrown, the `code` property will be one of the following: + /// **Common Codes:** _(the error message associated with these codes are user-friendly)_ + /// - [ErrorCodes.invalidNumber] + /// - [ErrorCodes.invalidExpiration] + /// - [ErrorCodes.invalidCvv] + /// - [ErrorCodes.invalidPostalCode] + /// **Uncommon Codes:** _(these do no occur very often)_ + /// - [ErrorCodes.invalidCardDetails] + /// - [ErrorCodes.apiError] + /// - [ErrorCodes.invalidRequest] + /// - [ErrorCodes.connection] + /// - [ErrorCodes.rateLimit] + /// - [ErrorCodes.authentication] + /// - [ErrorCodes.unexpectedError] + /// - [ErrorCodes.expiredCard] + /// - [ErrorCodes.cardDeclined] + /// - [ErrorCodes.processingError] + /// - [ErrorCodes.unknownCard] + /// - [ErrorCodes.generalError] + Future createPaymentMethod() async { + try { + final Map? result = + await _channel.invokeOloMapMethod(DataKeys.createPaymentMethodKey); + + // This should never happen + if (result == null) { + throw PlatformExceptionFactory.create( + errorDetails: Strings.unexpectedNullValue, + ); + } + + return PaymentMethod( + id: result[DataKeys.pmIdKey], + last4: result[DataKeys.pmLast4Key], + cardType: CardType.fromStringValue(result[DataKeys.pmCardTypeKey]), + expirationMonth: result[DataKeys.pmExpirationMonthKey], + expirationYear: result[DataKeys.pmExpirationYearKey], + postalCode: result[DataKeys.pmPostalCodeKey], + country: result[DataKeys.pmCountryCodeKey], + isDigitalWallet: result[DataKeys.pmIsDigitalWalletKey], + productionEnvironment: result[DataKeys.productionEnvironmentKey]); + } on PlatformException catch (e) { + _errorHandler?.call(e.message!); + rethrow; + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Returns the current state of the widget for each field + /// + /// **NOTE:** It may not be necessary to use this method, as this state data can also be accessed via the + /// following callbacks: + /// - [CardDetailsSingleLineTextField.onInputChanged] + /// - [CardDetailsSingleLineTextField.onValidStateChanged] + /// - [CardDetailsSingleLineTextField.onFocusChanged] + /// + /// This state can be useful when implementing custom behavior of the widget (e.g. providing custom error messages + /// with custom logic determining when to show the errors). + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future> getState() async { + try { + final Map? result = + await _channel.invokeOloMapMethod(DataKeys.getStateMethodKey); + + // This should never happen + if (result == null) { + throw PlatformExceptionFactory.create( + errorDetails: Strings.unexpectedNullValue, + ); + } + + return result.toFieldStateMap(); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Convenience method for to query whether user-entered card details are currently valid + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future isValid() async { + try { + return await _channel.invokeOloMethod(DataKeys.isValidMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Convenience method to query the detected card type + /// + /// **NOTE:** Unless implementing custom business logic, there is generally no need to use this method + /// because a [PaymentMethod] created using [createPaymentMethod] includes this data. + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future getCardType() async { + try { + return CardType.fromStringValue( + await _channel.invokeOloMethod(DataKeys.getCardTypeMethodKey)); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Enable or disable user-interaction with the widget + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future setEnabled(bool enabled) async { + try { + return await _channel.invokeOloMethod(DataKeys.setEnabledMethodKey, + {DataKeys.enabledParameterKey: enabled}); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Clear all user-entered data in the widget's fields and reset the widget to its initial state + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future clearFields() async { + try { + return await _channel.invokeOloMethod(DataKeys.clearFieldsMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Check if the widget is currently enabled (i.e. able to response to user input) + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future isEnabled() async { + try { + return await _channel.invokeOloMethod(DataKeys.isEnabledMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Check if there are error messages that can be displayed to the user + /// + /// **NOTE:** Unless implementing custom business logic there is generally no need to use this method. Error messages + /// can be obtained using the [CardDetailsSingleLineTextField.onErrorMessageChanged] callback. + /// + /// If [ignoreUneditedFields] is `true` (the default) only fields that have been "edited" by the user will be + /// considered. If `false` then all fields regardless will be considered regardless of their current state. + /// + /// For purposes of this method, "edited" means the user has entered text and focus has changed to another field while + /// not empty. Once a field is considered "edited", it stays edited even if empty, unless [clearFields] is called to reset + /// the state. + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future hasErrorMessage({bool ignoreUneditedFields = true}) async { + try { + return await _channel.invokeOloMethod(DataKeys.hasErrorMessageMethodKey, { + DataKeys.ignoreUneditedFieldsParameterKey: ignoreUneditedFields, + }); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Get an error message that can be displayed to the user + /// + /// **Note:** Unless implementing custom business logic there is generally no need to use this method. Error messages + /// can be obtained using the [CardDetailsSingleLineTextField.onErrorMessageChanged] callback. + /// + /// If [ignoreUneditedFields] is `true` (the default) only fields that have been "edited" by the user will be + /// considered. If `false` then all fields will be considered regardless of their current state. + /// + /// For purposes of this method, "edited" means the user has entered text and focus has changed to another field while + /// not empty. Once a field is considered "edited", it stays edited even if empty, unless [clearFields] is called to reset + /// the state. + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future getErrorMessage({bool ignoreUneditedFields = true}) async { + try { + return await _channel.invokeOloMethod(DataKeys.getErrorMessageMethodKey, { + DataKeys.ignoreUneditedFieldsParameterKey: ignoreUneditedFields, + }); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Puts focus on the widget and displays the keyboard + /// + /// **Note:** Due to differences across platforms, on Android the card number field will always be given focus. On iOS, + /// the last field that had focus will regain that focus. + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future requestFocus() async { + try { + return await _channel.invokeOloMethod(DataKeys.requestFocusMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } + + /// Clears focus from the card number field and dismisses the keyboard + /// + /// If a [PlatformException] is thrown, the `code` property would be [ErrorCodes.unexpectedError] + Future clearFocus() async { + try { + return await _channel.invokeOloMethod(DataKeys.clearFocusMethodKey); + } catch (e, trace) { + throw PlatformExceptionFactory.createFromError( + error: e, + trace: trace, + ); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..968a540 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,77 @@ +name: olo_pay_sdk +# NOTE: The description needs to be between 60 and 180 characters +description: "Olo Pay Flutter SDK: Add simple PCI-compliant payments to your apps" +version: 1.2.0 +buildType: "public" +homepage: "https://www.olo.com/" +repository: "https://github.com/ololabs/ololabs-olo-pay-flutter-sdk-releases" + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.10.0' + +dependencies: + checked_yaml: ^2.0.3 + collection: ^1.18.0 + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + pubspec_parse: ^1.2.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.olo.flutter.olo_pay_sdk + pluginClass: OloPaySdkPlugin + ios: + pluginClass: OloPaySdkPlugin + + # To add assets to your plugin package, add an assets section, like this: + assets: + - pubspec.yaml + + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/test/olo_pay_sdk_method_channel_test.dart b/test/olo_pay_sdk_method_channel_test.dart new file mode 100644 index 0000000..a9fea80 --- /dev/null +++ b/test/olo_pay_sdk_method_channel_test.dart @@ -0,0 +1,25 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +// import 'package:flutter/services.dart'; +// import 'package:flutter_test/flutter_test.dart'; +// import 'package:olo_pay_sdk/src/private/olo_pay_sdk_method_channel.dart'; + +void main() { + // TestWidgetsFlutterBinding.ensureInitialized(); + + // MethodChannelOloPaySdk platform = MethodChannelOloPaySdk(); + // const MethodChannel channel = MethodChannel('olo_pay_sdk'); + + // setUp(() { + // TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + // channel, + // (MethodCall methodCall) async { + // return '42'; + // }, + // ); + // }); + + // tearDown(() { + // TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + // }); +} diff --git a/test/olo_pay_sdk_test.dart b/test/olo_pay_sdk_test.dart new file mode 100644 index 0000000..9641067 --- /dev/null +++ b/test/olo_pay_sdk_test.dart @@ -0,0 +1,77 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/private/olo_pay_sdk_platform_interface.dart'; +import 'package:olo_pay_sdk/src/private/olo_pay_sdk_method_channel.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/apple_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/digital_wallet_payment_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_vendor_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/olo_pay_setup_parameters.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/payment_method.dart'; +import 'package:olo_pay_sdk/src/public/data_types.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockOloPaySdkPlatform + with MockPlatformInterfaceMixin + implements OloPaySdkPlatform { + @override + Future initializeOloPay({ + required OloPaySetupParameters oloPayParams, + ApplePaySetupParameters? applePayParams, + GooglePaySetupParameters? googlePayParams, + }) { + // TODO: implement initializeOloPay + throw UnimplementedError(); + } + + @override + Future isOloPayInitialized() { + // TODO: implement isOloPayInitialized + throw UnimplementedError(); + } + + @override + DigitalWalletReadyChanged? onDigitalWalletReady; + + @override + Future changeGooglePayVendor(GooglePayVendorParameters vendorParams) { + // TODO: implement changeGooglePayVendor + throw UnimplementedError(); + } + + @override + Future createDigitalWalletPaymentMethod( + DigitalWalletPaymentParameters paymentParams) { + // TODO: implement createDigitalWalletPaymentMethod + throw UnimplementedError(); + } + + @override + Future isDigitalWalletReady() { + // TODO: implement isDigitalWalletReady + throw UnimplementedError(); + } + + @override + Future>> getFontNames(List fontAssets) { + // TODO: implement getFontNames + throw UnimplementedError(); + } +} + +void main() { + final OloPaySdkPlatform initialPlatform = OloPaySdkPlatform.instance; + + test('$MethodChannelOloPaySdk is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + // test('getPlatformVersion', () async { + // OloPaySdk oloPaySdkPlugin = OloPaySdk(); + // MockOloPaySdkPlatform fakePlatform = MockOloPaySdkPlatform(); + // OloPaySdkPlatform.instance = fakePlatform; + + // expect(await oloPaySdkPlugin.getPlatformVersion(), '42'); + // }); +} diff --git a/test/private/data/creation_params_test.dart b/test/private/data/creation_params_test.dart new file mode 100644 index 0000000..400b2e0 --- /dev/null +++ b/test/private/data/creation_params_test.dart @@ -0,0 +1,89 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_error_messages.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/hints.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_field_alignment.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/background_styles.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/padding_styles.dart'; +import 'package:olo_pay_sdk/src/private/data/creation_params.dart'; + +void main() { + group('CreationParams:', () { + const hints = Hints.defaults; + const textStyles = TextStyles.only(); + const backgroundStyles = BackgroundStyles.only(); + const paddingStyles = PaddingStyles.defaults; + const customErrorMessages = CustomErrorMessages.only(); + const textAlignment = TextFieldAlignment.left; + + final allParamsMap = const CreationParams( + hints: hints, + textStyles: textStyles, + backgroundStyles: backgroundStyles, + paddingStyles: paddingStyles, + customErrorMessages: customErrorMessages, + textAlignment: textAlignment) + .toMap(); + + final requiredParamsMap = const CreationParams( + hints: hints, + textStyles: textStyles, + backgroundStyles: backgroundStyles, + paddingStyles: paddingStyles, + ).toMap(); + + group('toMap():', () { + group('All Params', () { + test('Has correct length', () { + expect(allParamsMap.length, 6); + }); + + test('Has correct keys', () { + expect(allParamsMap.containsKey('hints'), true); + expect(allParamsMap.containsKey('textStyles'), true); + expect(allParamsMap.containsKey('backgroundStyles'), true); + expect(allParamsMap.containsKey('paddingStyles'), true); + expect(allParamsMap.containsKey('customErrorMessages'), true); + expect(allParamsMap.containsKey('textAlignment'), true); + }); + + test('Keys have correct values', () { + expect(allParamsMap['hints'], hints.toMap()); + expect(allParamsMap['textStyles'], textStyles.toMap()); + expect(allParamsMap['backgroundStyles'], backgroundStyles.toMap()); + expect(allParamsMap['paddingStyles'], paddingStyles.toMap()); + expect( + allParamsMap['customErrorMessages'], customErrorMessages.toMap()); + expect(allParamsMap['textAlignment'], textAlignment.toString()); + }); + }); + + group('Required Params Only', () { + test('Has correct length', () { + expect(requiredParamsMap.length, 6); + }); + + test('Has correct keys', () { + expect(requiredParamsMap.containsKey('hints'), true); + expect(requiredParamsMap.containsKey('textStyles'), true); + expect(requiredParamsMap.containsKey('backgroundStyles'), true); + expect(requiredParamsMap.containsKey('paddingStyles'), true); + expect(requiredParamsMap.containsKey('customErrorMessages'), true); + expect(requiredParamsMap.containsKey('textAlignment'), true); + }); + + test('Keys have correct values', () { + expect(requiredParamsMap['hints'], hints.toMap()); + expect(requiredParamsMap['textStyles'], textStyles.toMap()); + expect( + requiredParamsMap['backgroundStyles'], backgroundStyles.toMap()); + expect(requiredParamsMap['paddingStyles'], paddingStyles.toMap()); + expect(requiredParamsMap['customErrorMessages'], null); + expect(requiredParamsMap['textAlignment'], null); + }); + }); + }); + }); +} diff --git a/test/private/extensions/color_extensions_test.dart b/test/private/extensions/color_extensions_test.dart new file mode 100644 index 0000000..51c6eea --- /dev/null +++ b/test/private/extensions/color_extensions_test.dart @@ -0,0 +1,19 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:olo_pay_sdk/src/private/extensions/color_extensions.dart'; + +void main() { + group('ColorExtensions:', () { + group('toHex():', () { + test('Flutter named color converts to hex', () { + expect(Colors.red.toHex(), '#fff44336'); + }); + + test('Color with alpha converts to hex', () { + expect(const Color.fromRGBO(54, 244, 140, 120).toHex(), '#8836f48c'); + }); + }); + }); +} diff --git a/test/public/data_classes/apple_pay_setup_parameters_test.dart b/test/public/data_classes/apple_pay_setup_parameters_test.dart new file mode 100644 index 0000000..f1f0ab9 --- /dev/null +++ b/test/public/data_classes/apple_pay_setup_parameters_test.dart @@ -0,0 +1,29 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/apple_pay_setup_parameters.dart'; + +void main() { + final applePayParamsMap = const ApplePaySetupParameters( + merchantId: "com.merchant.test", + companyLabel: "Test Company", + ).toMap(); + + group('ApplePaySetupParameters:', () { + group('toMap():', () { + test('Has correct length', () { + expect(applePayParamsMap.length, 2); + }); + + test('Has correct keys', () { + expect(applePayParamsMap.containsKey("merchantId"), true); + expect(applePayParamsMap.containsKey("companyLabel"), true); + }); + + test('Keys have correct values', () { + expect(applePayParamsMap["merchantId"], "com.merchant.test"); + expect(applePayParamsMap["companyLabel"], "Test Company"); + }); + }); + }); +} diff --git a/test/public/data_classes/background_styles_test.dart b/test/public/data_classes/background_styles_test.dart new file mode 100644 index 0000000..e06a872 --- /dev/null +++ b/test/public/data_classes/background_styles_test.dart @@ -0,0 +1,221 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/background_styles.dart'; + +void main() { + final allParamsMap = const BackgroundStyles( + backgroundColor: Colors.white, + borderColor: Colors.black, + borderWidth: 5.0, + borderRadius: 1.0, + ).toMap(); + + final emptyParamsMap = const BackgroundStyles.only().toMap(); + + const colorScheme = ColorScheme( + brightness: Brightness.dark, + primary: Colors.blue, + onPrimary: Colors.blueAccent, + secondary: Colors.green, + onSecondary: Colors.greenAccent, + error: Colors.red, + onError: Colors.redAccent, + background: Colors.yellow, + onBackground: Colors.yellowAccent, + surface: Colors.purple, + onSurface: Colors.purpleAccent, + ); + + const inputDecorationTheme = InputDecorationTheme( + fillColor: Colors.orange, + outlineBorder: BorderSide( + color: Colors.orangeAccent, + width: 2, + ), + ); + + group('BackgroundStyles:', () { + group('merge():', () { + group('backgroundColor:', () { + test('No theme, uses defaultLightThemeBackgroundColor', () { + var newStyles = BackgroundStyles.merge(theme: null); + expect(newStyles.backgroundColor, Colors.white); + }); + + test('With theme, uses colorScheme.background', () { + var themeData = ThemeData( + brightness: Brightness.dark, + colorScheme: colorScheme, + ); + + var newStyles = BackgroundStyles.merge(theme: themeData); + + expect(newStyles.backgroundColor, colorScheme.background); + }); + + test('With theme, uses inputDecorationTheme.fillColor', () { + var themeData = ThemeData( + brightness: Brightness.light, + inputDecorationTheme: inputDecorationTheme, + ); + + var newStyles = BackgroundStyles.merge(theme: themeData); + + expect(newStyles.backgroundColor, inputDecorationTheme.fillColor); + }); + + test('With otherStyles, uses otherStyles.backgroundColor', () { + final themeData = ThemeData( + colorScheme: colorScheme, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = + BackgroundStyles.only(backgroundColor: Colors.amber); + final newStyles = BackgroundStyles.merge( + otherStyles: otherStyles, theme: themeData); + + expect(newStyles.backgroundColor, otherStyles.backgroundColor); + }); + }); + + group('borderColor:', () { + test('No theme, uses defaultBorderColor', () { + var newStyles = BackgroundStyles.merge(theme: null); + expect(newStyles.borderColor, const Color.fromRGBO(189, 188, 188, 1)); + }); + + test('With theme, uses colorScheme.onBackground', () { + var themeData = ThemeData( + brightness: Brightness.dark, + colorScheme: colorScheme, + ); + + var newStyles = BackgroundStyles.merge(theme: themeData); + + expect(newStyles.borderColor, colorScheme.onBackground); + }); + + test('With theme, uses inputDecorationTheme.outlineBorder.color', () { + var themeData = ThemeData( + brightness: Brightness.light, + inputDecorationTheme: inputDecorationTheme, + ); + + var newStyles = BackgroundStyles.merge(theme: themeData); + + expect( + newStyles.borderColor, inputDecorationTheme.outlineBorder!.color); + }); + + test('With otherStyles, uses otherStyles.borderColor', () { + final themeData = ThemeData( + colorScheme: colorScheme, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = BackgroundStyles.only(borderColor: Colors.amber); + final newStyles = BackgroundStyles.merge( + otherStyles: otherStyles, theme: themeData); + + expect(newStyles.borderColor, otherStyles.borderColor); + }); + }); + + group('borderWidth:', () { + test('No theme, uses defaultBorderWidth', () { + var newStyles = BackgroundStyles.merge(theme: null); + expect(newStyles.borderWidth, 1.0); + }); + + test('With theme, uses inputDecorationTheme.outlineBorder.width', () { + var themeData = ThemeData( + brightness: Brightness.dark, + inputDecorationTheme: inputDecorationTheme); + + var newStyles = BackgroundStyles.merge(theme: themeData); + + expect( + newStyles.borderWidth, inputDecorationTheme.outlineBorder!.width); + }); + + test('With otherStyles, uses otherStyles.borderWidth', () { + final themeData = ThemeData( + colorScheme: colorScheme, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = BackgroundStyles.only(borderWidth: 4.5); + final newStyles = BackgroundStyles.merge( + otherStyles: otherStyles, theme: themeData); + + expect(newStyles.borderWidth, 4.5); + }); + }); + + group('borderRadius:', () { + test('Without otherStyles, uses defaultBorderRadius', () { + var newStyles = BackgroundStyles.merge(theme: null); + expect(newStyles.borderRadius, 3.0); + }); + + test('With otherStyles, uses otherStyles.borderRadius', () { + final themeData = ThemeData( + colorScheme: colorScheme, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = BackgroundStyles.only(borderRadius: 3.2); + final newStyles = BackgroundStyles.merge( + otherStyles: otherStyles, theme: themeData); + + expect(newStyles.borderRadius, 3.2); + }); + }); + }); + + group('toMap():', () { + group('All Params:', () { + test('Has correct length', () { + expect(allParamsMap.length, 4); + }); + + test('Has correct keys', () { + expect(allParamsMap.containsKey("backgroundColor"), true); + expect(allParamsMap.containsKey("borderColor"), true); + expect(allParamsMap.containsKey("borderWidth"), true); + expect(allParamsMap.containsKey("borderRadius"), true); + }); + + test('Keys have correct values', () { + expect(allParamsMap["backgroundColor"], "#ffffffff"); + expect(allParamsMap["borderColor"], "#ff000000"); + expect(allParamsMap["borderWidth"], 5.0); + expect(allParamsMap["borderRadius"], 1.0); + }); + }); + + group('Empty Params:', () { + test('Has correct length', () { + expect(emptyParamsMap.length, 4); + }); + + test('Has correct keys', () { + expect(emptyParamsMap.containsKey("backgroundColor"), true); + expect(emptyParamsMap.containsKey("borderColor"), true); + expect(emptyParamsMap.containsKey("borderWidth"), true); + expect(emptyParamsMap.containsKey("borderRadius"), true); + }); + + test('Keys have correct values', () { + expect(emptyParamsMap["backgroundColor"], null); + expect(emptyParamsMap["borderColor"], null); + expect(emptyParamsMap["borderWidth"], null); + expect(emptyParamsMap["borderRadius"], null); + }); + }); + }); + }); +} diff --git a/test/public/data_classes/card_field_state_test.dart b/test/public/data_classes/card_field_state_test.dart new file mode 100644 index 0000000..d5d8363 --- /dev/null +++ b/test/public/data_classes/card_field_state_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field_state.dart'; + +const validDataMap = { + "isValid": true, + "isFocused": false, + "isEmpty": true, + "wasEdited": false, + "wasFocused": true, +}; + +const mapWithMissingEntry = { + "isValid": true, + "isFocused": true, + "isEmpty": true, + "wasEdited": true, + // "wasFocused": true, Removed for testing purposes +}; + +const mapWithNullValue = { + "isValid": true, + "isFocused": true, + "isEmpty": true, + "wasEdited": true, + "wasFocused": null, // <-- +}; + +const mapWithNonBooleanValueType = { + "isValid": true, + "isFocused": true, + "isEmpty": true, + "wasEdited": true, + "wasFocused": "true", // <-- +}; + +void main() { + group('CardFieldState:', () { + group('fromMap():', () { + test('Converts map with all valid entries to CardFieldState', () { + identical( + CardFieldState.fromMap(validDataMap), + const CardFieldState( + isValid: false, + isFocused: true, + isEmpty: false, + wasEdited: true, + wasFocused: false, + ), + ); + }); + + test('Map with missing required entry returns null', () { + expect(CardFieldState.fromMap(mapWithMissingEntry), null); + }); + + test('Map with required value as null returns null', () { + expect(CardFieldState.fromMap(mapWithNullValue), null); + }); + + test('Map with required value as wrong type returns null', () { + expect(CardFieldState.fromMap(mapWithNonBooleanValueType), null); + }); + }); + }); +} diff --git a/test/public/data_classes/card_field_test.dart b/test/public/data_classes/card_field_test.dart new file mode 100644 index 0000000..3806ed1 --- /dev/null +++ b/test/public/data_classes/card_field_test.dart @@ -0,0 +1,30 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_field.dart'; + +void main() { + group('CardField:', () { + group('fromStringValue():', () { + test('Converts to card number field', () { + expect(CardField.cardNumber, CardField.fromStringValue("CardNumber")); + }); + + test('Converts to expiration field', () { + expect(CardField.fromStringValue("Expiration"), CardField.expiration); + }); + + test('Converts to cvv field', () { + expect(CardField.fromStringValue("Cvv"), CardField.cvv); + }); + + test('Converts to postal code field', () { + expect(CardField.fromStringValue("PostalCode"), CardField.postalCode); + }); + + test('Converts invalid string to null', () { + expect(CardField.fromStringValue("ljsdfu"), null); + }); + }); + }); +} diff --git a/test/public/data_classes/card_type_test.dart b/test/public/data_classes/card_type_test.dart new file mode 100644 index 0000000..087300f --- /dev/null +++ b/test/public/data_classes/card_type_test.dart @@ -0,0 +1,35 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/card_type.dart'; + +void main() { + group('CardType:', () { + group('fromStringValue():', () { + test('Converts to visa', () { + expect(CardType.visa, CardType.fromStringValue('Visa')); + }); + + test('Converts to americanExpress', () { + expect(CardType.americanExpress, CardType.fromStringValue('Amex')); + }); + + test('Converts to discover', () { + expect(CardType.discover, CardType.fromStringValue('Discover')); + }); + + test('Converts to masterCard', () { + expect(CardType.masterCard, CardType.fromStringValue('Mastercard')); + }); + + test('Converts to unsupported', () { + expect(CardType.unsupported, CardType.fromStringValue('Unsupported')); + }); + + test('Converts to unknown', () { + expect(CardType.unknown, CardType.fromStringValue('Unknown')); + expect(CardType.unknown, CardType.fromStringValue('lkjsen')); + }); + }); + }); +} diff --git a/test/public/data_classes/custom_error_messages_test.dart b/test/public/data_classes/custom_error_messages_test.dart new file mode 100644 index 0000000..77231a3 --- /dev/null +++ b/test/public/data_classes/custom_error_messages_test.dart @@ -0,0 +1,100 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_error_messages.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_field_errors.dart'; + +void main() { + final allCustomErrorMessages = const CustomErrorMessages( + number: CustomFieldErrors( + emptyError: "Custom empty number", + invalidError: "Custom invalid number", + ), + expiration: CustomFieldErrors( + emptyError: "Custom empty expiration", + invalidError: "Custom invalid expiration", + ), + cvv: CustomFieldErrors( + emptyError: "Custom empty cvv", + invalidError: "Custom invalid cvv", + ), + postalCode: CustomFieldErrors( + emptyError: "Custom empty postalCode", + invalidError: "Custom invalid postalCode", + ), + unsupportedCardError: "Custom unsupported card", + ).toMap(); + + final noCustomErrorMessages = const CustomErrorMessages.only().toMap(); + + group('CustomErrorMessages:', () { + group('toMap():', () { + group('All CustomErrorMessages:', () { + test('Has correct length', () { + expect(allCustomErrorMessages.length, 5); + }); + test('Has correct keys', () { + expect(allCustomErrorMessages.containsKey('CardNumber'), true); + expect(allCustomErrorMessages.containsKey('Expiration'), true); + expect(allCustomErrorMessages.containsKey('Cvv'), true); + expect(allCustomErrorMessages.containsKey('PostalCode'), true); + expect( + allCustomErrorMessages.containsKey('unsupportedCardError'), true); + }); + test('Has correct values', () { + expect( + allCustomErrorMessages['CardNumber'], + { + 'emptyError': 'Custom empty number', + 'invalidError': 'Custom invalid number' + }, + ); + expect( + allCustomErrorMessages['Expiration'], + { + 'emptyError': 'Custom empty expiration', + 'invalidError': 'Custom invalid expiration' + }, + ); + expect( + allCustomErrorMessages['Cvv'], + { + 'emptyError': 'Custom empty cvv', + 'invalidError': 'Custom invalid cvv' + }, + ); + expect( + allCustomErrorMessages['PostalCode'], + { + 'emptyError': 'Custom empty postalCode', + 'invalidError': 'Custom invalid postalCode' + }, + ); + expect(allCustomErrorMessages['unsupportedCardError'], + "Custom unsupported card"); + }); + }); + + group('Default CustomErrorMessages:', () { + test('Has correct length', () { + expect(noCustomErrorMessages.length, 5); + }); + test('Has correct keys', () { + expect(noCustomErrorMessages.containsKey('CardNumber'), true); + expect(noCustomErrorMessages.containsKey('Expiration'), true); + expect(noCustomErrorMessages.containsKey('Cvv'), true); + expect(noCustomErrorMessages.containsKey('PostalCode'), true); + expect( + noCustomErrorMessages.containsKey('unsupportedCardError'), true); + }); + test('Has correct values', () { + expect(noCustomErrorMessages['CardNumber'], null); + expect(noCustomErrorMessages['Expiration'], null); + expect(noCustomErrorMessages['Cvv'], null); + expect(noCustomErrorMessages['PostalCode'], null); + expect(noCustomErrorMessages['unsupportedCardError'], null); + }); + }); + }); + }); +} diff --git a/test/public/data_classes/custom_field_error_test.dart b/test/public/data_classes/custom_field_error_test.dart new file mode 100644 index 0000000..e6f7acb --- /dev/null +++ b/test/public/data_classes/custom_field_error_test.dart @@ -0,0 +1,44 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/custom_field_errors.dart'; + +void main() { + final allCustomFieldErrors = const CustomFieldErrors( + emptyError: "Custom empty error", + invalidError: "Custom invalid error", + ).toMap(); + + final noCustomFieldErrors = const CustomFieldErrors.only().toMap(); + + group('CustomErrorMessages:', () { + group('toMap():', () { + group('All CustomErrorMessages:', () { + test('Has correct length', () { + expect(allCustomFieldErrors.length, 2); + }); + test('Has correct keys', () { + expect(allCustomFieldErrors.containsKey('emptyError'), true); + expect(allCustomFieldErrors.containsKey('invalidError'), true); + }); + test('Has correct values', () { + expect(allCustomFieldErrors['emptyError'], 'Custom empty error'); + expect(allCustomFieldErrors['invalidError'], 'Custom invalid error'); + }); + }); + group('Default CustomErrorMessages:', () { + test('Has correct length', () { + expect(noCustomFieldErrors.length, 2); + }); + test('Has correct keys', () { + expect(noCustomFieldErrors.containsKey('emptyError'), true); + expect(noCustomFieldErrors.containsKey('invalidError'), true); + }); + test('Has correct values', () { + expect(noCustomFieldErrors['emptyError'], null); + expect(noCustomFieldErrors['invalidError'], null); + }); + }); + }); + }); +} diff --git a/test/public/data_classes/digital_wallet_payment_parameters_test.dart b/test/public/data_classes/digital_wallet_payment_parameters_test.dart new file mode 100644 index 0000000..da088f5 --- /dev/null +++ b/test/public/data_classes/digital_wallet_payment_parameters_test.dart @@ -0,0 +1,60 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/digital_wallet_payment_parameters.dart'; + +void main() { + final allParamsMap = const DigitalWalletPaymentParameters( + amount: 120, + currencyCode: "CAD", + currencyMultiplier: 120, + countryCode: "CA", + ).toMap(); + + final requiredParamsMap = + const DigitalWalletPaymentParameters(amount: 234).toMap(); + + group('DigitalWalletPaymentParameters:', () { + group('toMap():', () { + group('All Params:', () { + test('Has correct length', () { + expect(allParamsMap.length, 4); + }); + + test('Has correct keys', () { + expect(allParamsMap.containsKey('amount'), true); + expect(allParamsMap.containsKey('countryCode'), true); + expect(allParamsMap.containsKey('currencyCode'), true); + expect(allParamsMap.containsKey('currencyMultiplier'), true); + }); + + test('Keys have correct values', () { + expect(allParamsMap['amount'], 120); + expect(allParamsMap['countryCode'], 'CA'); + expect(allParamsMap['currencyCode'], 'CAD'); + expect(allParamsMap['currencyMultiplier'], 120); + }); + }); + + group('Required Params Only:', () { + test('Has correct length', () { + expect(requiredParamsMap.length, 4); + }); + + test('Has correct keys', () { + expect(requiredParamsMap.containsKey('amount'), true); + expect(requiredParamsMap.containsKey('countryCode'), true); + expect(requiredParamsMap.containsKey('currencyCode'), true); + expect(requiredParamsMap.containsKey('currencyMultiplier'), true); + }); + + test('Keys have correct values', () { + expect(requiredParamsMap['amount'], 234); + expect(requiredParamsMap['countryCode'], "US"); + expect(requiredParamsMap['currencyCode'], "USD"); + expect(requiredParamsMap['currencyMultiplier'], 100); + }); + }); + }); + }); +} diff --git a/test/public/data_classes/google_pay_setup_parameters_test.dart b/test/public/data_classes/google_pay_setup_parameters_test.dart new file mode 100644 index 0000000..da00e8e --- /dev/null +++ b/test/public/data_classes/google_pay_setup_parameters_test.dart @@ -0,0 +1,82 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_setup_parameters.dart'; + +void main() { + final allParamsMap = const GooglePaySetupParameters( + countryCode: "CA", + merchantName: "Test Merchant 1", + productionEnvironment: false, + fullAddressFormat: true, + existingPaymentMethodRequired: false, + emailRequired: true, + phoneNumberRequired: false, + ).toMap(); + + final requiredParamsMap = const GooglePaySetupParameters( + countryCode: "US", + merchantName: "Test Merchant 2", + ).toMap(); + + group('GooglePaySetupParameters:', () { + group('toMap():', () { + group('All Params', () { + test('Has correct length', () { + expect(allParamsMap.length, 7); + }); + + test('Has correct keys', () { + expect(allParamsMap.containsKey('countryCode'), true); + expect(allParamsMap.containsKey('merchantName'), true); + expect( + allParamsMap.containsKey('googlePayProductionEnvironment'), true); + expect(allParamsMap.containsKey('fullAddressFormat'), true); + expect( + allParamsMap.containsKey('existingPaymentMethodRequired'), true); + expect(allParamsMap.containsKey('emailRequired'), true); + expect(allParamsMap.containsKey('phoneNumberRequired'), true); + }); + + test('Keys have correct values', () { + expect(allParamsMap['countryCode'], 'CA'); + expect(allParamsMap['merchantName'], 'Test Merchant 1'); + expect(allParamsMap['googlePayProductionEnvironment'], false); + expect(allParamsMap['fullAddressFormat'], true); + expect(allParamsMap['existingPaymentMethodRequired'], false); + expect(allParamsMap['emailRequired'], true); + expect(allParamsMap['phoneNumberRequired'], false); + }); + }); + + group('Required Params Only', () { + test('Has correct length', () { + expect(requiredParamsMap.length, 7); + }); + + test('Has correct keys', () { + expect(requiredParamsMap.containsKey('countryCode'), true); + expect(requiredParamsMap.containsKey('merchantName'), true); + expect( + requiredParamsMap.containsKey('googlePayProductionEnvironment'), + true); + expect(requiredParamsMap.containsKey('fullAddressFormat'), true); + expect(requiredParamsMap.containsKey('existingPaymentMethodRequired'), + true); + expect(requiredParamsMap.containsKey('emailRequired'), true); + expect(requiredParamsMap.containsKey('phoneNumberRequired'), true); + }); + + test('Keys have correct values', () { + expect(requiredParamsMap['countryCode'], 'US'); + expect(requiredParamsMap['merchantName'], 'Test Merchant 2'); + expect(requiredParamsMap['googlePayProductionEnvironment'], true); + expect(requiredParamsMap['fullAddressFormat'], false); + expect(requiredParamsMap['existingPaymentMethodRequired'], true); + expect(requiredParamsMap['emailRequired'], false); + expect(requiredParamsMap['phoneNumberRequired'], false); + }); + }); + }); + }); +} diff --git a/test/public/data_classes/google_pay_vendor_parameters_test.dart b/test/public/data_classes/google_pay_vendor_parameters_test.dart new file mode 100644 index 0000000..735aa81 --- /dev/null +++ b/test/public/data_classes/google_pay_vendor_parameters_test.dart @@ -0,0 +1,29 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/google_pay_vendor_parameters.dart'; + +void main() { + final paramsMap = const GooglePayVendorParameters( + countryCode: "US", + merchantName: "Test Merchant", + ).toMap(); + + group('GooglePayVendorParameters:', () { + group('toMap():', () { + test('Has correct length', () { + expect(paramsMap.length, 2); + }); + + test('Has correct keys', () { + expect(paramsMap.containsKey("countryCode"), true); + expect(paramsMap.containsKey("merchantName"), true); + }); + + test('Keys have correct values', () { + expect(paramsMap["countryCode"], "US"); + expect(paramsMap["merchantName"], "Test Merchant"); + }); + }); + }); +} diff --git a/test/public/data_classes/hints_test.dart b/test/public/data_classes/hints_test.dart new file mode 100644 index 0000000..6adb527 --- /dev/null +++ b/test/public/data_classes/hints_test.dart @@ -0,0 +1,101 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/hints.dart'; + +void main() { + final allHintsMap = const Hints( + cardNumber: 'Card', + expiration: 'Exp', + cvv: 'Sec Code', + postalCode: 'Zip', + ).toMap(); + + final defaultHintsMap = const Hints.only().toMap(); + + group('Hints:', () { + group('toMap():', () { + group('All Hints:', () { + test('Has correct length', () { + expect(allHintsMap.length, 4); + }); + + test('Has correct keys', () { + expect(allHintsMap.containsKey('CardNumber'), true); + expect(allHintsMap.containsKey('Expiration'), true); + expect(allHintsMap.containsKey('Cvv'), true); + expect(allHintsMap.containsKey('PostalCode'), true); + }); + + test('Keys have correct values', () { + expect(allHintsMap['CardNumber'], 'Card'); + expect(allHintsMap['Expiration'], 'Exp'); + expect(allHintsMap['Cvv'], 'Sec Code'); + expect(allHintsMap['PostalCode'], 'Zip'); + }); + }); + + group('Default Hints:', () { + test('Has correct length', () { + expect(defaultHintsMap.length, 4); + }); + + test('Has correct keys', () { + expect(defaultHintsMap.containsKey('CardNumber'), true); + expect(defaultHintsMap.containsKey('Expiration'), true); + expect(defaultHintsMap.containsKey('Cvv'), true); + expect(defaultHintsMap.containsKey('PostalCode'), true); + }); + + test('Keys have correct values', () { + expect(defaultHintsMap['CardNumber'], '4242 4242 4242 4242'); + expect(defaultHintsMap['Expiration'], 'MM/YY'); + expect(defaultHintsMap['Cvv'], 'CVV'); + expect(defaultHintsMap['PostalCode'], 'Postal Code'); + }); + }); + }); + + group('isEqualTo():', () { + test('All values equal, equality passes', () { + const hints1 = Hints.defaults; + const hints2 = Hints.only(); + + expect(hints1.isEqualTo(hints2), true); + expect(hints2.isEqualTo(hints1), true); + }); + + test('cardNumber different, equality fails', () { + const hints1 = Hints.only(cardNumber: "4242"); + const hints2 = Hints.only(cardNumber: 'Card'); + + expect(hints1.isEqualTo(hints2), false); + expect(hints2.isEqualTo(hints1), false); + }); + + test('expiration different, equality fails', () { + const hints1 = Hints.only(expiration: "Exp"); + const hints2 = Hints.only(expiration: 'MM/YY'); + + expect(hints1.isEqualTo(hints2), false); + expect(hints2.isEqualTo(hints1), false); + }); + + test('cvv different, equality fails', () { + const hints1 = Hints.only(cvv: "cvc"); + const hints2 = Hints.only(cvv: 'cvv2'); + + expect(hints1.isEqualTo(hints2), false); + expect(hints2.isEqualTo(hints1), false); + }); + + test('postalCode different, equality fails', () { + const hints1 = Hints.only(postalCode: "Zip"); + const hints2 = Hints.only(postalCode: 'Zip+4'); + + expect(hints1.isEqualTo(hints2), false); + expect(hints2.isEqualTo(hints1), false); + }); + }); + }); +} diff --git a/test/public/data_classes/olo_pay_setup_parameters_test.dart b/test/public/data_classes/olo_pay_setup_parameters_test.dart new file mode 100644 index 0000000..9530a4b --- /dev/null +++ b/test/public/data_classes/olo_pay_setup_parameters_test.dart @@ -0,0 +1,13 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/olo_pay_setup_parameters.dart'; + +void main() { + group('OloPaySetupParameters:', () { + test('Optional constructor params have correct default value', () { + const params = OloPaySetupParameters(); + expect(params.productionEnvironment, true); + }); + }); +} diff --git a/test/public/data_classes/padding_styles_test.dart b/test/public/data_classes/padding_styles_test.dart new file mode 100644 index 0000000..c679e57 --- /dev/null +++ b/test/public/data_classes/padding_styles_test.dart @@ -0,0 +1,72 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/padding_styles.dart'; + +void main() { + final allParamsMap = const PaddingStyles( + startPadding: 1.0, + endPadding: 2.0, + topPadding: 3.0, + bottomPadding: 4.0, + ).toMap(); + + final defaultParamsMap = PaddingStyles.defaults.toMap(); + + group('PaddingStyles:', () { + group('toMap():', () { + group('All Params:', () { + test('Has correct length', () { + expect(allParamsMap.length, 4); + }); + + test('Has correct keys', () { + expect(allParamsMap.containsKey('startPadding'), true); + expect(allParamsMap.containsKey('endPadding'), true); + expect(allParamsMap.containsKey('topPadding'), true); + expect(allParamsMap.containsKey('bottomPadding'), true); + }); + + test('Keys have correct values', () { + expect(allParamsMap['startPadding'], 1.0); + expect(allParamsMap['endPadding'], 2.0); + expect(allParamsMap['topPadding'], 3.0); + expect(allParamsMap['bottomPadding'], 4.0); + }); + }); + + group('Default Params:', () { + test('Has correct length', () { + expect(defaultParamsMap.length, 4); + }); + + test('Has correct keys', () { + expect(defaultParamsMap.containsKey('startPadding'), true); + expect(defaultParamsMap.containsKey('endPadding'), true); + expect(defaultParamsMap.containsKey('topPadding'), true); + expect(defaultParamsMap.containsKey('bottomPadding'), true); + }); + + test('Keys have correct values', () { + expect(defaultParamsMap['startPadding'], 8.0); + expect(defaultParamsMap['endPadding'], 8.0); + expect(defaultParamsMap['topPadding'], 0.0); + expect(defaultParamsMap['bottomPadding'], 0.0); + }); + + test("Same as empty only() constructor", () { + final emptyOnlyConstructor = const PaddingStyles.only().toMap(); + + expect(defaultParamsMap['startPadding'], + emptyOnlyConstructor['startPadding']); + expect(defaultParamsMap['endPadding'], + emptyOnlyConstructor['endPadding']); + expect(defaultParamsMap['topPadding'], + emptyOnlyConstructor['topPadding']); + expect(defaultParamsMap['bottomPadding'], + emptyOnlyConstructor['bottomPadding']); + }); + }); + }); + }); +} diff --git a/test/public/data_classes/text_field_alignment_test.dart b/test/public/data_classes/text_field_alignment_test.dart new file mode 100644 index 0000000..3f2ea94 --- /dev/null +++ b/test/public/data_classes/text_field_alignment_test.dart @@ -0,0 +1,20 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:olo_pay_sdk/olo_pay_sdk_data_classes.dart'; + +void main() { + group('TextFieldAlignment:', () { + group('toString():', () { + test('Converts value `center` to string', () { + expect(TextFieldAlignment.center.toString(), "center"); + }); + test('Converts value `left` to string', () { + expect(TextFieldAlignment.left.toString(), "left"); + }); + test('Converts value `right` to string', () { + expect(TextFieldAlignment.right.toString(), "right"); + }); + }); + }); +} diff --git a/test/public/data_classes/text_styles_test.dart b/test/public/data_classes/text_styles_test.dart new file mode 100644 index 0000000..512fd23 --- /dev/null +++ b/test/public/data_classes/text_styles_test.dart @@ -0,0 +1,286 @@ +// Copyright © 2022 Olo Inc. All rights reserved. +// This software is made available under the Olo Pay SDK License (See LICENSE.md file) +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:olo_pay_sdk/src/public/data_classes/text_styles.dart'; + +void main() { + final allParamsMap = const TextStyles( + textColor: Colors.black, + errorTextColor: Colors.orange, + cursorColor: Colors.orangeAccent, + hintTextColor: Colors.indigo, + textSize: 12, + fontAsset: "font asset", + iOSFontName: "font name", + ).toMap(); + + final emptyParamsMap = const TextStyles.only().toMap(); + + const colorScheme = ColorScheme( + brightness: Brightness.light, + primary: Colors.blue, + onPrimary: Colors.blueAccent, + secondary: Colors.green, + onSecondary: Colors.greenAccent, + error: Colors.red, + onError: Colors.redAccent, + background: Colors.yellow, + onBackground: Colors.yellowAccent, + surface: Colors.purple, + onSurface: Colors.purpleAccent, + ); + + const inputDecorationTheme = InputDecorationTheme( + errorStyle: TextStyle(color: Colors.grey), + hintStyle: TextStyle(color: Colors.cyan), + ); + + const textSelectionTheme = + TextSelectionThemeData(cursorColor: Colors.blueGrey); + + group('TextStyles:', () { + group('merge():', () { + group('textColor:', () { + test('No theme, uses defaultLightThemeTextColor', () { + final newStyles = TextStyles.merge(theme: null); + expect(newStyles.textColor, const Color.fromRGBO(20, 20, 20, 1)); + }); + + // NOTE: We do not have a test for ensuring colorScheme.onBackground is used + // if textTheme.bodyMedium is null because, even though that property + // is nullable, I could not find a configuration that would make it null + test('With theme, uses textTheme.bodyMedium.color', () { + final themeData = ThemeData( + textTheme: + const TextTheme(bodyMedium: TextStyle(color: Colors.blue)), + ); + + final newStyles = TextStyles.merge(theme: themeData); + + expect(newStyles.textColor, Colors.blue); + }); + + test('With otherStyles, uses otherStyles.textColor', () { + final themeData = ThemeData(colorScheme: colorScheme); + + const otherStyles = TextStyles.only(textColor: Colors.amber); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: themeData); + + expect(newStyles.textColor, otherStyles.textColor); + }); + }); + + group('errorTextColor:', () { + test('No theme, uses defaultLightThemeErrorTextColor', () { + final newStyles = TextStyles.merge(theme: null); + expect( + newStyles.errorTextColor, const Color.fromRGBO(196, 45, 50, 1)); + }); + + test('With theme, uses colorScheme.error', () { + final themeData = ThemeData(colorScheme: colorScheme); + final newStyles = TextStyles.merge(theme: themeData); + expect(newStyles.errorTextColor, colorScheme.error); + }); + + test('With theme, uses inputDecorationTheme.errorStyle.color', () { + final themeData = + ThemeData(inputDecorationTheme: inputDecorationTheme); + final newStyles = TextStyles.merge(theme: themeData); + + expect( + newStyles.errorTextColor, inputDecorationTheme.errorStyle!.color); + }); + + test('With otherStyles, uses otherStyles.errorTextColor', () { + final themeData = ThemeData( + colorScheme: colorScheme, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = TextStyles.only(errorTextColor: Colors.amber); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: themeData); + + expect(newStyles.errorTextColor, otherStyles.errorTextColor); + }); + }); + + group('cursorColor:', () { + test('No theme, uses defaultCursorColor', () { + final newStyles = TextStyles.merge(theme: null); + expect(newStyles.cursorColor, Colors.grey); + }); + + test('With theme, uses colorScheme.primary', () { + final themeData = ThemeData(colorScheme: colorScheme); + final newStyles = TextStyles.merge(theme: themeData); + expect(newStyles.cursorColor, colorScheme.primary); + }); + + test('With theme, uses textSelectionTheme.cursorColor', () { + final themeData = ThemeData(textSelectionTheme: textSelectionTheme); + final newStyles = TextStyles.merge(theme: themeData); + + expect(newStyles.cursorColor, textSelectionTheme.cursorColor); + }); + + test('With otherStyles, uses otherStyles.cursorColor', () { + final themeData = ThemeData( + colorScheme: colorScheme, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = TextStyles.only(cursorColor: Colors.amber); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: themeData); + + expect(newStyles.cursorColor, otherStyles.cursorColor); + }); + }); + + group('hintTextColor:', () { + test('No theme, uses defaultHintTextColor', () { + final newStyles = TextStyles.merge(theme: null); + expect(newStyles.hintTextColor, const Color.fromRGBO(91, 89, 89, 1)); + }); + + test('With theme, uses theme.hintColor', () { + final themeData = ThemeData(hintColor: Colors.amber); + final newStyles = TextStyles.merge(theme: themeData); + expect(newStyles.hintTextColor, Colors.amber); + }); + + test('With theme, uses inputDecoration.hintStyle.color', () { + final themeData = + ThemeData(inputDecorationTheme: inputDecorationTheme); + final newStyles = TextStyles.merge(theme: themeData); + + expect( + newStyles.hintTextColor, inputDecorationTheme.hintStyle!.color); + }); + + test('With otherStyles, uses otherStyles.hintTextColor', () { + final themeData = ThemeData( + hintColor: Colors.cyanAccent, + inputDecorationTheme: inputDecorationTheme, + ); + + const otherStyles = TextStyles.only(hintTextColor: Colors.amber); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: themeData); + + expect(newStyles.hintTextColor, otherStyles.hintTextColor); + }); + }); + + group('textSize:', () { + test('No theme, uses defaultFontSize', () { + final newStyles = TextStyles.merge(theme: null); + expect(newStyles.textSize, 14.0); + }); + + // NOTE: We do not have a test for ensuring colorScheme.onBackground is used + // if textTheme.bodyMedium is null because, even though that property + // is nullable, I could not find a configuration that would make it null + test('With theme, uses textTheme.bodyMedium.fontSize', () { + final themeData = ThemeData( + textTheme: const TextTheme(bodyMedium: TextStyle(fontSize: 11.0)), + ); + + final newStyles = TextStyles.merge(theme: themeData); + + expect(newStyles.textSize, 11.0); + }); + + test('With otherStyles, uses otherStyles.textSize', () { + final themeData = ThemeData( + textTheme: const TextTheme(bodyMedium: TextStyle(fontSize: 11.0)), + ); + + const otherStyles = TextStyles.only(textSize: 18.0); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: themeData); + + expect(newStyles.textSize, otherStyles.textSize); + }); + }); + + group('fontAsset: ', () { + test('With otherStyles, uses otherStyles.fontAsset', () { + const otherStyles = TextStyles.only(fontAsset: "font"); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: null); + + expect(newStyles.fontAsset, otherStyles.fontAsset); + }); + }); + + group('iOSFontName: ', () { + test('With otherStyles, uses otherStyles.iOSFontName', () { + const otherStyles = TextStyles.only(iOSFontName: "name"); + final newStyles = + TextStyles.merge(otherStyles: otherStyles, theme: null); + + expect(newStyles.iOSFontName, otherStyles.iOSFontName); + }); + }); + }); + + group('toMap():', () { + group('All Params:', () { + test('Has correct length', () { + expect(allParamsMap.length, 7); + }); + + test('Has correct keys', () { + expect(allParamsMap.containsKey("textColor"), true); + expect(allParamsMap.containsKey("errorTextColor"), true); + expect(allParamsMap.containsKey("cursorColor"), true); + expect(allParamsMap.containsKey("hintTextColor"), true); + expect(allParamsMap.containsKey("textSize"), true); + expect(allParamsMap.containsKey("fontAsset"), true); + expect(allParamsMap.containsKey("fontName"), true); + }); + + test('Keys have correct values', () { + expect(allParamsMap["textColor"], "#ff000000"); + expect(allParamsMap["errorTextColor"], "#ffff9800"); + expect(allParamsMap["cursorColor"], '#ffffab40'); + expect(allParamsMap["hintTextColor"], '#ff3f51b5'); + expect(allParamsMap["textSize"], 12.0); + expect(allParamsMap["fontAsset"], "font asset"); + expect(allParamsMap["fontName"], "font name"); + }); + }); + + group('Empty Params:', () { + test('Has correct length', () { + expect(emptyParamsMap.length, 7); + }); + + test('Has correct keys', () { + expect(emptyParamsMap.containsKey("textColor"), true); + expect(emptyParamsMap.containsKey("errorTextColor"), true); + expect(emptyParamsMap.containsKey("cursorColor"), true); + expect(emptyParamsMap.containsKey("hintTextColor"), true); + expect(emptyParamsMap.containsKey("textSize"), true); + expect(allParamsMap.containsKey("fontAsset"), true); + expect(allParamsMap.containsKey("fontName"), true); + }); + + test('Keys have correct values', () { + expect(emptyParamsMap["textColor"], null); + expect(emptyParamsMap["errorTextColor"], null); + expect(emptyParamsMap["cursorColor"], null); + expect(emptyParamsMap["hintTextColor"], null); + expect(emptyParamsMap["textSize"], null); + expect(emptyParamsMap["fontAsset"], null); + expect(emptyParamsMap["fontName"], null); + }); + }); + }); + }); +}