Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: membership purchase #165

Merged
merged 4 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions App/BeMatch/Multiplatform/Production/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,7 @@
<string>ArrQmb3aHuS47UnotusTAe</string>
<key>apple-app-id</key>
<string>6473888485</string>
<key>BEMATCH_PRO_ID</key>
<string>jp.bematch.ios.production.subscription.pro.1week</string>
</dict>
</plist>
2 changes: 2 additions & 0 deletions App/BeMatch/Multiplatform/Staging/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,7 @@
<string>ArrQmb3aHuS47UnotusTAe</string>
<key>apple-app-id</key>
<string>6473888434</string>
<key>BEMATCH_PRO_ID</key>
<string>jp.bematch.ios.staging.subscription.pro</string>
</dict>
</plist>
11 changes: 11 additions & 0 deletions Packages/BeMatch/GraphQL/Queries/Membership.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
query Membership {
activeInvitationCampaign {
id
quantity
}

invitationCode {
id
code
}
}
5 changes: 3 additions & 2 deletions Packages/BeMatch/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ let package = Package(
"ForceUpdateFeature",
"MaintenanceFeature",
.product(name: "AsyncValue", package: "SDK"),
.product(name: "StoreKitClient", package: "SDK"),
.product(name: "StoreKitHelpers", package: "SDK"),
.product(name: "AppsFlyerClient", package: "SDK"),
.product(name: "ConfigGlobalClient", package: "SDK"),
.product(name: "UserSettingsClient", package: "SDK"),
Expand Down Expand Up @@ -205,7 +203,10 @@ let package = Package(
"Styleguide",
"AnalyticsKeys",
"BeMatchClient",
.product(name: "Build", package: "SDK"),
.product(name: "ColorHex", package: "SDK"),
.product(name: "StoreKitClient", package: "SDK"),
.product(name: "StoreKitHelpers", package: "SDK"),
.product(name: "FeedbackGeneratorClient", package: "SDK"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// @generated
// This file was automatically generated and should not be edited.

@_exported import ApolloAPI

public extension BeMatch {
class MembershipQuery: GraphQLQuery {
public static let operationName: String = "Membership"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query Membership { activeInvitationCampaign { __typename id quantity } invitationCode { __typename id code } }"#
))

public init() {}

public struct Data: BeMatch.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }

public static var __parentType: ApolloAPI.ParentType { BeMatch.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("activeInvitationCampaign", ActiveInvitationCampaign?.self),
.field("invitationCode", InvitationCode.self),
] }

/// 招待キャンペーンを取得
public var activeInvitationCampaign: ActiveInvitationCampaign? { __data["activeInvitationCampaign"] }
/// 招待コードを取得
public var invitationCode: InvitationCode { __data["invitationCode"] }

/// ActiveInvitationCampaign
///
/// Parent Type: `InvitationCampaign`
public struct ActiveInvitationCampaign: BeMatch.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }

public static var __parentType: ApolloAPI.ParentType { BeMatch.Objects.InvitationCampaign }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", BeMatch.ID.self),
.field("quantity", Int.self),
] }

public var id: BeMatch.ID { __data["id"] }
/// 招待キャンペーンの発行数
public var quantity: Int { __data["quantity"] }
}

/// InvitationCode
///
/// Parent Type: `InvitationCode`
public struct InvitationCode: BeMatch.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }

public static var __parentType: ApolloAPI.ParentType { BeMatch.Objects.InvitationCode }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", BeMatch.ID.self),
.field("code", String.self),
] }

