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/Subscription/SubscriptionView.swift

303 lines
9.6 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 {
didSet {
self._computePrice()
self.selectedProduct = self.products.first(where: { $0.id == StoreItem.unit.rawValue })
}
}
@Published var products: [Product] = []
@Published var totalPrice: String = ""
@State var showSuccessfulPurchaseView: Bool = false
func load() {
self.isLoading = true
if self.storeManager == nil {
self.storeManager = StoreManager(delegate: self)
}
}
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() {
guard let product: Product = self.selectedProduct else {
return
}
Task {
if product.item.isConsumable {
if let result = try await self.storeManager?.purchase(product, quantity: self.quantity) {
self.showSuccessfulPurchaseView = true
}
} else {
let _ = try await self.storeManager?.purchase(product)
}
}
}
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()
var showLackOfPlanMessage: Bool = false
@State var isRestoring: Bool = false
@State var showLoginView: Bool = false
var body: some View {
Group {
if self.showLoginView {
LoginView { _ in
self.showLoginView = false
self._purchase()
}
} else {
Form {
if self.showLackOfPlanMessage {
HStack {
Image(systemName: "exclamationmark.bubble.fill").foregroundStyle(Color.accentColor)
.font(.title)
Text("Vous ne disposez malheureusement plus d'offre pour continuer votre tournoi. Voici ce que nous proposons:")
.fontWeight(.semibold)
}
}
if self.model.products.count > 0 {
Section {
List {
ForEach(self.model.products) { product in
let isSelected = self.model.selectedProduct == product
ProductView(product: product,
quantity: self.$model.quantity,
selected: isSelected)
.onTapGesture {
self.model.selectedProduct = product
}
}
}
} header: {
Text("Les offres")
}
Section {
Button {
if Store.main.hasToken() {
self._purchase()
} else {
self.showLoginView = true
}
} label: {
HStack {
Text("Acheter")
if let _ = self.model.selectedProduct {
Spacer()
Text(self.model.totalPrice)
}
}
}
} footer: {
if self.model.selectedProduct?.item.isConsumable == false {
SubscriptionFooterView()
}
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if self.isRestoring {
ProgressView()
} else {
Button("Restaurer") {
self._restore()
}
}
}
}
}
}
.navigationTitle("Abonnements")
.onAppear {
self._load()
}
}
fileprivate func _purchase() {
self.model.purchase()
}
fileprivate func _load() {
self.model.load()
}
fileprivate func _restore() {
Task {
do {
self.isRestoring = true
try await Guard.main.refreshPurchasedAppleProducts()
self.isRestoring = false
} catch {
self.isRestoring = false
Logger.error(error)
}
}
}
}
struct ProductView: View {
var product: Product
@Binding var quantity: Int
var selected: Bool
var body: some View {
HStack {
Image(systemName: self._image)
.font(.title)
.foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text(product.displayName)
Text(product.formattedPrice)
.foregroundColor(.accentColor)
if self._isConsumable {
StepperView(count: self.$quantity, minimum: 1).font(.callout)
}
}
Spacer()
if self.selected {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}.contentShape(.rect)
}
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"
}
}
}
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))
})
}
}
}
}
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.")
}
}
#Preview {
NavigationStack {
SubscriptionView(showLackOfPlanMessage: true)
}
}