Add in app purchase.

This commit is contained in:
Marcin Czachursk 2023-02-13 21:10:07 +01:00
parent 709888a560
commit 04c25454d6
15 changed files with 458 additions and 52 deletions

View File

@ -53,6 +53,12 @@
F866F6A729604629002E8F88 /* SignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A629604629002E8F88 /* SignInView.swift */; };
F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A929605AFA002E8F88 /* SceneDelegate.swift */; };
F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6AD29606367002E8F88 /* ApplicationViewMode.swift */; };
F86A42FD299A8B8E00DF7645 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F86A42FC299A8B8E00DF7645 /* StoreKit.framework */; };
F86A42FF299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = F86A42FE299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit */; };
F86A4301299A97F500DF7645 /* ProductIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A4300299A97F500DF7645 /* ProductIdentifiers.swift */; };
F86A4303299A9AF500DF7645 /* Tips.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A4302299A9AF500DF7645 /* Tips.swift */; };
F86A4305299AA12800DF7645 /* PurchaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A4304299AA12800DF7645 /* PurchaseError.swift */; };
F86A4307299AA5E900DF7645 /* ThanksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86A4306299AA5E900DF7645 /* ThanksView.swift */; };
F86B7214296BFDCE00EE59EC /* UserProfileHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B7213296BFDCE00EE59EC /* UserProfileHeader.swift */; };
F86B7216296BFFDA00EE59EC /* UserProfileStatuses.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B7215296BFFDA00EE59EC /* UserProfileStatuses.swift */; };
F86B7218296C27C100EE59EC /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86B7217296C27C100EE59EC /* ActionButton.swift */; };
@ -180,6 +186,12 @@
F866F6A929605AFA002E8F88 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
F866F6AD29606367002E8F88 /* ApplicationViewMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationViewMode.swift; sourceTree = "<group>"; };
F86728AD296D3CE200475EC9 /* MastodonKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MastodonKit; sourceTree = "<group>"; };
F86A42FC299A8B8E00DF7645 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
F86A42FE299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = InAppPurchaseStoreKitConfiguration.storekit; sourceTree = "<group>"; };
F86A4300299A97F500DF7645 /* ProductIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductIdentifiers.swift; sourceTree = "<group>"; };
F86A4302299A9AF500DF7645 /* Tips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tips.swift; sourceTree = "<group>"; };
F86A4304299AA12800DF7645 /* PurchaseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseError.swift; sourceTree = "<group>"; };
F86A4306299AA5E900DF7645 /* ThanksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThanksView.swift; sourceTree = "<group>"; };
F86B7213296BFDCE00EE59EC /* UserProfileHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileHeader.swift; sourceTree = "<group>"; };
F86B7215296BFFDA00EE59EC /* UserProfileStatuses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileStatuses.swift; sourceTree = "<group>"; };
F86B7217296C27C100EE59EC /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = "<group>"; };
@ -267,6 +279,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
F86A42FD299A8B8E00DF7645 /* StoreKit.framework in Frameworks */,
F89992C7296D3DF8005994BF /* MastodonKit in Frameworks */,
F8210DD52966BB7E001D9973 /* Nuke in Frameworks */,
F88E4D4D297EA4290057491A /* EmojiText in Frameworks */,
@ -287,6 +300,7 @@
F802884E297AEED5000BDD51 /* DatabaseError.swift */,
F87AEB962986D16D00434FB6 /* AuthorisationError.swift */,
F8742FC329990AFB00E9642B /* ClientError.swift */,
F86A4304299AA12800DF7645 /* PurchaseError.swift */,
);
path = Errors;
sourceTree = "<group>";
@ -481,10 +495,12 @@
F88C246B295C37B80006098B /* VernissageApp.swift */,
F88E4D55297EAD6E0057491A /* AppRouteur.swift */,
F87AEB932986C51B00434FB6 /* AppConstants.swift */,
F86A4300299A97F500DF7645 /* ProductIdentifiers.swift */,
F866F6A929605AFA002E8F88 /* SceneDelegate.swift */,
F88C246F295C37BB0006098B /* Assets.xcassets */,
F88C2476295C37BB0006098B /* Vernissage.xcdatamodeld */,
F88C2471295C37BB0006098B /* Preview Content */,
F86A42FE299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit */,
);
path = Vernissage;
sourceTree = "<group>";
@ -524,6 +540,7 @@
F89992C5296D3DF8005994BF /* Frameworks */ = {
isa = PBXGroup;
children = (
F86A42FC299A8B8E00DF7645 /* StoreKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -537,6 +554,7 @@
F86167C5297FE6CC004D1F67 /* AvatarShapesSection.swift */,
F8B0885F29943498002AB40A /* OtherSection.swift */,
F8B08861299435C9002AB40A /* SupportView.swift */,
F86A4306299AA5E900DF7645 /* ThanksView.swift */,
);
path = SettingsView;
sourceTree = "<group>";
@ -573,6 +591,7 @@
F8B9B350298D4B34009CC69C /* Client+Account.swift */,
F8B9B352298D4B5D009CC69C /* Client+Search.swift */,
F8B9B355298D4C1E009CC69C /* Client+Instance.swift */,
F86A4302299A9AF500DF7645 /* Tips.swift */,
);
path = EnvironmentObjects;
sourceTree = "<group>";
@ -662,6 +681,7 @@
files = (
F88C2473295C37BB0006098B /* Preview Assets.xcassets in Resources */,
F88C2470295C37BB0006098B /* Assets.xcassets in Resources */,
F86A42FF299A8C5500DF7645 /* InAppPurchaseStoreKitConfiguration.storekit in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -676,6 +696,7 @@
F8210DDF2966CFC7001D9973 /* AttachmentData+Attachment.swift in Sources */,
F87AEB922986C44E00434FB6 /* AuthorizationSession.swift in Sources */,
F88E4D44297E82EB0057491A /* Status+MediaAttachmentType.swift in Sources */,
F86A4301299A97F500DF7645 /* ProductIdentifiers.swift in Sources */,
F89D6C4229717FDC001DA3D4 /* AccountsSection.swift in Sources */,
F80048082961E6DE00E6868A /* StatusDataHandler.swift in Sources */,
F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */,
@ -707,6 +728,7 @@
F85D498329642FAC00751DF7 /* AttachmentData+Comperable.swift in Sources */,
F8B9B353298D4B5D009CC69C /* Client+Search.swift in Sources */,
F85D497B29640C8200751DF7 /* UsernameRow.swift in Sources */,
F86A4305299AA12800DF7645 /* PurchaseError.swift in Sources */,
F89D6C4429718092001DA3D4 /* AccentsSection.swift in Sources */,
F88E4D42297E69FD0057491A /* StatusesView.swift in Sources */,
F86FB555298BF83F000131F0 /* FavouriteTouch.swift in Sources */,
@ -765,6 +787,7 @@
F88C246C295C37B80006098B /* VernissageApp.swift in Sources */,
F8121CA8298A86D600B466C7 /* InstanceRow.swift in Sources */,
F802884F297AEED5000BDD51 /* DatabaseError.swift in Sources */,
F86A4307299AA5E900DF7645 /* ThanksView.swift in Sources */,
F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */,
F8B9B356298D4C1E009CC69C /* Client+Instance.swift in Sources */,
F88E4D56297EAD6E0057491A /* AppRouteur.swift in Sources */,
@ -778,6 +801,7 @@
F88FAD2D295F4AD7009B20C9 /* ApplicationState.swift in Sources */,
F88E4D54297EA7EE0057491A /* MarkdownFormattedText.swift in Sources */,
F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */,
F86A4303299A9AF500DF7645 /* Tips.swift in Sources */,
F8C5E56229892CC300ADF6A7 /* FirstAppear.swift in Sources */,
F8C14394296AF21B001FE31D /* Double+Round.swift in Sources */,
F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */,

View File

@ -52,4 +52,16 @@ extension View {
}
}
}
func withOverlayDestinations(overlayDestinations: Binding<OverlayDestinations?>) -> some View {
self.overlay {
switch overlayDestinations.wrappedValue {
case .successPayment:
ThanksView()
.transition(.move(edge: .bottom).combined(with: .opacity))
default:
EmptyView()
}
}
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "40",
"green" : "40",
"red" : "40"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "247",
"green" : "247",
"red" : "247"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "247",
"green" : "247",
"red" : "247"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.157",
"green" : "0.157",
"red" : "0.157"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,138 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
import StoreKit
@MainActor
final class Tips: ObservableObject {
@Published private(set) var items = [Product]()
@Published private(set) var status: ActionStatus? {
didSet{
switch status {
case .failed:
self.hasError = true
default:
self.hasError = false
}
}
}
@Published var hasError = false
var error: PurchaseError? {
switch status {
case .failed(let error):
return error
default:
return nil
}
}
private var transactionListener: Task<Void, Error>?
init() {
transactionListener = configureTransactionListener()
Task { [weak self] in
await self?.retrieve()
}
}
deinit {
transactionListener?.cancel()
}
/// Purchase new product.
public func purchase(_ product: Product) async {
do {
let result = try await product.purchase()
try await self.handlePurchase(from: result)
} catch {
self.status = .failed(.system(error))
ErrorService.shared.handle(error, message: "Purchase failed.", showToastr: false)
}
}
/// Reset status of the purchase/action.
public func reset() {
self.status = nil
}
/// Handle purchase result.
private func handlePurchase(from result: Product.PurchaseResult) async throws {
switch result {
case .success(let verificationResult):
let transaction = try self.checkVerified(verificationResult)
self.status = .successful
await transaction.finish()
case .userCancelled:
print("User click cancel before their transaction started.")
case .pending:
print("User needs to complete some action on their account before their complete the purchase.")
default:
break
}
}
/// We have to verify if transaction ends successfuly.
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw PurchaseError.failedVerification
case .verified(let signedType):
return signedType
}
}
/// Configure listener of interrupted transactions.
private func configureTransactionListener() -> Task<Void, Error> {
Task.detached(priority: .background) { @MainActor [weak self] in
do {
for await result in Transaction.updates {
let transaction = try self?.checkVerified(result)
self?.status = .successful
await transaction?.finish()
}
} catch {
self?.status = .failed(.system(error))
ErrorService.shared.handle(error, message: "Cannot configure transaction listener.", showToastr: false)
}
}
}
/// Retrieve products from Apple store.
private func retrieve() async {
do {
let products = try await Product.products(for: ProductIdentifiers.allCases.map({ $0.rawValue }))
.sorted(by: { $0.price < $1.price })
self.items = products
} catch {
self.status = .failed(.system(error))
ErrorService.shared.handle(error, message: "Cannot download in-app products.", showToastr: false)
}
}
}
extension Tips {
public enum ActionStatus: Equatable {
case successful
case failed(PurchaseError)
public static func == (lhs: Tips.ActionStatus, rhs: Tips.ActionStatus) -> Bool {
switch (lhs, rhs) {
case (.successful, .successful):
return true
case (let .failed(lhsError), let .failed(rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}
}

View File

@ -0,0 +1,24 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
public enum PurchaseError: Error {
case failedVerification
case system(Error)
}
extension PurchaseError: LocalizedError {
public var errorDescription: String? {
switch self {
case .failedVerification:
return NSLocalizedString("Purchase verification failed.", comment: "Something went wrong during purchase verification.")
case .system(let error):
return error.localizedDescription
}
}
}

View File

@ -13,6 +13,8 @@ extension Color {
static let lightGrayColor = Color("LightGrayColor")
static let mainTextColor = Color("MainTextColor")
static let selectedRowColor = Color("SelectedRowColor")
static let viewBackgroundColor = Color("ViewBackgroundColor")
static let viewTextColor = Color("ViewTextColor")
static let accentColor1 = Color("AccentColor1")
static let accentColor2 = Color("AccentColor2")
static let accentColor3 = Color("AccentColor3")

View File

@ -0,0 +1,65 @@
{
"identifier" : "1FBBF671",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "FCCAC94C",
"localizations" : [
{
"description" : "Treat me to a doughnut.",
"displayName" : "Donut",
"locale" : "en_US"
}
],
"productID" : "dev.mczachurski.Vernissage.donut",
"referenceName" : "Donut",
"type" : "Consumable"
},
{
"displayPrice" : "3.99",
"familyShareable" : false,
"internalID" : "AD04459F",
"localizations" : [
{
"description" : "Treat me to a coffe.",
"displayName" : "Cofee",
"locale" : "en_US"
}
],
"productID" : "dev.mczachurski.Vernissage.cofee",
"referenceName" : "Cofee",
"type" : "Consumable"
},
{
"displayPrice" : "9.99",
"familyShareable" : false,
"internalID" : "16E4E30C",
"localizations" : [
{
"description" : "Treat me to a coffe and cake.",
"displayName" : "Cofee & cake",
"locale" : "en_US"
}
],
"productID" : "dev.mczachurski.Vernissage.cake",
"referenceName" : "Cake",
"type" : "Consumable"
}
],
"settings" : {
"_billingIssuesEnabled" : false,
"_failTransactionsEnabled" : false,
"_storeKitError" : 0
},
"subscriptionGroups" : [
],
"version" : {
"major" : 2,
"minor" : 0
}
}

View File

@ -0,0 +1,13 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import Foundation
public enum ProductIdentifiers: String, CaseIterable {
case donut = "dev.mczachurski.Vernissage.donut"
case cofee = "dev.mczachurski.Vernissage.cofee"
case cake = "dev.mczachurski.Vernissage.cake"
}

View File

@ -35,12 +35,17 @@ enum SheetDestinations: Identifiable {
}
}
enum OverlayDestinations {
case successPayment
}
@MainActor
class RouterPath: ObservableObject {
public var urlHandler: ((URL) -> OpenURLAction.Result)?
@Published public var path: [RouteurDestinations] = []
@Published public var presentedSheet: SheetDestinations?
@Published public var presentedOverlay: OverlayDestinations?
public init() {}

View File

@ -17,6 +17,7 @@ struct VernissageApp: App {
@StateObject var applicationState = ApplicationState.shared
@StateObject var client = Client.shared
@StateObject var routerPath = RouterPath()
@StateObject var tips = Tips()
@State var applicationViewMode: ApplicationViewMode = .loading
@State var tintColor = ApplicationState.shared.tintColor.color()
@ -42,12 +43,14 @@ struct VernissageApp: App {
MainView()
.withAppRouteur()
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
.withOverlayDestinations(overlayDestinations: $routerPath.presentedOverlay)
}
}
.environment(\.managedObjectContext, coreDataHandler.container.viewContext)
.environmentObject(applicationState)
.environmentObject(client)
.environmentObject(routerPath)
.environmentObject(tips)
.tint(self.tintColor)
.preferredColorScheme(self.theme)
.task {

View File

@ -20,6 +20,7 @@ struct MainView: View {
@EnvironmentObject var applicationState: ApplicationState
@EnvironmentObject var client: Client
@EnvironmentObject var routerPath: RouterPath
@EnvironmentObject var tips: Tips
@State private var navBarTitle: String = "Home"
@State private var viewMode: ViewMode = .home {
@ -43,6 +44,14 @@ struct MainView: View {
self.getPrincipalToolbar()
self.getTrailingToolbar()
}
.onChange(of: tips.status) { action in
if action == .successful {
withAnimation(.spring()) {
self.routerPath.presentedOverlay = .successPayment
self.tips.reset()
}
}
}
}
@ViewBuilder

View File

@ -8,6 +8,9 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var applicationState: ApplicationState
@EnvironmentObject var routerPath: RouterPath
@EnvironmentObject var tips: Tips
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) private var dismiss
@ -59,11 +62,21 @@ struct SettingsView: View {
.preferredColorScheme(self.theme)
}
.withAppRouteur()
.withOverlayDestinations(overlayDestinations: $routerPath.presentedOverlay)
}
.onChange(of: self.applicationState.theme) { newValue in
// Change theme of current modal screen (unformtunatelly it's not changed autmatically.
self.theme = self.applicationState.theme.colorScheme() ?? self.getSystemColorScheme()
}
.onChange(of: tips.status) { status in
if status == .successful {
withAnimation(.spring()) {
self.routerPath.presentedOverlay = .successPayment
self.tips.reset()
}
}
}
.alert(isPresented: $tips.hasError, error: tips.error) { }
}
@ViewBuilder

View File

@ -3,66 +3,48 @@
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import SwiftUI
import StoreKit
struct SupportView: View {
@EnvironmentObject var tips: Tips
var body: some View {
Section("Support") {
HStack(alignment: .center) {
Text("🍩")
.font(.title)
VStack(alignment: .leading) {
Text("Donut")
.font(.caption)
Text("Treat me to a doughnut.")
.font(.footnote)
.foregroundColor(.lightGrayColor)
ForEach(tips.items) { product in
HStack(alignment: .center) {
Text(self.getIcon(for: product))
.font(.title)
VStack(alignment: .leading) {
Text(product.displayName)
.font(.caption)
Text(product.description)
.font(.footnote)
.foregroundColor(.lightGrayColor)
}
Spacer()
Button(product.displayPrice) {
Task {
await tips.purchase(product)
}
}
.font(.footnote)
.buttonStyle(.borderedProminent)
}
Spacer()
Button("5.99 PLN") {
}
.font(.footnote)
.buttonStyle(.borderedProminent)
.padding(.vertical, 4)
}
.padding(.vertical, 4)
HStack(alignment: .center) {
Text("☕️")
.font(.title)
VStack(alignment: .leading) {
Text("Cofee")
.font(.caption)
Text("Treat me to a coffe.")
.font(.footnote)
.foregroundColor(.lightGrayColor)
}
Spacer()
Button("17.99 PLN") {
}
.font(.footnote)
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 4)
HStack(alignment: .center) {
Text("🍰")
.font(.title)
VStack(alignment: .leading) {
Text("Cofee & cake")
.font(.caption)
Text("Treat me to a coffe and cake.")
.font(.footnote)
.foregroundColor(.lightGrayColor)
}
Spacer()
Button("39.99 PLN") {
}
.font(.footnote)
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 4)
}
}
private func getIcon(for product: Product) -> String {
if product.id == ProductIdentifiers.donut.rawValue {
return "🍩"
} else if product.id == ProductIdentifiers.cofee.rawValue {
return "☕️"
} else {
return "🍰"
}
}
}

View File

@ -0,0 +1,40 @@
//
// https://mczachurski.dev
// Copyright © 2023 Marcin Czachurski and the repository contributors.
// Licensed under the MIT License.
//
import SwiftUI
struct ThanksView: View {
@EnvironmentObject var routerPath: RouterPath
var body: some View {
VStack {
Spacer()
VStack {
Group {
Text("Thank you 💕")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.viewTextColor)
.padding(.top, 8)
Text("Thanks for your purchase. Purchases both big and small help us keep our dream of providing the best quality products to our customers. We hope youre loving Vernissage.")
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundColor(.viewTextColor)
Button("Close") {
withAnimation(.spring()) {
self.routerPath.presentedOverlay = nil
}
}
.buttonStyle(.borderedProminent)
}.padding(8)
}
.background(Color.viewBackgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.padding(8)
}
}