public var id: BeMatch.ID { __data["id"] }
/// 招待コード
public var code: String { __data["code"] }
}
}
}
}
2 changes: 1 addition & 1 deletion Packages/BeMatch/Sources/BeMatchClient/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public struct BeMatchClient: Sendable {

public var createInvitation: @Sendable (BeMatch.CreateInvitationInput) async throws -> BeMatch.CreateInvitationMutation.Data
public var invitationCode: @Sendable () -> AsyncThrowingStream<BeMatch.InvitationCodeQuery.Data, Error> = { .finished() }
public var activeInvitationCampaign: @Sendable () -> AsyncThrowingStream<BeMatch.ActiveInvitationCampaignQuery.Data, Error> = { .finished() }
public var membership: @Sendable () -> AsyncThrowingStream<BeMatch.MembershipQuery.Data, Error> = { .finished() }

public var activePremiumMemberships: @Sendable () -> AsyncThrowingStream<BeMatch.ActivePremiumMembershipsQuery.Data, Error> = { .finished() }
public var createAppleSubscription: @Sendable (BeMatch.CreateAppleSubscriptionInput) async throws -> BeMatch.CreateAppleSubscriptionMutation.Data
Expand Down
4 changes: 2 additions & 2 deletions Packages/BeMatch/Sources/BeMatchClient/LiveKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ public extension BeMatchClient {
let query = BeMatch.InvitationCodeQuery()
return apolloClient.watch(query: query)
},
activeInvitationCampaign: {
let query = BeMatch.ActiveInvitationCampaignQuery()
membership: {
let query = BeMatch.MembershipQuery()
return apolloClient.watch(query: query)
},
activePremiumMemberships: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ public struct InvitationCampaignLogic {

public enum Action {
case onTask
case onAppear
}

@Dependency(\.analytics) var analytics
Expand All @@ -27,10 +26,6 @@ public struct InvitationCampaignLogic {
switch action {
case .onTask:
return .none

case .onAppear:
analytics.logScreen(screenName: "InvitationCampaign", of: self)
return .none
}
}
}
Expand Down Expand Up @@ -96,7 +91,6 @@ public struct InvitationCampaignView: View {
.background(backgroundGradient)
.multilineTextAlignment(.center)
.task { await store.send(.onTask).finish() }
.onAppear { store.send(.onAppear) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ public struct InvitationCodeCampaignLogic {
public init() {}

public struct State: Equatable {
var code = "ACF"
public init() {}
let code: String

public init(code: String) {
self.code = code
}
}

public enum Action {
case onTask
case onAppear
}

@Dependency(\.analytics) var analytics
Expand All @@ -24,10 +26,6 @@ public struct InvitationCodeCampaignLogic {
switch action {
case .onTask:
return .none

case .onAppear:
analytics.logScreen(screenName: "InvitationCodeCampaign", of: self)
return .none
}
}
}
Expand Down Expand Up @@ -71,15 +69,14 @@ public struct InvitationCodeCampaignView: View {
.background()
.multilineTextAlignment(.center)
.task { await store.send(.onTask).finish() }
.onAppear { store.send(.onAppear) }
}
}
}

#Preview {
InvitationCodeCampaignView(
store: .init(
initialState: InvitationCodeCampaignLogic.State(),
initialState: InvitationCodeCampaignLogic.State(code: "ABCDEF"),
reducer: { InvitationCodeCampaignLogic() }
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@
}
}
}
},
"MembershipPurchase" : {

},
"Recurring billing. You can cancel at any time. Payment will be charged to your iTunes account and your subscription will auto-renew at $500/week until you cancel in iTunes Store settings. By tapping Unlock, you agree to the Terms of Service and auto-renewal." : {
"localizations" : {
Expand Down
120 changes: 106 additions & 14 deletions Packages/BeMatch/Sources/MembershipFeature/Membership.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import AnalyticsClient
import BeMatch
import BeMatchClient
import Build
import ComposableArchitecture
import StoreKit
import StoreKitClient
import StoreKitHelpers
import SwiftUI

@Reducer
Expand All @@ -12,53 +16,121 @@ public struct MembershipLogic {
var child: Child.State?
var isActivityIndicatorVisible = false

public init() {}
let bematchProOneWeekId: String
var product: StoreKit.Product?

public init() {
@Dependency(\.build) var build
bematchProOneWeekId = build.infoDictionary("BEMATCH_PRO_ID", for: String.self)!
}
}

public enum Action {
case onTask
case closeButtonTapped
case activeInvitationCampaignResponse(Result<BeMatch.ActiveInvitationCampaignQuery.Data, Error>)
case productsResponse(Result<[Product], Error>)
case membershipResponse(Result<BeMatch.MembershipQuery.Data, Error>)
case purchaseResponse(Result<StoreKit.Transaction, Error>)
case child(Child.Action)
}

@Dependency(\.build) var build
@Dependency(\.store) var store
@Dependency(\.dismiss) var dismiss
@Dependency(\.bematch) var bematch
@Dependency(\.analytics) var analytics

enum Cancel {
case activeInvitationCampaign
case products
case purchase
case membership
}

public var body: some Reducer<State, Action> {
Reduce<State, Action> { state, action in
switch action {
case .onTask:
return .run { send in
for try await data in bematch.activeInvitationCampaign() {
await send(.activeInvitationCampaignResponse(.success(data)))
return .run { [id = state.bematchProOneWeekId] send in
await withTaskGroup(of: Void.self) { group in
group.addTask {
await productsRequest(send: send, ids: [id])
}

group.addTask {
await membershipRequest(send: send)
}
}
} catch: { error, send in
await send(.activeInvitationCampaignResponse(.failure(error)))
}
.cancellable(id: Cancel.activeInvitationCampaign, cancelInFlight: true)

case .closeButtonTapped:
return .run { _ in
await dismiss()
}

case let .activeInvitationCampaignResponse(.success(data)):
if let campaign = data.activeInvitationCampaign {
state.child = .campaign(MembershipCampaignLogic.State(campaign: campaign))
case .child(.campaign(.delegate(.purchase))):
let appAccountToken = UUID()
guard let product = state.product
else { return .none }

state.isActivityIndicatorVisible = true

return .run { send in
let result = try await store.purchase(product, appAccountToken)

switch result {
case let .success(verificationResult):
await send(.purchaseResponse(Result {
try checkVerified(verificationResult)
}))

case .pending:
await send(.purchaseResponse(.failure(InAppPurchaseError.pending)))
case .userCancelled:
await send(.purchaseResponse(.failure(InAppPurchaseError.userCancelled)))
@unknown default:
fatalError()
}
} catch: { error, send in
await send(.purchaseResponse(.failure(error)))
}
.cancellable(id: Cancel.purchase, cancelInFlight: true)

case let .productsResponse(.success(products)):
guard
let product = products.first(where: { $0.id == state.bematchProOneWeekId })
else { return .none }
state.product = product
return .none

case let .membershipResponse(.success(data)):
let campaign = data.activeInvitationCampaign
let invitationCode = data.invitationCode

if let campaign {
state.child = .campaign(
MembershipCampaignLogic.State(
campaign: campaign,
code: invitationCode.code
)
)
} else {
state.child = .purchase(MembershipPurchaseLogic.State())
state.child = .purchase(
MembershipPurchaseLogic.State()
)
}
return .none

case .activeInvitationCampaignResponse(.failure):
case .membershipResponse(.failure):
state.child = .purchase(MembershipPurchaseLogic.State())
return .none

case let .purchaseResponse(.success(transaction)):
state.isActivityIndicatorVisible = false
return .none

case .purchaseResponse(.failure):
state.isActivityIndicatorVisible = false
return .none

default:
return .none
Expand All @@ -69,6 +141,26 @@ public struct MembershipLogic {
}
}

func productsRequest(send: Send<Action>, ids: [String]) async {
await withTaskCancellation(id: Cancel.products, cancelInFlight: true) {
await send(.productsResponse(Result {
try await store.products(ids)
}))
}
}

func membershipRequest(send: Send<Action>) async {
await withTaskCancellation(id: Cancel.membership, cancelInFlight: true) {
do {
for try await data in bematch.membership() {
await send(.membershipResponse(.success(data)))
}
} catch {
await send(.membershipResponse(.failure(error)))
}
}
}

@Reducer
public struct Child {
public enum State: Equatable {
Expand Down
Loading