diff --git a/LeCountdown.xcodeproj/project.pbxproj b/LeCountdown.xcodeproj/project.pbxproj index 1ddfc15..c30b6db 100644 --- a/LeCountdown.xcodeproj/project.pbxproj +++ b/LeCountdown.xcodeproj/project.pbxproj @@ -120,6 +120,9 @@ C4F8B1C0298ACA61005C86A5 /* Model+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C438C806298195E600BF3EF9 /* Model+Extensions.swift */; }; C4F8B1C3298ACBDB005C86A5 /* Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = C445FA912987CC8A0054D761 /* Sound.swift */; }; C4F8B1C6298ACC1F005C86A5 /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C445FA8E2987B83B0054D761 /* SoundPlayer.swift */; }; + C4F8B1D0298BF2E2005C86A5 /* DialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8B1CF298BF2E2005C86A5 /* DialView.swift */; }; + C4F8B1D2298BF646005C86A5 /* PermissionAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8B1D1298BF646005C86A5 /* PermissionAlertView.swift */; }; + C4F8B1D8298C0727005C86A5 /* TimerRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F8B1D7298C0727005C86A5 /* TimerRouter.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -243,6 +246,9 @@ C4F8B1B7298AC81D005C86A5 /* CountdownDialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownDialView.swift; sourceTree = ""; }; C4F8B1BC298AC8DE005C86A5 /* AlarmDialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmDialView.swift; sourceTree = ""; }; C4F8B1BE298ACA0B005C86A5 /* StopwatchDialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopwatchDialView.swift; sourceTree = ""; }; + C4F8B1CF298BF2E2005C86A5 /* DialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DialView.swift; sourceTree = ""; }; + C4F8B1D1298BF646005C86A5 /* PermissionAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAlertView.swift; sourceTree = ""; }; + C4F8B1D7298C0727005C86A5 /* TimerRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerRouter.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -319,6 +325,7 @@ C4060DBF297AE73B003FAB80 /* LeCountdownApp.swift */, C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */, C4F8B15629891271005C86A5 /* Conductor.swift */, + C4F8B1D7298C0727005C86A5 /* TimerRouter.swift */, C438C80B2981DE2E00BF3EF9 /* Views */, C438C8092981DDF800BF3EF9 /* Model */, C445FA8D2987B82E0054D761 /* Sound */, @@ -430,7 +437,9 @@ C4F8B1BA298AC83F005C86A5 /* Alarm */, C4F8B1B9298AC830005C86A5 /* Countdown */, C4F8B1BB298AC848005C86A5 /* Stopwatch */, + C4F8B1D3298BF686005C86A5 /* Components */, C4060DC1297AE73B003FAB80 /* ContentView.swift */, + C4F8B1CF298BF2E2005C86A5 /* DialView.swift */, C438C80E29828B8600BF3EF9 /* RecordsView.swift */, C4742B5A298414B000D5D950 /* ImageSelectionView.swift */, C4F8B165298A9ABB005C86A5 /* SoundImageFormView.swift */, @@ -508,6 +517,14 @@ path = Stopwatch; sourceTree = ""; }; + C4F8B1D3298BF686005C86A5 /* Components */ = { + isa = PBXGroup; + children = ( + C4F8B1D1298BF646005C86A5 /* PermissionAlertView.swift */, + ); + path = Components; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -701,12 +718,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C4F8B1D2298BF646005C86A5 /* PermissionAlertView.swift in Sources */, C4060DC9297AE73D003FAB80 /* Persistence.swift in Sources */, C4F8B1AB298AC3A0005C86A5 /* Countdown+CoreDataProperties.swift in Sources */, C438C80F29828B8600BF3EF9 /* RecordsView.swift in Sources */, C4F8B187298AC234005C86A5 /* Activity+CoreDataProperties.swift in Sources */, C438C80D2982847300BF3EF9 /* CoreDataRequests.swift in Sources */, C4742B5F2984205000D5D950 /* ViewModifiers.swift in Sources */, + C4F8B1D8298C0727005C86A5 /* TimerRouter.swift in Sources */, C4F8B186298AC234005C86A5 /* Activity+CoreDataClass.swift in Sources */, C4F8B15729891271005C86A5 /* Conductor.swift in Sources */, C4F8B17E298AC234005C86A5 /* Alarm+CoreDataClass.swift in Sources */, @@ -718,6 +737,7 @@ C4F8B1A8298AC2FC005C86A5 /* AbstractSoundTimer+CoreDataProperties.swift in Sources */, C4F8B1B8298AC81D005C86A5 /* CountdownDialView.swift in Sources */, C445FA922987CC8A0054D761 /* Sound.swift in Sources */, + C4F8B1D0298BF2E2005C86A5 /* DialView.swift in Sources */, C4742B59298411E800D5D950 /* CountdownFormView.swift in Sources */, C4060DC2297AE73B003FAB80 /* ContentView.swift in Sources */, C438C7C12980228B00BF3EF9 /* CountdownScheduler.swift in Sources */, diff --git a/LeCountdown/LeCountdownApp.swift b/LeCountdown/LeCountdownApp.swift index 24d748b..4987495 100644 --- a/LeCountdown/LeCountdownApp.swift +++ b/LeCountdown/LeCountdownApp.swift @@ -6,6 +6,7 @@ // import SwiftUI +import CoreData import BackgroundTasks enum BGTaskIdentifier : String { @@ -22,7 +23,7 @@ struct LeCountdownApp: App { var body: some Scene { WindowGroup { ContentView() - .environmentObject(Conductor.maestro) + .environmentObject(BoringContext()) .environment(\.managedObjectContext, persistenceController.container.viewContext) .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in self._willEnterForegroundNotification() @@ -50,7 +51,7 @@ struct LeCountdownApp: App { // } // } } - + fileprivate func _registerBackgroundRefreshes() { BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTaskIdentifier.refresh.rawValue, using: nil) { task in self._handleAppRefresh(task: task as! BGAppRefreshTask) diff --git a/LeCountdown/Model/Model+Extensions.swift b/LeCountdown/Model/Model+Extensions.swift index 05320f4..7e8ebdd 100644 --- a/LeCountdown/Model/Model+Extensions.swift +++ b/LeCountdown/Model/Model+Extensions.swift @@ -9,6 +9,10 @@ import Foundation import SwiftUI import CoreData +enum TimerError: Error { + case notificationAuthorizationMissing +} + extension AbstractTimer { var displayName: String { @@ -23,7 +27,7 @@ extension AbstractTimer { if let url = URL(string: self.stringId) { return url } else { - return URL(fileURLWithPath: self.stringId) // stupid fallthrough + fatalError("Can't produce url with \(self.stringId)") } } @@ -54,15 +58,6 @@ extension AbstractSoundTimer { extension Countdown { -// override func view() -> any View { -// return CountdownLiveView(countdown: self) -// .environmentObject(Conductor.maestro) -// } -// -// override func editView() -> any View { -// -// } - static func fake(context: NSManagedObjectContext) -> Countdown { let cd = Countdown(context: context) cd.duration = 4 * 60.0 diff --git a/LeCountdown/TimerRouter.swift b/LeCountdown/TimerRouter.swift new file mode 100644 index 0000000..dc68e9a --- /dev/null +++ b/LeCountdown/TimerRouter.swift @@ -0,0 +1,56 @@ +// +// TimerRouter.swift +// LeCountdown +// +// Created by Laurent Morvillier on 02/02/2023. +// + +import Foundation +import NotificationCenter + +class TimerRouter { + + static func performAction(timer: AbstractTimer, handler: @escaping (Result) -> Void) { + switch timer { + case let countdown as Countdown: + self._launchCountdown(countdown, handler: handler) + case let alarm as Alarm: + self._scheduleAlarm(alarm, handler: handler) + case let stopwatch as Stopwatch: + self._startStopwatch(stopwatch, handler: handler) + default: + print("missing launcher for \(self)") + } + + } + + fileprivate static func _launchCountdown(_ countdown: Countdown, handler: @escaping (Result) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + + switch settings.authorizationStatus { + case .notDetermined, .denied: + handler(.failure(TimerError.notificationAuthorizationMissing)) + default: + CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in + switch result { + case .success(_): + handler(.success(true)) + case .failure(let failure): + handler(.failure(failure)) + } + } + } + } + + } + + fileprivate static func _scheduleAlarm(_ alarm: Alarm, handler: @escaping (Result) -> Void) { + + } + + fileprivate static func _startStopwatch(_ stopwatch: Stopwatch, handler: @escaping (Result) -> Void) { + + } + + +} diff --git a/LeCountdown/Views/Components/PermissionAlertView.swift b/LeCountdown/Views/Components/PermissionAlertView.swift new file mode 100644 index 0000000..2236194 --- /dev/null +++ b/LeCountdown/Views/Components/PermissionAlertView.swift @@ -0,0 +1,31 @@ +// +// PermissionAlertView.swift +// LeCountdown +// +// Created by Laurent Morvillier on 02/02/2023. +// + +import SwiftUI + +struct PermissionAlertView: View { + + var body: some View { + Button("Show permissions") { + self._showPermissionSettings() + } + Button("OK", role: .cancel) { } + } + + fileprivate func _showPermissionSettings() { + if let url = URL(string: UIApplication.openNotificationSettingsURLString) { + UIApplication.shared.open(url) + } + } + +} + +struct PermissionAlertView_Previews: PreviewProvider { + static var previews: some View { + PermissionAlertView() + } +} diff --git a/LeCountdown/Views/ContentView.swift b/LeCountdown/Views/ContentView.swift index 418921b..599fc6b 100644 --- a/LeCountdown/Views/ContentView.swift +++ b/LeCountdown/Views/ContentView.swift @@ -8,10 +8,19 @@ import SwiftUI import CoreData -struct ContentView: View { +class BoringContext : ObservableObject { + + @Published var isShowingNewData = false + @Published var error: Error? + @Published var showDefaultAlert: Bool = false + @Published var showPermissionAlert: Bool = false + +} - @EnvironmentObject var environment: Conductor +struct ContentView: View { + @EnvironmentObject var boringContext: BoringContext + @Environment(\.managedObjectContext) private var viewContext @FetchRequest( @@ -21,14 +30,6 @@ struct ContentView: View { fileprivate let itemSpacing: CGFloat = 10.0 - @State private var isShowingNewData = false -// @State private var isShowingNewStopwatch = false -// @State private var isShowingNewAlarm = false - - @State var error: Error? - @State var showDefaultAlert: Bool = false - @State var showPermissionAlert: Bool = false - private let columns: [GridItem] = [ GridItem(spacing: 10.0), GridItem(spacing: 10.0), @@ -51,29 +52,8 @@ struct ContentView: View { ReorderableForEach(items: timersArray) { timer in - ZStack(alignment: .topTrailing) { - - Image(timer.imageName).resizable() - - Button { - self._launchTimer(timer) - } label: { - self._dialView(timer: timer) - } - - NavigationLink { - - self._editView(timer: timer, isPresented: $isShowingNewData) - } label: { - Image(systemName: "gearshape.fill") - .font(.system(size: 24, weight: .light)) - .padding() - .foregroundColor(Color.white) - } - - } - .frame(width: width, height: width) - .cornerRadius(40.0) + DialView(timer: timer, frameSize: width) .environment(\.managedObjectContext, viewContext) + .environmentObject(boringContext) } moveAction: { from, to in self._reorder(from: from, to: to) @@ -82,52 +62,24 @@ struct ContentView: View { }.padding(itemSpacing) .navigationTitle("Youpi") - .alert(error?.localizedDescription ?? "missing error", isPresented: $showDefaultAlert) { + .alert(boringContext.error?.localizedDescription ?? "missing error", isPresented: $boringContext.showDefaultAlert) { Button("OK", role: .cancel) { } } - .alert("You need to accept notifications, please check your settings", isPresented: $showPermissionAlert, actions: { - Button("Show permissions") { - self._showPermissionSettings() - } - Button("OK", role: .cancel) { } - }) - .sheet(isPresented: self.$isShowingNewData, content: { - NewCountdownView(isPresented: $isShowingNewData) .environment(\.managedObjectContext, viewContext) + .alert("You need to accept notifications, please check your settings", isPresented: $boringContext.showPermissionAlert) { + PermissionAlertView() + } + .sheet(isPresented: $boringContext.isShowingNewData, content: { + NewCountdownView(isPresented: $boringContext.isShowingNewData) .environment(\.managedObjectContext, viewContext) }) - .sheet(isPresented: self.$isShowingNewData, content: { - NewStopwatchView(isPresented: $isShowingNewData) .environment(\.managedObjectContext, viewContext) + .sheet(isPresented: $boringContext.isShowingNewData, content: { + NewStopwatchView(isPresented: $boringContext.isShowingNewData) .environment(\.managedObjectContext, viewContext) }) - .sheet(isPresented: self.$isShowingNewData, content: { - NewAlarmView(isPresented: $isShowingNewData) .environment(\.managedObjectContext, viewContext) + .sheet(isPresented: $boringContext.isShowingNewData, content: { + NewAlarmView(isPresented: $boringContext.isShowingNewData) .environment(\.managedObjectContext, viewContext) }) - .toolbar { ToolbarItemGroup(placement: .bottomBar) { - Button { - self.isShowingNewData = true - } label: { - HStack { - Image(systemName: "timer") - Text("countdown") - } - } - Button { - self.isShowingNewStopwatch = true - } label: { - HStack { - Image(systemName: "stopwatch") - Text("stopwatch") - } - } - Button { - self.isShowingNewAlarm = true - } label: { - HStack { - Image(systemName: "alarm") - Text("alarm") - } - } - + MainToolbarView(isShowingNewData: $boringContext.isShowingNewData) } ToolbarItem(placement: .navigationBarTrailing) { NavigationLink { @@ -139,47 +91,18 @@ struct ContentView: View { ToolbarItem(placement: .navigationBarLeading) { EditButton() } - } .onAppear { self._askPermissions() } .onOpenURL { url in print("open URL = \(url)") - self._startCountdownIfPossible(url: url) + self._performActionIfPossible(url: url) } } } - @ViewBuilder - fileprivate func _dialView(timer: AbstractTimer) -> some View { - switch timer { - case let countdown as Countdown: - CountdownDialView(countdown: countdown) - case let alarm as Alarm: - AlarmDialView(alarm: alarm) - case let stopwatch as Stopwatch: - StopwatchDialView(stopwatch: stopwatch) - default: - Text("missing dial view") - } - } - - @ViewBuilder - fileprivate func _editView(timer: AbstractTimer, isPresented: Binding) -> some View { - switch timer { - case let countdown as Countdown: - CountdownEditView(countdown: countdown, isPresented: isPresented) - case let alarm as Alarm: - AlarmEditView(alarm: alarm, isPresented: isPresented) - case let stopwatch as Stopwatch: - StopwatchEditView(stopwatch: stopwatch, isPresented: isPresented) - default: - Text("missing edit view") - } - } - fileprivate func _reorder(from: IndexSet, to: Int) { var timers: [AbstractTimer] = self.timersArray timers.move(fromOffsets: from, toOffset: to) @@ -189,61 +112,77 @@ struct ContentView: View { do { try viewContext.save() } catch { - self.error = error + boringContext.error = error } } - fileprivate func _startCountdownIfPossible(url: URL) { + fileprivate func _askPermissions() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in + print("requestAuthorization > success = \(success), error = \(String(describing: error))") + } + } + + fileprivate func _performActionIfPossible(url: URL) { let urlString = url.absoluteString - if let countdown = viewContext.object(stringId: urlString) as? Countdown { + if let timer = viewContext.object(stringId: urlString) as? AbstractTimer { - print("Start countdown: \(countdown.name ?? ""), \(countdown.duration)") + TimerRouter.performAction(timer: timer) { result in + switch result { + case .success: + break + case .failure(let failure): + switch failure { + case TimerError.notificationAuthorizationMissing: + self.boringContext.showPermissionAlert = true + default: + self.boringContext.error = failure + self.boringContext.showDefaultAlert = true + } + } + } - self._launchCountdown(countdown) +// print("Start countdown: \(countdown.name ?? ""), \(countdown.duration)") +// self._launchCountdown(countdown) } else { - print("countdown not found with id = \(urlString)") + print("timer not found with id = \(urlString)") } } - fileprivate func _launchTimer(_ timer: AbstractTimer) { - - } +} + +struct MainToolbarView: View { - fileprivate func _launchCountdown(_ countdown: Countdown) { - - UNUserNotificationCenter.current().getNotificationSettings { settings in - - switch settings.authorizationStatus { - case .notDetermined, .denied: - self.showPermissionAlert = true - default: - CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in - switch result { - case .success(_): - break - case .failure(let failure): - self.error = failure - self.showDefaultAlert = true - } - } + var isShowingNewData: Binding + + var body: some View { + Button { + self.isShowingNewData.wrappedValue = true + } label: { + HStack { + Image(systemName: "timer") + Text("countdown") } } - } - - fileprivate func _askPermissions() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in - print("requestAuthorization > success = \(success), error = \(String(describing: error))") + Button { + self.isShowingNewData.wrappedValue = true + } label: { + HStack { + Image(systemName: "stopwatch") + Text("stopwatch") + } } - } - - fileprivate func _showPermissionSettings() { - if let url = URL(string: UIApplication.openNotificationSettingsURLString) { - UIApplication.shared.open(url) + Button { + self.isShowingNewData.wrappedValue = true + } label: { + HStack { + Image(systemName: "alarm") + Text("alarm") + } } + } - } fileprivate extension Countdown { diff --git a/LeCountdown/Views/DialView.swift b/LeCountdown/Views/DialView.swift new file mode 100644 index 0000000..009384d --- /dev/null +++ b/LeCountdown/Views/DialView.swift @@ -0,0 +1,98 @@ +// +// DialView.swift +// LeCountdown +// +// Created by Laurent Morvillier on 02/02/2023. +// + +import SwiftUI + +struct DialView: View { + + @Environment(\.managedObjectContext) private var viewContext + + @EnvironmentObject var boringContext: BoringContext + + @State var timer: AbstractTimer + + var frameSize: CGFloat + + var body: some View { + ZStack(alignment: .topTrailing) { + + Image(timer.imageName).resizable() + + Button { + self._launchTimer(timer) + } label: { + self._dialView(timer: timer) + } + + NavigationLink { + self._editView(timer: timer, isPresented: $boringContext.isShowingNewData) + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: 24, weight: .light)) + .padding() + .foregroundColor(Color.white) + } + + } + .frame(width: frameSize, height: frameSize) + .cornerRadius(40.0) + } + + @ViewBuilder + fileprivate func _dialView(timer: AbstractTimer) -> some View { + switch timer { + case let countdown as Countdown: + CountdownDialView(countdown: countdown) + case let alarm as Alarm: + AlarmDialView(alarm: alarm) + case let stopwatch as Stopwatch: + StopwatchDialView(stopwatch: stopwatch) + default: + Text("missing dial view") + } + } + + @ViewBuilder + fileprivate func _editView(timer: AbstractTimer, isPresented: Binding) -> some View { + switch timer { + case let countdown as Countdown: + CountdownEditView(countdown: countdown, isPresented: isPresented) + case let alarm as Alarm: + AlarmEditView(alarm: alarm, isPresented: isPresented) + case let stopwatch as Stopwatch: + StopwatchEditView(stopwatch: stopwatch, isPresented: isPresented) + default: + Text("missing edit view") + } + } + + fileprivate func _launchTimer(_ timer: AbstractTimer) { + + TimerRouter.performAction(timer: timer) { result in + switch result { + case .success: + break + case .failure(let failure): + switch failure { + case TimerError.notificationAuthorizationMissing: + self.boringContext.showPermissionAlert = true + default: + self.boringContext.error = failure + self.boringContext.showDefaultAlert = true + } + } + } + + } + +} + +struct DialView_Previews: PreviewProvider { + static var previews: some View { + DialView(timer: Countdown.fake(context: PersistenceController.preview.container.viewContext), frameSize: 150.0) + } +}