You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/Views/Tournament/Subscription/SubscriptionView.swift

427 lines
13 KiB

//
// SubscriptionView.swift
// PadelClub
//
// Created by Laurent Morvillier on 13/02/2024.
//
import SwiftUI
import StoreKit
import LeStorage
extension Product.SubscriptionPeriod.Unit {
var label: String {
switch self {
case .day: return "jour"
case .week: return "semaine"
case .month: return "mois"
case .year: return "année"
@unknown default: return "inconnu"
}
}
}
extension Product {
var item: StoreItem {
return StoreItem(rawValue: self.id)!
}
var formattedPrice: String {
if let period = self.subscription?.subscriptionPeriod {
return self.displayPrice + " / " + period.unit.label
}
return self.displayPrice
}
}
class SubscriptionModel: ObservableObject, StoreDelegate {
var storeManager: StoreManager? = nil
@Published var error: Error? = nil
@Published var isLoading: Bool = false
@Published var selectedProduct: Product? = nil {
didSet {
self._computePrice()
}
}
@Published var quantity: Int = 1
@Published var products: [Product] = []
@Published var totalPrice: String = ""
init() {
self.load()
}
func load() {
self.isLoading = true
if self.storeManager == nil {
self.storeManager = StoreManager(delegate: self)
}
}
func isSelected(product: Product) -> Bool {
return self.selectedProduct == product
}
func productsReceived(products: [Product]) {
self.isLoading = false
self.products = products
Logger.log("products received = \(products.count)")
}
func errorDidOccur(error: Error) {
self.isLoading = false
self.error = error
}
func purchase() async throws -> Bool {
Logger.log("start purchase...")
guard let product: Product = self.selectedProduct, let storeManager = self.storeManager else {
Logger.w("missing product or store manager")
return false
}
if product.item.isConsumable {
if let _ = try await storeManager.purchase(product, quantity: self.quantity) {
return true
}
} else {
let _ = try await storeManager.purchase(product)
return true
}
return false
}
fileprivate func _computePrice() {
if let product = self.selectedProduct, let item = StoreItem(rawValue: product.id) {
if item.isConsumable {
let price = NSDecimalNumber(decimal: product.price).multiplying(by: NSDecimalNumber(integerLiteral: self.quantity))
self.totalPrice = product.priceFormatStyle.format(price.decimalValue)
} else {
self.totalPrice = product.displayPrice
}
} else {
self.totalPrice = ""
}
}
}
struct SubscriptionView: View {
@ObservedObject var model: SubscriptionModel = SubscriptionModel()
@Binding var isPresented: Bool
var showLackOfPlanMessage: Bool = false
@State var isRestoring: Bool = false
@State var showLoginView: Bool = false
@State var isPurchasing: Bool = false
@State var showSuccessfulPurchaseView: Bool = false
init(isPresented: Binding<Bool>, showLackOfPlanMessage: Bool = false) {
self._isPresented = isPresented
self.showLackOfPlanMessage = showLackOfPlanMessage
}
var body: some View {
VStack(alignment: .leading) {
Text("Abonnements")
.font(.system(size: 36.0))
.fontWeight(.bold)
.padding(.horizontal)
.foregroundStyle(.white)
if self.showLoginView {
LoginView { _ in
self.showLoginView = false
self._purchase()
}
} else {
if self.showLackOfPlanMessage {
SubscriptionDetailView()
}
List {
if self.model.products.count > 0 {
ProductsSectionView(model: self.model)
if let product = self.model.selectedProduct {
Section {
Button {
self._purchaseIfPossible()
} label: {
PurchaseLabelView(price: self.model.totalPrice, isPurchasing: self.isPurchasing)
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.listRowBackground(Color.clear)
} footer: {
if product.item.isConsumable == false {
SubscriptionFooterView()
.foregroundStyle(Color(white: 0.8))
}
}
}
} else {
NoProductView()
.isLoading(self.model.isLoading)
}
}
.listStyle(.grouped)
.scrollContentBackground(.hidden)
}
}
.background(.logoBackground)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Restaurer") {
self._restore()
}.isLoading(self.isRestoring)
}
}
// .toolbar {
// ToolbarItem(placement: .principal) {
// VStack(spacing: -4.0) {
// Text("Abonnements").font(.headline).foregroundStyle(.white)
// }
// }
//
// }.navigationBarTitleDisplayMode(.inline)
// .preferredColorScheme(.dark)
// .navigationTitle("Abonnements")
}
fileprivate func _purchaseIfPossible() {
if StoreCenter.main.userId != nil {
self._purchase()
} else {
self.showLoginView = true
}
}
fileprivate func _purchase() {
self.isPurchasing = true
Task {
do {
let success = try await self.model.purchase()
DispatchQueue.main.async {
self.isPurchasing = false
self.showSuccessfulPurchaseView = true
if success {
self.isPresented = false
}
}
} catch {
Logger.error(error)
DispatchQueue.main.async {
self.isPurchasing = false
}
}
}
}
fileprivate func _restore() {
Task {
do {
self.isRestoring = true
try await Guard.main.refreshPurchasedAppleProducts()
self.isRestoring = false
} catch {
self.isRestoring = false
Logger.error(error)
}
}
}
}
fileprivate struct ProductsSectionView: View {
@ObservedObject var model: SubscriptionModel
var body: some View {
Section {
ForEach(self.model.products) { product in
ProductView(product: product,
model: self.model)
.onTapGesture {
self.model.selectedProduct = product
}
}
} header: {
Text("Sélectionnez une offre").foregroundStyle(Color(white: 0.8))
} footer: {
let message = "Consulter notre [politique de confidentialité](\(URLs.privacy.rawValue)) et le [contrat d'utilisation](\(URLs.eula.rawValue)) de Padel Club."
Text(.init(message))
.foregroundStyle(.white)
}
}
}
fileprivate struct PurchaseLabelView: View {
var price: String
var isPurchasing: Bool
var body: some View {
HStack(alignment: .center) {
if self.isPurchasing {
Spacer()
ProgressView().tint(Color.white)
Spacer()
} else {
Text("Acheter")
Spacer()
Text(self.price)
}
}
.padding(8.0)
.fontWeight(.bold)
}
}
fileprivate struct NoProductView: View {
var body: some View {
HStack {
if let plan = Guard.main.currentPlan {
Image(systemName: plan.systemImage)
} else {
Image(systemName: "questionmark.diamond.fill")
}
Text("Il n'y a pas de produits à vous proposer")
}
}
}
struct ProductView: View {
var product: Product
@ObservedObject var model: SubscriptionModel
// @Binding var quantity: Int
// var selected: Bool
var body: some View {
HStack {
Image(systemName: self._image)
.font(.system(size: 36.0))
.foregroundColor(.orange)
VStack(alignment: .leading) {
Text(self.product.displayName)
Text(self.product.formattedPrice)
.foregroundColor(.accentColor)
if self._isConsumable {
StepperView(count: self.$model.quantity, minimum: 1, countChanged: { self.model.selectedProduct = self.product })
.font(.callout).foregroundColor(.accentColor)
}
}
Spacer()
if self.model.isSelected(product: self.product) {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.contentShape(.rect)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.buttonStyle(.borderedProminent)
.tint(Color.master)
.listRowBackground(Color.clear)
}
fileprivate var _item: StoreItem? {
return StoreItem(rawValue: self.product.id)
}
fileprivate var _isConsumable: Bool {
return self._item?.isConsumable ?? false
}
fileprivate var _image: String {
if let item = self._item {
return item.systemImage
} else {
return "gift.circle.fill"
}
}
}
fileprivate struct SubscriptionNoProductView: View {
@ObservedObject var model: SubscriptionModel
var body: some View {
VStack(alignment: .center) {
if let error = self.model.error {
Text(error.localizedDescription)
} else {
Text("Aucun produit disponible")
}
if self.model.isLoading {
ProgressView()
} else {
Button(action: {
self.model.load()
}, label: {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.system(size: 64.0))
})
}
}
}
}
fileprivate struct SubscriptionFooterView: View {
var body: some View {
Text("Conditions d’utilisations concernant l’abonnement:\n- Le paiement sera facturé sur votre compte Apple.\n- L’abonnement est renouvelé automatiquement chaque mois, à moins d’avoir été désactivé au moins 24 heures avant la fin de la période de l’abonnement.\n- L’abonnement peut être géré par l’utilisateur et désactivé en allant dans les réglages de son compte après s’être abonné.\n- Le compte sera facturé pour le renouvellement de l'abonnement dans les 24 heures précédent la fin de la période d’abonnement.\n- Un abonnement en cours ne peut être annulé.\n- Toute partie inutilisée de l'offre gratuite, si souscrite, sera abandonnée lorsque l'utilisateur s'abonnera, dans les cas applicables.")
.padding(.top)
}
}
fileprivate struct SubscriptionDetailView: View {
var body: some View {
HStack {
Image(systemName: "exclamationmark.bubble.fill")
//.foregroundStyle(Color.accentColor)
.font(.title)
Text("Vous êtes arrivé à limite de votre offre actuelle. Voici ce que nous proposons pour poursuivre votre tournoi:")
.fontWeight(.semibold)
}
.padding()
.background(.orange)
.foregroundStyle(.black)
.clipShape(.rect(cornerRadius: 16.0))
.padding()
}
}
//#Preview {
// SubscriptionDetailView()
//}
//#Preview {
// NavigationStack {
// SubscriptionView(isPresented: .constant(true), showLackOfPlanMessage: false)
// }
//}