diff --git a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m index e7a30e11b93f..a3b08e26342e 100644 --- a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m +++ b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/Stubs.m @@ -23,7 +23,7 @@ - (instancetype)initWithMap:(NSDictionary *)map { self = [super init]; if (self) { [self setValue:map[@"price"] ?: [NSNull null] forKey:@"price"]; - NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US"]; + NSLocale *locale = NSLocale.systemLocale; [self setValue:locale ?: [NSNull null] forKey:@"priceLocale"]; [self setValue:map[@"numberOfPeriods"] ?: @(0) forKey:@"numberOfPeriods"]; SKProductSubscriptionPeriodStub *subscriptionPeriodSub = @@ -46,7 +46,7 @@ - (instancetype)initWithMap:(NSDictionary *)map { [self setValue:map[@"localizedTitle"] ?: [NSNull null] forKey:@"localizedTitle"]; [self setValue:map[@"downloadable"] ?: @NO forKey:@"downloadable"]; [self setValue:map[@"price"] ?: [NSNull null] forKey:@"price"]; - NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US"]; + NSLocale *locale = NSLocale.systemLocale; [self setValue:locale ?: [NSNull null] forKey:@"priceLocale"]; [self setValue:map[@"downloadContentLengths"] ?: @(0) forKey:@"downloadContentLengths"]; SKProductSubscriptionPeriodStub *period = diff --git a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/TranslatorTest.m b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/TranslatorTest.m index fa4cd1700daf..10bf67f5a364 100644 --- a/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/TranslatorTest.m +++ b/packages/in_app_purchase/example/ios/in_app_purchase_pluginTests/TranslatorTest.m @@ -12,6 +12,7 @@ @interface TranslatorTest : XCTestCase @property(strong, nonatomic) NSDictionary *discountMap; @property(strong, nonatomic) NSDictionary *productMap; @property(strong, nonatomic) NSDictionary *productResponseMap; +@property(strong, nonatomic) NSDictionary *localeMap; @end @@ -21,14 +22,14 @@ - (void)setUp { self.periodMap = @{@"numberOfUnits" : @(0), @"unit" : @(0)}; self.discountMap = @{ @"price" : @1.0, - @"currencyCode" : @"USD", + @"priceLocale" : [NSLocale.systemLocale toMap], @"numberOfPeriods" : @1, @"subscriptionPeriod" : self.periodMap, @"paymentMode" : @1 }; self.productMap = @{ @"price" : @1.0, - @"currencyCode" : @"USD", + @"priceLocale" : [NSLocale.systemLocale toMap], @"productIdentifier" : @"123", @"localizedTitle" : @"title", @"localizedDescription" : @"des", @@ -69,4 +70,10 @@ - (void)testProductResponseToMap { XCTAssertEqualObjects(map, self.productResponseMap); } +- (void)testLocaleToMap { + NSLocale *system = NSLocale.systemLocale; + NSDictionary *map = [system toMap]; + XCTAssertEqualObjects(map[@"currencySymbol"], system.currencySymbol); +} + @end diff --git a/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.h b/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.h index 260dc15ab3fe..77c4bd89a77d 100644 --- a/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.h +++ b/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.h @@ -31,4 +31,10 @@ NS_ASSUME_NONNULL_BEGIN @end +@interface NSLocale (Coder) + +- (nullable NSDictionary *)toMap; + +@end + NS_ASSUME_NONNULL_END diff --git a/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m b/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m index 277dc1fcd235..d8b98448233e 100644 --- a/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m +++ b/packages/in_app_purchase/ios/Classes/FIAObjectTranslator.m @@ -19,12 +19,10 @@ - (NSDictionary *)toMap { @"downloadContentVersion" : self.downloadContentVersion ?: [NSNull null] }]; - if (@available(iOS 10.0, *)) { - // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this - // expanded to a map. Matching android to only get the currencyCode for now. - // https://github.com/flutter/flutter/issues/26610 - [map setObject:self.priceLocale.currencyCode ?: [NSNull null] forKey:@"currencyCode"]; - } + // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this + // expanded to a map. Matching android to only get the currencySymbol for now. + // https://github.com/flutter/flutter/issues/26610 + [map setObject:[self.priceLocale toMap] ?: [NSNull null] forKey:@"priceLocale"]; if (@available(iOS 11.2, *)) { [map setObject:[self.subscriptionPeriod toMap] ?: [NSNull null] forKey:@"subscriptionPeriod"]; } @@ -58,12 +56,10 @@ - (NSDictionary *)toMap { @"paymentMode" : @(self.paymentMode) }]; - if (@available(iOS 10.0, *)) { - // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this - // expanded to a map. Matching android to only get the currencyCode for now. - // https://github.com/flutter/flutter/issues/26610 - [map setObject:self.priceLocale.currencyCode ?: [NSNull null] forKey:@"currencyCode"]; - } + // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this + // expanded to a map. Matching android to only get the currencySymbol for now. + // https://github.com/flutter/flutter/issues/26610 + [map setObject:[self.priceLocale toMap] ?: [NSNull null] forKey:@"priceLocale"]; return map; } @@ -83,3 +79,14 @@ - (NSDictionary *)toMap { } @end + +@implementation NSLocale (Coder) + +- (nullable NSDictionary *)toMap { + NSMutableDictionary *map = [[NSMutableDictionary alloc] init]; + [map setObject:[self objectForKey:NSLocaleCurrencySymbol] ?: [NSNull null] + forKey:@"currencySymbol"]; + return map; +} + +@end diff --git a/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart b/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart index 631cd15c22e1..1c6ab07ce9b0 100644 --- a/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart +++ b/packages/in_app_purchase/lib/src/billing_client_wrappers/sku_details_wrapper.g.dart @@ -20,15 +20,46 @@ SkuDetailsWrapper _$SkuDetailsWrapperFromJson(Map json) { sku: json['sku'] as String, subscriptionPeriod: json['subscriptionPeriod'] as String, title: json['title'] as String, - type: const SkuTypeConverter().fromJson(json['type'] as String), + type: _$enumDecode(_$SkuTypeEnumMap, json['type']), isRewarded: json['isRewarded'] as bool); } +T _$enumDecode(Map enumValues, dynamic source) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + return enumValues.entries + .singleWhere((e) => e.value == source, + orElse: () => throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}')) + .key; +} + +const _$SkuTypeEnumMap = { + SkuType.inapp: 'inapp', + SkuType.subs: 'subs' +}; + SkuDetailsResponseWrapper _$SkuDetailsResponseWrapperFromJson(Map json) { return SkuDetailsResponseWrapper( - responseCode: const BillingResponseConverter() - .fromJson(json['responseCode'] as int), + responseCode: + _$enumDecode(_$BillingResponseEnumMap, json['responseCode']), skuDetailsList: (json['skuDetailsList'] as List) .map((e) => SkuDetailsWrapper.fromJson(e as Map)) .toList()); } + +const _$BillingResponseEnumMap = { + BillingResponse.featureNotSupported: -2, + BillingResponse.ok: 0, + BillingResponse.userCanceled: 1, + BillingResponse.serviceUnavailable: 2, + BillingResponse.billingUnavailable: 3, + BillingResponse.itemUnavailable: 4, + BillingResponse.developerError: 5, + BillingResponse.error: 6, + BillingResponse.itemAlreadyOwned: 7, + BillingResponse.itemNotOwned: 8 +}; diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart index 110a03f5d2e4..7b09fd1acce6 100644 --- a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.dart @@ -107,14 +107,12 @@ enum ProductDiscountPaymentMode { /// Dart wrapper around StoreKit's [SKProductDiscount](https://developer.apple.com/documentation/storekit/skproductdiscount?language=objc). /// -/// Most of the fields are identical to OBJC SKProduct. -/// The only difference is instead of the locale object, we only exposed currencyCode for simplicity. /// It is used as a property in [SKProductWrapper]. @JsonSerializable(nullable: true) class SKProductDiscountWrapper { SKProductDiscountWrapper( {@required this.price, - @required this.currencyCode, + @required this.priceLocale, @required this.numberOfPeriods, @required this.paymentMode, @required this.subscriptionPeriod}); @@ -128,14 +126,11 @@ class SKProductDiscountWrapper { return _$SKProductDiscountWrapperFromJson(map); } - /// The discounted price, in the currency that is defined in [currencyCode]. + /// The discounted price, in the currency that is defined in [priceLocale]. final double price; - // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this expanded to - // a map. Matching android to only get the currencyCode for now. - // https://github.com/flutter/flutter/issues/26610 - /// The currencyCode for the [price], e.g USD for U.S. dollars. - final String currencyCode; + /// Includes locale information about the price, e.g. `$` as the currency symbol for US locale. + final PriceLocaleWrapper priceLocale; /// The object represent the discount period length. /// @@ -154,8 +149,6 @@ class SKProductDiscountWrapper { /// Dart wrapper around StoreKit's [SKProduct](https://developer.apple.com/documentation/storekit/skproduct?language=objc). /// -/// Most of the fields are identical to OBJC SKProduct. -/// The only difference is instead of the locale object, we only exposed currencyCode for simplicity. /// A list of [SKProductWrapper] is returned in the [SKRequestMaker.startProductRequest] method, and /// should be stored for use when making a payment. @JsonSerializable(nullable: true) @@ -164,7 +157,7 @@ class SKProductWrapper { @required this.productIdentifier, @required this.localizedTitle, @required this.localizedDescription, - @required this.currencyCode, + @required this.priceLocale, @required this.downloadContentVersion, @required this.subscriptionGroupIdentifier, @required this.price, @@ -196,11 +189,8 @@ class SKProductWrapper { /// It is localized based on the current locale. final String localizedDescription; - // TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this expanded to - // a map. Matching android to only get the currencyCode for now. - // https://github.com/flutter/flutter/issues/26610 - /// The currencyCode for the price, e.g USD for U.S. dollars. - final String currencyCode; + /// Includes locale information about the price, e.g. `$` as the currency symbol for US locale. + final PriceLocaleWrapper priceLocale; /// The version of the downloadable content. /// @@ -214,7 +204,7 @@ class SKProductWrapper { /// Check [SubscriptionGroup](https://developer.apple.com/app-store/subscriptions/) for more details about subscription group. final String subscriptionGroupIdentifier; - /// The price of the product, in the currency that is defined in [currencyCode]. + /// The price of the product, in the currency that is defined in [priceLocale]. final double price; /// Whether the AppStore has downloadable content for this product. @@ -242,3 +232,26 @@ class SKProductWrapper { /// and their units and duration do not have to be matched. final SKProductDiscountWrapper introductoryPrice; } + +/// Object that indicates the locale of the price +/// +/// It is a thin wrapper of [NSLocale](https://developer.apple.com/documentation/foundation/nslocale?language=objc). +// TODO(cyanglaz): NSLocale is a complex object, want to see the actual need of getting this expanded. +// Matching android to only get the currencySymbol for now. +// https://github.com/flutter/flutter/issues/26610 +@JsonSerializable() +class PriceLocaleWrapper { + PriceLocaleWrapper({@required this.currencySymbol}); + + /// Constructing an instance from a map from the Objective-C layer. + /// + /// This method should only be used with `map` values returned by [SKProductWrapper.fromJson] and [SKProductDiscountWrapper.fromJson]. + /// The `map` parameter must not be null. + factory PriceLocaleWrapper.fromJson(Map map) { + assert(map != null); + return _$PriceLocaleWrapperFromJson(map); + } + + ///The currency symbol for the locale, e.g. $ for US locale. + final String currencySymbol; +} diff --git a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart index 8799d65f61f7..52467266d948 100644 --- a/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart +++ b/packages/in_app_purchase/lib/src/store_kit_wrappers/sk_product_wrapper.g.dart @@ -54,7 +54,9 @@ const _$SubscriptionPeriodUnitEnumMap = { SKProductDiscountWrapper _$SKProductDiscountWrapperFromJson(Map json) { return SKProductDiscountWrapper( price: (json['price'] as num)?.toDouble(), - currencyCode: json['currencyCode'] as String, + priceLocale: json['priceLocale'] == null + ? null + : PriceLocaleWrapper.fromJson(json['priceLocale'] as Map), numberOfPeriods: json['numberOfPeriods'] as int, paymentMode: _$enumDecodeNullable( _$ProductDiscountPaymentModeEnumMap, json['paymentMode']), @@ -76,7 +78,9 @@ SKProductWrapper _$SKProductWrapperFromJson(Map json) { productIdentifier: json['productIdentifier'] as String, localizedTitle: json['localizedTitle'] as String, localizedDescription: json['localizedDescription'] as String, - currencyCode: json['currencyCode'] as String, + priceLocale: json['priceLocale'] == null + ? null + : PriceLocaleWrapper.fromJson(json['priceLocale'] as Map), downloadContentVersion: json['downloadContentVersion'] as String, subscriptionGroupIdentifier: json['subscriptionGroupIdentifier'] as String, @@ -94,3 +98,7 @@ SKProductWrapper _$SKProductWrapperFromJson(Map json) { : SKProductDiscountWrapper.fromJson( json['introductoryPrice'] as Map)); } + +PriceLocaleWrapper _$PriceLocaleWrapperFromJson(Map json) { + return PriceLocaleWrapper(currencySymbol: json['currencySymbol'] as String); +} diff --git a/packages/in_app_purchase/test/store_kit_wrappers/sk_product_test.dart b/packages/in_app_purchase/test/store_kit_wrappers/sk_product_test.dart index f691e7a73a18..df3695ad1fda 100644 --- a/packages/in_app_purchase/test/store_kit_wrappers/sk_product_test.dart +++ b/packages/in_app_purchase/test/store_kit_wrappers/sk_product_test.dart @@ -6,13 +6,16 @@ import 'package:test/test.dart'; import 'package:in_app_purchase/src/store_kit_wrappers/sk_product_wrapper.dart'; void main() { + final Map localeMap = { + 'currencySymbol': '\$' + }; final Map subMap = { 'numberOfUnits': 1, 'unit': 2 }; final Map discountMap = { 'price': 1.0, - 'currencyCode': 'USD', + 'priceLocale': localeMap, 'numberOfPeriods': 1, 'paymentMode': 2, 'subscriptionPeriod': subMap, @@ -21,7 +24,7 @@ void main() { 'productIdentifier': 'id', 'localizedTitle': 'title', 'localizedDescription': 'description', - 'currencyCode': 'USD', + 'priceLocale': localeMap, 'downloadContentVersion': 'version', 'subscriptionGroupIdentifier': 'com.group', 'price': 1.0, @@ -37,6 +40,11 @@ void main() { }; group('product request wrapper test', () { + void testMatchLocale( + PriceLocaleWrapper wrapper, Map localeMap) { + expect(wrapper.currencySymbol, localeMap['currencySymbol']); + } + test( 'SKProductSubscriptionPeriodWrapper should have property values consistent with map', () { @@ -61,7 +69,7 @@ void main() { final SKProductDiscountWrapper wrapper = SKProductDiscountWrapper.fromJson(discountMap); expect(wrapper.price, discountMap['price']); - expect(wrapper.currencyCode, discountMap['currencyCode']); + testMatchLocale(wrapper.priceLocale, discountMap['priceLocale']); expect(wrapper.numberOfPeriods, discountMap['numberOfPeriods']); expect(wrapper.paymentMode, ProductDiscountPaymentMode.values[discountMap['paymentMode']]); @@ -79,7 +87,7 @@ void main() { final SKProductDiscountWrapper wrapper = SKProductDiscountWrapper.fromJson({}); expect(wrapper.price, null); - expect(wrapper.currencyCode, null); + expect(wrapper.priceLocale, null); expect(wrapper.numberOfPeriods, null); expect(wrapper.paymentMode, null); expect(wrapper.subscriptionPeriod, null); @@ -89,8 +97,8 @@ void main() { SKProductWrapper wrapper, Map productMap) { expect(wrapper.productIdentifier, productMap['productIdentifier']); expect(wrapper.localizedTitle, productMap['localizedTitle']); + testMatchLocale(wrapper.priceLocale, productMap['priceLocale']); expect(wrapper.localizedDescription, productMap['localizedDescription']); - expect(wrapper.currencyCode, productMap['currencyCode']); expect( wrapper.downloadContentVersion, productMap['downloadContentVersion']); expect(wrapper.subscriptionGroupIdentifier, @@ -121,6 +129,7 @@ void main() { .values[productMap['subscriptionPeriod']['unit']]); expect(wrapper.subscriptionPeriod.numberOfUnits, productMap['subscriptionPeriod']['numberOfUnits']); + expect(wrapper.price, discountMap['price']); } test('SKProductWrapper should have property values consistent with map', @@ -136,7 +145,7 @@ void main() { expect(wrapper.productIdentifier, null); expect(wrapper.localizedTitle, null); expect(wrapper.localizedDescription, null); - expect(wrapper.currencyCode, null); + expect(wrapper.priceLocale, null); expect(wrapper.downloadContentVersion, null); expect(wrapper.subscriptionGroupIdentifier, null); expect(wrapper.price, null); @@ -163,5 +172,10 @@ void main() { expect(wrapper.products.length, 0); expect(wrapper.invalidProductIdentifiers.length, 0); }); + + test('LocaleWrapper should have property values consistent with map', () { + final PriceLocaleWrapper wrapper = PriceLocaleWrapper.fromJson(localeMap); + testMatchLocale(wrapper, localeMap); + }); }); } diff --git a/packages/in_app_purchase/test/store_kit_wrappers/sk_request_test.dart b/packages/in_app_purchase/test/store_kit_wrappers/sk_request_test.dart index a03e056f45f1..2db357355889 100644 --- a/packages/in_app_purchase/test/store_kit_wrappers/sk_request_test.dart +++ b/packages/in_app_purchase/test/store_kit_wrappers/sk_request_test.dart @@ -11,13 +11,16 @@ import '../stub_in_app_purchase_platform.dart'; void main() { final StubInAppPurchasePlatform stubPlatform = StubInAppPurchasePlatform(); + final Map localeMap = { + 'currencySymbol': '\$' + }; final Map subMap = { 'numberOfUnits': 1, 'unit': 2 }; final Map discountMap = { 'price': 1.0, - 'currencyCode': 'USD', + 'priceLocale': localeMap, 'numberOfPeriods': 1, 'paymentMode': 2, 'subscriptionPeriod': subMap, @@ -26,7 +29,7 @@ void main() { 'productIdentifier': 'id', 'localizedTitle': 'title', 'localizedDescription': 'description', - 'currencyCode': 'USD', + 'priceLocale': localeMap, 'downloadContentVersion': 'version', 'subscriptionGroupIdentifier': 'com.group', 'price': 1.0, @@ -57,12 +60,12 @@ void main() { isNotEmpty, ); expect( - response.products.first.currencyCode, - 'USD', + response.products.first.priceLocale.currencySymbol, + '\$', ); expect( - response.products.first.currencyCode, - isNot('USDA'), + response.products.first.priceLocale.currencySymbol, + isNot('A'), ); expect( response.invalidProductIdentifiers,