Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift
Hendrik Hogertz 527acd2967 Raise deployment target to iOS 18 and modernize SwiftUI APIs
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>
2026-02-14 23:14:57 +01:00

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)")
}
}
}
}