Adds yearly plan

main
Laurent 3 years ago
parent ea459d7a2a
commit f36323fea8
  1. 4
      LeCountdown.xcodeproj/project.pbxproj
  2. 8
      LeCountdown/Model/NSManagedContext+Extensions.swift
  3. 7
      LeCountdown/Sound/Sound.swift
  4. BIN
      LeCountdown/Sound_Assets/Stephan_Bodzin/Clave_Loop_LLL.wav
  5. 26
      LeCountdown/Subscription/AppGuard.swift
  6. 2
      LeCountdown/Subscription/Store.swift
  7. 70
      LeCountdown/Subscription/StoreView.swift
  8. 16
      LeCountdown/Views/PresetsView.swift
  9. 2
      LeCountdown/Views/Reusable/SoundSelectionView.swift
  10. 2
      LeCountdown/fr.lproj/Localizable.strings

@ -144,7 +144,6 @@
C4BA2AE22995ABE700CB4FBA /* SquareArp_Loop_River.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE12995ABE700CB4FBA /* SquareArp_Loop_River.wav */; }; C4BA2AE22995ABE700CB4FBA /* SquareArp_Loop_River.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE12995ABE700CB4FBA /* SquareArp_Loop_River.wav */; };
C4BA2AE42995AC0D00CB4FBA /* Arpeggio_Loop_River.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE32995AC0D00CB4FBA /* Arpeggio_Loop_River.wav */; }; C4BA2AE42995AC0D00CB4FBA /* Arpeggio_Loop_River.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE32995AC0D00CB4FBA /* Arpeggio_Loop_River.wav */; };
C4BA2AE62995AC3F00CB4FBA /* Loop_ToneSD_Boavista.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE52995AC3E00CB4FBA /* Loop_ToneSD_Boavista.wav */; }; C4BA2AE62995AC3F00CB4FBA /* Loop_ToneSD_Boavista.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE52995AC3E00CB4FBA /* Loop_ToneSD_Boavista.wav */; };
C4BA2AE82995ACC200CB4FBA /* Clave_Loop_LLL.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE72995ACC200CB4FBA /* Clave_Loop_LLL.wav */; };
C4BA2AEA2995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE92995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav */; }; C4BA2AEA2995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BA2AE92995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav */; };
C4BA2AF12996A11900CB4FBA /* CustomSound+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AED2996A11900CB4FBA /* CustomSound+CoreDataClass.swift */; }; C4BA2AF12996A11900CB4FBA /* CustomSound+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AED2996A11900CB4FBA /* CustomSound+CoreDataClass.swift */; };
C4BA2AF22996A11900CB4FBA /* CustomSound+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AEE2996A11900CB4FBA /* CustomSound+CoreDataProperties.swift */; }; C4BA2AF22996A11900CB4FBA /* CustomSound+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AEE2996A11900CB4FBA /* CustomSound+CoreDataProperties.swift */; };
@ -406,7 +405,6 @@
C4BA2AE12995ABE700CB4FBA /* SquareArp_Loop_River.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = SquareArp_Loop_River.wav; sourceTree = "<group>"; }; C4BA2AE12995ABE700CB4FBA /* SquareArp_Loop_River.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = SquareArp_Loop_River.wav; sourceTree = "<group>"; };
C4BA2AE32995AC0D00CB4FBA /* Arpeggio_Loop_River.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = Arpeggio_Loop_River.wav; sourceTree = "<group>"; }; C4BA2AE32995AC0D00CB4FBA /* Arpeggio_Loop_River.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = Arpeggio_Loop_River.wav; sourceTree = "<group>"; };
C4BA2AE52995AC3E00CB4FBA /* Loop_ToneSD_Boavista.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = Loop_ToneSD_Boavista.wav; sourceTree = "<group>"; }; C4BA2AE52995AC3E00CB4FBA /* Loop_ToneSD_Boavista.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = Loop_ToneSD_Boavista.wav; sourceTree = "<group>"; };
C4BA2AE72995ACC200CB4FBA /* Clave_Loop_LLL.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = Clave_Loop_LLL.wav; sourceTree = "<group>"; };
C4BA2AE92995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = SEM_Synths_Loop4_Nothing_Like_You.wav; sourceTree = "<group>"; }; C4BA2AE92995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = SEM_Synths_Loop4_Nothing_Like_You.wav; sourceTree = "<group>"; };
C4BA2AEB2996A09600CB4FBA /* LeCountdown.0.5.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.5.1.xcdatamodel; sourceTree = "<group>"; }; C4BA2AEB2996A09600CB4FBA /* LeCountdown.0.5.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.5.1.xcdatamodel; sourceTree = "<group>"; };
C4BA2AED2996A11900CB4FBA /* CustomSound+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomSound+CoreDataClass.swift"; sourceTree = "<group>"; }; C4BA2AED2996A11900CB4FBA /* CustomSound+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomSound+CoreDataClass.swift"; sourceTree = "<group>"; };
@ -780,7 +778,6 @@
children = ( children = (
C4E5D67B29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav */, C4E5D67B29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav */,
C4BA2AE92995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav */, C4BA2AE92995AD1C00CB4FBA /* SEM_Synths_Loop4_Nothing_Like_You.wav */,
C4BA2AE72995ACC200CB4FBA /* Clave_Loop_LLL.wav */,
C4BA2AE52995AC3E00CB4FBA /* Loop_ToneSD_Boavista.wav */, C4BA2AE52995AC3E00CB4FBA /* Loop_ToneSD_Boavista.wav */,
C4BA2AE32995AC0D00CB4FBA /* Arpeggio_Loop_River.wav */, C4BA2AE32995AC0D00CB4FBA /* Arpeggio_Loop_River.wav */,
C4BA2AE12995ABE700CB4FBA /* SquareArp_Loop_River.wav */, C4BA2AE12995ABE700CB4FBA /* SquareArp_Loop_River.wav */,
@ -1094,7 +1091,6 @@
C4A16DAD29D0AB1F00143D5E /* ESM_MVG_fx_ui_one_shot_cancel_clicky_reverb_digital_long.wav in Resources */, C4A16DAD29D0AB1F00143D5E /* ESM_MVG_fx_ui_one_shot_cancel_clicky_reverb_digital_long.wav in Resources */,
C4BA2ADE2995ABA800CB4FBA /* MatriarchFxs_Loop2_Collider.wav in Resources */, C4BA2ADE2995ABA800CB4FBA /* MatriarchFxs_Loop2_Collider.wav in Resources */,
C415D3EF29C376DD0037B215 /* QP01 0017 Stream sparkling.wav in Resources */, C415D3EF29C376DD0037B215 /* QP01 0017 Stream sparkling.wav in Resources */,
C4BA2AE82995ACC200CB4FBA /* Clave_Loop_LLL.wav in Resources */,
C4E5D67C29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav in Resources */, C4E5D67C29B8D4A5008E7465 /* Low_Tom_Disto_Earth.wav in Resources */,
C4BA2AE02995ABD200CB4FBA /* HighChords_Loop_River.wav in Resources */, C4BA2AE02995ABD200CB4FBA /* HighChords_Loop_River.wav in Resources */,
C4E5D66D29B753D7008E7465 /* AppShortcuts.strings in Resources */, C4E5D66D29B753D7008E7465 /* AppShortcuts.strings in Resources */,

