Adopt modern SwiftUI patterns now that the minimum target is iOS 18: NavigationStack, .toolbar, .tint, new Tab API with sidebarAdaptable style, and remove iOS 17 availability checks. Add Liquid Glass effect support for iOS 26 in TimerView and fix an optional interpolation warning in AppState. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
212 lines
6.7 KiB
Swift
212 lines
6.7 KiB
Swift
//
|
|
// TimerView.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 11.01.24.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import Combine
|
|
import AVFoundation
|
|
import UserNotifications
|
|
|
|
|
|
struct TimerView: View {
|
|
@ObservedObject var timer: RecipeTimer
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Gauge(value: timer.timeTotal - timer.timeElapsed, in: 0...timer.timeTotal) {
|
|
Text("Cooking time")
|
|
} currentValueLabel: {
|
|
Button {
|
|
if timer.isRunning {
|
|
timer.pause()
|
|
} else {
|
|
timer.start()
|
|
}
|
|
} label: {
|
|
if timer.isRunning {
|
|
Image(systemName: "pause.fill")
|
|
} else {
|
|
Image(systemName: "play.fill")
|
|
}
|
|
}
|
|
}
|
|
.gaugeStyle(.accessoryCircularCapacity)
|
|
.animation(.easeInOut, value: timer.timeElapsed)
|
|
.tint(timer.isRunning ? .green : .nextcloudBlue)
|
|
.foregroundStyle(timer.isRunning ? Color.green : Color.nextcloudBlue)
|
|
|
|
VStack(alignment: .leading) {
|
|
Text("Cooking")
|
|
Text(timer.duration.toTimerText())
|
|
}
|
|
.padding(.horizontal)
|
|
|
|
Button {
|
|
timer.cancel()
|
|
} label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(timer.isRunning ? Color.nextcloudBlue : Color.secondary)
|
|
}
|
|
}
|
|
.bold()
|
|
.padding()
|
|
.background {
|
|
if #available(iOS 26, *) {
|
|
Color.clear
|
|
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.foregroundStyle(.ultraThickMaterial)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class RecipeTimer: ObservableObject {
|
|
var timeTotal: Double
|
|
@Published var duration: DurationComponents
|
|
private var startDate: Date?
|
|
private var pauseDate: Date?
|
|
@Published var timeElapsed: Double = 0
|
|
@Published var isRunning: Bool = false
|
|
@Published var timerExpired: Bool = false
|
|
private var timer: Timer.TimerPublisher?
|
|
private var timerCancellable: Cancellable?
|
|
var audioPlayer: AVAudioPlayer?
|
|
|
|
init(duration: DurationComponents) {
|
|
self.duration = duration
|
|
self.timeTotal = duration.toSeconds()
|
|
}
|
|
|
|
func start() {
|
|
self.isRunning = true
|
|
if startDate == nil {
|
|
startDate = Date()
|
|
} else if let pauseDate = pauseDate {
|
|
// Adjust start date based on the pause duration
|
|
let pauseDuration = Date().timeIntervalSince(pauseDate)
|
|
startDate = startDate?.addingTimeInterval(pauseDuration)
|
|
}
|
|
requestNotificationPermissions()
|
|
scheduleTimerNotification(timeInterval: timeTotal)
|
|
// Prepare audio session
|
|
setupAudioSession()
|
|
prepareAudioPlayer(with: "alarm_sound_0")
|
|
|
|
self.timer = Timer.publish(every: 1, on: .main, in: .common)
|
|
self.timerCancellable = self.timer?.autoconnect().sink { [weak self] _ in
|
|
DispatchQueue.main.async {
|
|
if let self = self, let startTime = self.startDate {
|
|
let elapsed = Date().timeIntervalSince(startTime)
|
|
if elapsed < self.timeTotal {
|
|
self.timeElapsed = elapsed
|
|
self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed))
|
|
} else {
|
|
self.timerExpired = true
|
|
self.timeElapsed = self.timeTotal
|
|
self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed))
|
|
self.pause()
|
|
|
|
self.startAlarm()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func pause() {
|
|
self.isRunning = false
|
|
pauseDate = Date()
|
|
self.timerCancellable?.cancel()
|
|
self.timerCancellable = nil
|
|
self.timer = nil
|
|
}
|
|
|
|
func resume() {
|
|
self.isRunning = true
|
|
start()
|
|
}
|
|
|
|
func cancel() {
|
|
self.isRunning = false
|
|
self.timerCancellable?.cancel()
|
|
self.timerCancellable = nil
|
|
self.timer = nil
|
|
self.timeElapsed = 0
|
|
self.startDate = nil
|
|
self.pauseDate = nil
|
|
self.duration.fromSeconds(Int(timeTotal))
|
|
}
|
|
}
|
|
|
|
extension RecipeTimer {
|
|
func setupAudioSession() {
|
|
do {
|
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
} catch {
|
|
print("Failed to set audio session category. Error: \(error)")
|
|
}
|
|
}
|
|
|
|
func prepareAudioPlayer(with soundName: String) {
|
|
if let soundURL = Bundle.main.url(forResource: "alarm_sound_0", withExtension: "mp3") {
|
|
do {
|
|
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
|
|
audioPlayer?.prepareToPlay()
|
|
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
|
} catch {
|
|
print("Error loading sound file: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func postNotification() {
|
|
NotificationCenter.default.post(name: Notification.Name("AlarmNotification"), object: nil)
|
|
}
|
|
|
|
func startAlarm() {
|
|
audioPlayer?.play()
|
|
postNotification()
|
|
}
|
|
|
|
func stopAlarm() {
|
|
audioPlayer?.stop()
|
|
try? AVAudioSession.sharedInstance().setActive(false)
|
|
}
|
|
|
|
func requestNotificationPermissions() {
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
|
if granted {
|
|
print("Notification permission granted.")
|
|
} else if let error = error {
|
|
print("Notification permission denied because: \(error.localizedDescription).")
|
|
}
|
|
}
|
|
}
|
|
|
|
func scheduleTimerNotification(timeInterval: TimeInterval) {
|
|
let content = UNMutableNotificationContent()
|
|
content.title = "Timer Finished"
|
|
content.body = "Your timer is up!"
|
|
content.sound = UNNotificationSound.default
|
|
|
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
|
|
|
|
let request = UNNotificationRequest(identifier: "timerNotification", content: content, trigger: trigger)
|
|
|
|
UNUserNotificationCenter.current().add(request) { error in
|
|
if let error = error {
|
|
print("Error scheduling notification: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|