Initial MultiTimer macOS app

This commit is contained in:
Martins-Voron
2026-04-29 19:22:13 +02:00
commit 5186ada84e
22 changed files with 1069 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import AppKit
import AudioToolbox
final class AlarmManager {
static let shared = AlarmManager()
private var sound: NSSound?
private init() {}
func playAlarm() {
AudioServicesPlaySystemSound(1005)
if let url = URL(string: "file:///System/Library/Sounds/Glass.aiff") {
sound = NSSound(contentsOf: url, byReference: true)
sound?.volume = 1.0
sound?.play()
}
NSApp.requestUserAttention(.criticalRequest)
}
}
+100
View File
@@ -0,0 +1,100 @@
import AppKit
import SwiftUI
@MainActor
final class AlarmWindowManager {
static let shared = AlarmWindowManager()
private var windows: [UUID: NSWindow] = [:]
private init() {}
func showAlarm(
timerID: UUID,
timerName: String,
onRepeat: @escaping () -> Void,
onFinish: @escaping () -> Void
) {
if let existingWindow = windows[timerID] {
existingWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let view = AlarmPopupView(
timerName: timerName,
repeatAction: { [weak self] in
onRepeat()
self?.closeWindow(timerID: timerID)
},
finishAction: { [weak self] in
onFinish()
self?.closeWindow(timerID: timerID)
}
)
let hostingView = NSHostingView(rootView: view)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 210),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "Timer abgelaufen"
window.contentView = hostingView
window.isReleasedWhenClosed = false
window.level = .floating
window.center()
windows[timerID] = window
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
func closeWindow(timerID: UUID) {
windows[timerID]?.close()
windows.removeValue(forKey: timerID)
}
}
struct AlarmPopupView: View {
let timerName: String
let repeatAction: () -> Void
let finishAction: () -> Void
var body: some View {
VStack(spacing: 18) {
Image(systemName: "timer")
.font(.system(size: 42))
.foregroundStyle(.orange)
VStack(spacing: 6) {
Text("Timer abgelaufen")
.font(.title2)
.bold()
Text("„\(timerName)” ist fertig.")
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button("Beenden") {
finishAction()
}
.keyboardShortcut(.cancelAction)
Button("Wiederholen") {
repeatAction()
}
.keyboardShortcut(.defaultAction)
}
}
.padding(24)
.frame(width: 360, height: 210)
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "icon_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "icon_32x32 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "icon_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "icon_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "icon_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "icon_256x256 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "icon_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "icon_512x512 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "icon_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "icon_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+178
View File
@@ -0,0 +1,178 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var store: TimerStore
var body: some View {
VStack(spacing: 16) {
header
ScrollView {
LazyVStack(spacing: 12) {
ForEach($store.timers) { $timer in
TimerRow(timer: $timer)
.environmentObject(store)
}
}
.padding(.horizontal)
.padding(.bottom)
}
}
.padding(.top)
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("MultiTimer")
.font(.largeTitle)
.bold()
Text("Bis zu 15 parallele Timer mit Alarm und Wiederholung")
.foregroundStyle(.secondary)
}
Spacer()
Button {
store.addTimer()
} label: {
Label("Timer hinzufügen", systemImage: "plus")
}
.disabled(store.timers.count >= 15)
}
.padding(.horizontal)
}
}
struct TimerRow: View {
@EnvironmentObject private var store: TimerStore
@Binding var timer: TimerItem
@State private var durationText: String = ""
@State private var durationInputIsInvalid = false
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
TextField("Name", text: $timer.name)
.textFieldStyle(.roundedBorder)
.font(.headline)
Spacer()
Text(timer.formattedRemaining)
.font(.system(size: 34, weight: .semibold, design: .monospaced))
.frame(width: 150, alignment: .trailing)
}
ProgressView(value: timer.progress)
.progressViewStyle(.linear)
HStack(spacing: 12) {
HStack(spacing: 6) {
Text("Dauer")
TextField("hh:mm", text: $durationText)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
.frame(width: 70)
.disabled(timer.isRunning)
.onSubmit {
commitDuration()
}
.onChange(of: durationText) { _ in
durationInputIsInvalid = false
}
Text("hh:mm")
.foregroundStyle(.secondary)
}
if durationInputIsInvalid {
Text("Ungültig")
.foregroundStyle(.red)
.font(.caption)
}
Stepper(
value: Binding(
get: { timer.durationSeconds / 60 },
set: { newMinutes in
let seconds = max(1, newMinutes) * 60
store.durationChanged(for: timer.id, newDurationSeconds: seconds)
durationText = TimerItem.formatHHMM(seconds: seconds)
}
),
in: 1...(24 * 60)
) {
Text("")
}
.labelsHidden()
.disabled(timer.isRunning)
Toggle("Auto-Wiederholung", isOn: $timer.autoRepeat)
.toggleStyle(.checkbox)
Spacer()
if timer.isRunning {
Button {
commitDuration()
store.pause(timer)
} label: {
Label("Pause", systemImage: "pause.fill")
}
} else {
Button {
commitDuration()
store.start(timer)
} label: {
Label("Start", systemImage: "play.fill")
}
}
Button {
store.reset(timer)
durationText = TimerItem.formatHHMM(seconds: timer.durationSeconds)
} label: {
Label("Reset", systemImage: "arrow.counterclockwise")
}
Button(role: .destructive) {
store.removeTimer(timer)
} label: {
Label("Löschen", systemImage: "trash")
}
Text("Fertig: \(timer.completedCount)x")
.foregroundStyle(.secondary)
.frame(width: 80, alignment: .trailing)
}
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
.onAppear {
durationText = TimerItem.formatHHMM(seconds: timer.durationSeconds)
}
.onChange(of: timer.durationSeconds) { newValue in
if !timer.isRunning {
durationText = TimerItem.formatHHMM(seconds: newValue)
}
}
}
private func commitDuration() {
guard !timer.isRunning else { return }
if let seconds = TimerItem.parseHHMM(durationText) {
durationInputIsInvalid = false
store.durationChanged(for: timer.id, newDurationSeconds: seconds)
durationText = TimerItem.formatHHMM(seconds: seconds)
} else {
durationInputIsInvalid = true
durationText = TimerItem.formatHHMM(seconds: timer.durationSeconds)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct MultiTimerApp: App {
@StateObject private var store = TimerStore()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
.frame(minWidth: 760, minHeight: 620)
}
}
}
+88
View File
@@ -0,0 +1,88 @@
import Foundation
struct TimerItem: Identifiable, Codable, Equatable {
var id: UUID
var name: String
/// Gesamtdauer in Sekunden
var durationSeconds: Int
/// Nur relevant, wenn Timer pausiert oder gestoppt ist
var remainingSeconds: Int
var isRunning: Bool
var completedCount: Int
var autoRepeat: Bool
/// Zielzeitpunkt. Dadurch bleiben Timer auch über Standby logisch korrekt.
var endDate: Date?
init(
id: UUID = UUID(),
name: String,
durationSeconds: Int = 10 * 60,
remainingSeconds: Int? = nil,
isRunning: Bool = false,
completedCount: Int = 0,
autoRepeat: Bool = false,
endDate: Date? = nil
) {
self.id = id
self.name = name
self.durationSeconds = max(60, durationSeconds)
self.remainingSeconds = remainingSeconds ?? max(60, durationSeconds)
self.isRunning = isRunning
self.completedCount = completedCount
self.autoRepeat = autoRepeat
self.endDate = endDate
}
var durationMinutes: Int {
max(1, durationSeconds / 60)
}
var progress: Double {
guard durationSeconds > 0 else { return 0 }
return min(1.0, max(0.0, 1.0 - Double(remainingSeconds) / Double(durationSeconds)))
}
var formattedRemaining: String {
Self.formatHHMMSS(seconds: remainingSeconds)
}
var formattedDurationHHMM: String {
Self.formatHHMM(seconds: durationSeconds)
}
static func formatHHMM(seconds: Int) -> String {
let safe = max(0, seconds)
let hours = safe / 3600
let minutes = (safe % 3600) / 60
return String(format: "%02d:%02d", hours, minutes)
}
static func formatHHMMSS(seconds: Int) -> String {
let safe = max(0, seconds)
let hours = safe / 3600
let minutes = (safe % 3600) / 60
let seconds = safe % 60
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%02d:%02d", minutes, seconds)
}
}
static func parseHHMM(_ text: String) -> Int? {
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = cleaned.split(separator: ":")
guard parts.count == 2 else { return nil }
guard let hours = Int(parts[0]), let minutes = Int(parts[1]) else { return nil }
guard hours >= 0, minutes >= 0, minutes <= 59 else { return nil }
let totalSeconds = hours * 3600 + minutes * 60
return totalSeconds >= 60 ? totalSeconds : nil
}
}
+212
View File
@@ -0,0 +1,212 @@
import Foundation
import Combine
import AppKit
@MainActor
final class TimerStore: ObservableObject {
@Published var timers: [TimerItem] = [] {
didSet {
save()
}
}
private var ticker: AnyCancellable?
private let storageKey = "multiTimerApp.timers.v2"
init() {
load()
if timers.isEmpty {
timers = (1...10).map {
TimerItem(name: "Timer \($0)", durationSeconds: 10 * 60)
}
}
// Beim Start prüfen: Falls Timer vor App-Ende lief, endDate verwenden.
reconcileRunningTimers()
ticker = Timer
.publish(every: 0.5, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
Task { @MainActor in
self?.tick()
}
}
NotificationCenter.default.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.tick()
}
}
}
func addTimer() {
guard timers.count < 15 else { return }
timers.append(
TimerItem(
name: "Timer \(timers.count + 1)",
durationSeconds: 10 * 60
)
)
}
func removeTimer(_ timer: TimerItem) {
AlarmWindowManager.shared.closeWindow(timerID: timer.id)
timers.removeAll { $0.id == timer.id }
}
func start(_ timer: TimerItem) {
guard let index = timers.firstIndex(where: { $0.id == timer.id }) else { return }
if timers[index].remainingSeconds <= 0 {
timers[index].remainingSeconds = timers[index].durationSeconds
}
timers[index].endDate = Date().addingTimeInterval(TimeInterval(timers[index].remainingSeconds))
timers[index].isRunning = true
}
func pause(_ timer: TimerItem) {
guard let index = timers.firstIndex(where: { $0.id == timer.id }) else { return }
if let endDate = timers[index].endDate {
timers[index].remainingSeconds = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
}
timers[index].isRunning = false
timers[index].endDate = nil
}
func reset(_ timer: TimerItem) {
guard let index = timers.firstIndex(where: { $0.id == timer.id }) else { return }
timers[index].isRunning = false
timers[index].endDate = nil
timers[index].remainingSeconds = timers[index].durationSeconds
AlarmWindowManager.shared.closeWindow(timerID: timer.id)
}
func repeatTimer(timerID: UUID) {
guard let index = timers.firstIndex(where: { $0.id == timerID }) else { return }
timers[index].remainingSeconds = timers[index].durationSeconds
timers[index].endDate = Date().addingTimeInterval(TimeInterval(timers[index].durationSeconds))
timers[index].isRunning = true
}
func finishTimer(timerID: UUID) {
guard let index = timers.firstIndex(where: { $0.id == timerID }) else { return }
timers[index].isRunning = false
timers[index].endDate = nil
timers[index].remainingSeconds = timers[index].durationSeconds
}
func durationChanged(for timerID: UUID, newDurationSeconds: Int) {
guard let index = timers.firstIndex(where: { $0.id == timerID }) else { return }
let safeSeconds = max(60, min(24 * 3600, newDurationSeconds))
timers[index].durationSeconds = safeSeconds
if !timers[index].isRunning {
timers[index].remainingSeconds = safeSeconds
timers[index].endDate = nil
}
}
private func tick() {
var changed = false
for index in timers.indices where timers[index].isRunning {
guard let endDate = timers[index].endDate else {
timers[index].endDate = Date().addingTimeInterval(TimeInterval(timers[index].remainingSeconds))
changed = true
continue
}
let secondsLeft = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
if timers[index].remainingSeconds != secondsLeft {
timers[index].remainingSeconds = secondsLeft
changed = true
}
if secondsLeft <= 0 {
handleExpiredTimer(at: index)
changed = true
}
}
if changed {
objectWillChange.send()
save()
}
}
private func handleExpiredTimer(at index: Int) {
timers[index].remainingSeconds = 0
timers[index].isRunning = false
timers[index].endDate = nil
timers[index].completedCount += 1
AlarmManager.shared.playAlarm()
let timerID = timers[index].id
let timerName = timers[index].name
if timers[index].autoRepeat {
timers[index].remainingSeconds = timers[index].durationSeconds
timers[index].endDate = Date().addingTimeInterval(TimeInterval(timers[index].durationSeconds))
timers[index].isRunning = true
} else {
AlarmWindowManager.shared.showAlarm(
timerID: timerID,
timerName: timerName,
onRepeat: { [weak self] in
self?.repeatTimer(timerID: timerID)
},
onFinish: { [weak self] in
self?.finishTimer(timerID: timerID)
}
)
}
}
private func reconcileRunningTimers() {
for index in timers.indices where timers[index].isRunning {
if let endDate = timers[index].endDate {
timers[index].remainingSeconds = max(0, Int(ceil(endDate.timeIntervalSinceNow)))
} else {
timers[index].endDate = Date().addingTimeInterval(TimeInterval(timers[index].remainingSeconds))
}
if timers[index].remainingSeconds <= 0 {
timers[index].isRunning = false
timers[index].remainingSeconds = 0
}
}
}
private func save() {
guard let data = try? JSONEncoder().encode(timers) else { return }
UserDefaults.standard.set(data, forKey: storageKey)
}
private func load() {
guard
let data = UserDefaults.standard.data(forKey: storageKey),
let decoded = try? JSONDecoder().decode([TimerItem].self, from: data)
else {
return
}
timers = decoded
}
}