@ -38,9 +38,13 @@ extension NSManagedObjectContext {
return try self.fetch(request) return try self.fetch(request)
} }
func count(entityName: String) throws -> Int { func count(entityName: String) -> Int {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName) let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
return try self.count(for: fetchRequest) do {
return try self.count(for: fetchRequest)
} catch {
return 0
}
} }
} }

@ -79,7 +79,6 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
// StephanBodzin // StephanBodzin
case sbSEM_Synths_Loop4_Nothing_Like_You case sbSEM_Synths_Loop4_Nothing_Like_You
case sbClave_Loop_LLL
case sbLoop_ToneSD_Boavista case sbLoop_ToneSD_Boavista
case sbArpeggio_Loop_River case sbArpeggio_Loop_River
case sbSquareArp_Loop_River case sbSquareArp_Loop_River
@ -112,7 +111,6 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
var localizedString: String { var localizedString: String {
switch self { switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You: return "Nothing Like You" case .sbSEM_Synths_Loop4_Nothing_Like_You: return "Nothing Like You"
case .sbClave_Loop_LLL: return "LLL"
case .sbLoop_ToneSD_Boavista: return "Boavista" case .sbLoop_ToneSD_Boavista: return "Boavista"
case .sbArpeggio_Loop_River: return "River 1" case .sbArpeggio_Loop_River: return "River 1"
case .sbSquareArp_Loop_River: return "River 2" case .sbSquareArp_Loop_River: return "River 2"
@ -142,7 +140,6 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
var fileName: String { var fileName: String {
switch self { switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You: return "SEM_Synths_Loop4_Nothing_Like_You.wav" case .sbSEM_Synths_Loop4_Nothing_Like_You: return "SEM_Synths_Loop4_Nothing_Like_You.wav"
case .sbClave_Loop_LLL: return "Clave_Loop_LLL.wav"
case .sbLoop_ToneSD_Boavista: return "Loop_ToneSD_Boavista.wav" case .sbLoop_ToneSD_Boavista: return "Loop_ToneSD_Boavista.wav"
case .sbArpeggio_Loop_River: return "Arpeggio_Loop_River.wav" case .sbArpeggio_Loop_River: return "Arpeggio_Loop_River.wav"
case .sbSquareArp_Loop_River: return "SquareArp_Loop_River.wav" case .sbSquareArp_Loop_River: return "SquareArp_Loop_River.wav"
@ -171,7 +168,7 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
var playlist: Playlist { var playlist: Playlist {
switch self { switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbClave_Loop_LLL, .sbLoop_ToneSD_Boavista, .sbArpeggio_Loop_River, .sbSquareArp_Loop_River, .sbHighChords_Loop_River, .sbMatriarchFxs_Loop2_Collider: case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .sbArpeggio_Loop_River, .sbSquareArp_Loop_River, .sbHighChords_Loop_River, .sbMatriarchFxs_Loop2_Collider:
return .stephanBodzin return .stephanBodzin
case .FF_SH_bowl_drone_tapping_C, .FF_SH_bowl_drone_tap_hold_E, .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm, .EX_ATSM_Bell_Binaural_Flam_Eb, .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am, .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm, .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab: case .FF_SH_bowl_drone_tapping_C, .FF_SH_bowl_drone_tap_hold_E, .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm, .EX_ATSM_Bell_Binaural_Flam_Eb, .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am, .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm, .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab:
return .relax return .relax
@ -184,7 +181,7 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
var isRestricted: Bool { var isRestricted: Bool {
switch self { switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You: case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .FF_SH_bowl_drone_tapping_C, .EX_ATSM_Bell_Binaural_Flam_Eb, .tropicalForestMorning, .rain_soft:
return false return false
default: default:
return true return true

@ -14,7 +14,16 @@ public enum StoreError: Error {
enum StorePlan: String, CaseIterable { enum StorePlan: String, CaseIterable {
case none case none
case unlimited = "com.staxriver.enchant.unlimited" case monthly = "com.staxriver.enchant.monthly"
case yearly = "com.staxriver.enchant.yearly"
var formattedPeriod: String {
switch self {
case .none: return ""
case .monthly: return NSLocalizedString("month", comment: "")
case .yearly: return NSLocalizedString("year", comment: "")
}
}
} }
extension Notification.Name { extension Notification.Name {
@ -115,15 +124,16 @@ extension Notification.Name {
} }
var isAuthorized: Bool { var isAuthorized: Bool {
return self.currentPlan == .unlimited return false //self.currentPlan != .none
} }
var currentPlan: StorePlan { var currentPlan: StorePlan {
#if DEBUG #if DEBUG
return .unlimited return .yearly
#else #else
if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) { if let currentBestPlan = self.currentBestPlan,
let plan = StorePlan(rawValue: currentBestPlan.productID) {
return plan return plan
} }
return .none return .none
@ -131,13 +141,13 @@ extension Notification.Name {
} }
fileprivate func _updateBestPlan() { fileprivate func _updateBestPlan() {
if let monthly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.monthly.rawValue }) {
if let unlimited = self.purchasedTransactions.first(where: { $0.productID == StorePlan.unlimited.rawValue }) { self.currentBestPlan = monthly
self.currentBestPlan = unlimited } else if let yearly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.yearly.rawValue }) {
self.currentBestPlan = yearly
} else { } else {
self.currentBestPlan = nil self.currentBestPlan = nil
} }
} }
} }

@ -44,7 +44,7 @@ class Store: ObservableObject {
@MainActor @MainActor
func requestProducts() async { func requestProducts() async {
do { do {
let identifiers: [String] = [StorePlan.unlimited.rawValue] let identifiers: [String] = [StorePlan.monthly.rawValue, StorePlan.yearly.rawValue]
products = try await Product.products(for: identifiers) products = try await Product.products(for: identifiers)
Logger.log("products = \(self.products.count)") Logger.log("products = \(self.products.count)")
self.delegate?.productsReceived() self.delegate?.productsReceived()

@ -47,13 +47,9 @@ struct StoreView: View, StoreDelegate {
var body: some View { var body: some View {
if let product = self.store.products.first { if !self.store.products.isEmpty {
PlanView()
PlanView(productName: product.displayName, .environmentObject(self.store)
price: product.displayPrice) {
self._purchase()
}
} else { } else {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
@ -61,16 +57,6 @@ struct StoreView: View, StoreDelegate {
} }
fileprivate func _purchase() {
if let product = self.store.products.first {
Task {
try await store.purchase(product)
}
}
}
// MARK: - StoreDelegate // MARK: - StoreDelegate
func productsReceived() { func productsReceived() {
@ -85,16 +71,16 @@ struct StoreView: View, StoreDelegate {
struct PlanView: View { struct PlanView: View {
var productName: String @EnvironmentObject var store: Store
var price: String
var actionHandler: () -> () // var productName: String
// var price: String
var body: some View { var body: some View {
VStack { VStack {
Text(productName) Text("Permanent enchantment")
.font(.title) .font(.title)
.fontWeight(.bold) .fontWeight(.bold)
.padding() .padding()
@ -116,24 +102,40 @@ struct PlanView: View {
PlayerWrapperView() PlayerWrapperView()
Button { Text("Purchase").font(.title)
self.actionHandler()
} label: { ForEach(self.store.products) { product in
HStack {
Spacer() Button {
VStack { self._purchase(product: product)
Text("Purchase") } label: {
Text(self.price).font(.title3) HStack {
Spacer()
VStack {
if let plan = StorePlan(rawValue: product.id) {
Text("\(product.displayPrice) / \(plan.formattedPeriod)").font(.title3)
} else {
Text("Plan not found")
}
}
Spacer()
} }
Spacer()
} }
.buttonStyle(.borderedProminent)
.fontWeight(.medium)
} }
.font(.title)
.buttonStyle(.borderedProminent).fontWeight(.medium)
}.padding() }.padding()
} }
fileprivate func _purchase(product: Product) {
Task {
try await store.purchase(product)
}
}
} }
struct PlayerWrapperView: View { struct PlayerWrapperView: View {
@ -194,8 +196,6 @@ struct PlayerWrapperView: View {
struct StoreView_Previews: PreviewProvider { struct StoreView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
PlanView(productName: "Pro version", PlanView().environmentObject(Store())
price: "$0.99 / month",
actionHandler: {})
} }
} }

@ -19,6 +19,8 @@ struct PresetsView: View {
@StateObject var model: PresetModel = PresetModel() @StateObject var model: PresetModel = PresetModel()
@State var isShowingSubscription: Bool = false
@State var isPresented: Bool = false @State var isPresented: Bool = false
@State var isShowingNewCountdown = false @State var isShowingNewCountdown = false
@ -50,7 +52,7 @@ struct PresetsView: View {
VStack(alignment: .leading, spacing: 10.0) { VStack(alignment: .leading, spacing: 10.0) {
Button { Button {
self.isShowingNewCountdown = true self._showNewCountdown()
} label: { } label: {
Text(NSLocalizedString("Create countdown", comment: "").uppercased()) Text(NSLocalizedString("Create countdown", comment: "").uppercased())
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -110,8 +112,20 @@ struct PresetsView: View {
CountdownEditView(isPresented: $isPresented, preset: self.model.selectedPreset, tabSelection: self.tabSelection) CountdownEditView(isPresented: $isPresented, preset: self.model.selectedPreset, tabSelection: self.tabSelection)
.environment(\.managedObjectContext, viewContext) .environment(\.managedObjectContext, viewContext)
}) })
.sheet(isPresented: $isShowingSubscription, content: {
StoreView()
})
.navigationTitle("Create") .navigationTitle("Create")
} }
fileprivate func _showNewCountdown() {
if AppGuard.main.isAuthorized || viewContext.count(entityName: "AbstractTimer") < 4 {
self.isShowingNewCountdown = true
} else {
self.isShowingSubscription = true
}
}
} }
struct TimerItemView: View { struct TimerItemView: View {

@ -46,7 +46,7 @@ struct PlaylistSectionView: View {
self._playSound(sound) self._playSound(sound)
} }
Spacer() Spacer()
if AppGuard.main.isAuthorized { if !sound.isRestricted || AppGuard.main.isAuthorized {
RightAlignToggleRow(item: sound, selected: self.model.binding(sound: sound), keyPath: \.formattedDuration) { selected in RightAlignToggleRow(item: sound, selected: self.model.binding(sound: sound), keyPath: \.formattedDuration) { selected in
self.model.selectSound(sound, selected: selected) self.model.selectSound(sound, selected: selected)
}.frame(width: 120.0) }.frame(width: 120.0)

@ -255,3 +255,5 @@
"Play cancellation sound" = "Jouer son d'annulation"; "Play cancellation sound" = "Jouer son d'annulation";
"Contact us" = "Contactez-nous"; "Contact us" = "Contactez-nous";
"Confirmation" = "Confirmation"; "Confirmation" = "Confirmation";
"month" = "mois";
"year" = "an";

Loading…
Cancel
Save