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
+25
View File
@@ -0,0 +1,25 @@
# macOS
.DS_Store
# Xcode
DerivedData/
build/
*.xcuserstate
xcuserdata/
*.moved-aside
*.xcscmblueprint
# Swift Package Manager
.build/
.swiftpm/
# Archives / exports
*.xcarchive
*.ipa
*.app
*.zip
# Signing
*.cer
*.p12
*.mobileprovision
+338
View File
@@ -0,0 +1,338 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
E77BD9C72F9E073E005AC11F /* MultiTimer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MultiTimer.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
E77BD9C92F9E073E005AC11F /* MultiTimer */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = MultiTimer;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
E77BD9C42F9E073E005AC11F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
E77BD9BE2F9E073E005AC11F = {
isa = PBXGroup;
children = (
E77BD9C92F9E073E005AC11F /* MultiTimer */,
E77BD9C82F9E073E005AC11F /* Products */,
);
sourceTree = "<group>";
};
E77BD9C82F9E073E005AC11F /* Products */ = {
isa = PBXGroup;
children = (
E77BD9C72F9E073E005AC11F /* MultiTimer.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
E77BD9C62F9E073E005AC11F /* MultiTimer */ = {
isa = PBXNativeTarget;
buildConfigurationList = E77BD9D22F9E0740005AC11F /* Build configuration list for PBXNativeTarget "MultiTimer" */;
buildPhases = (
E77BD9C32F9E073E005AC11F /* Sources */,
E77BD9C42F9E073E005AC11F /* Frameworks */,
E77BD9C52F9E073E005AC11F /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
E77BD9C92F9E073E005AC11F /* MultiTimer */,
);
name = MultiTimer;
packageProductDependencies = (
);
productName = MultiTimer;
productReference = E77BD9C72F9E073E005AC11F /* MultiTimer.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E77BD9BF2F9E073E005AC11F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640;
TargetAttributes = {
E77BD9C62F9E073E005AC11F = {
CreatedOnToolsVersion = 26.4.1;
};
};
};
buildConfigurationList = E77BD9C22F9E073E005AC11F /* Build configuration list for PBXProject "MultiTimer" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = E77BD9BE2F9E073E005AC11F;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = E77BD9C82F9E073E005AC11F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
E77BD9C62F9E073E005AC11F /* MultiTimer */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
E77BD9C52F9E073E005AC11F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
E77BD9C32F9E073E005AC11F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
E77BD9D02F9E0740005AC11F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = NO;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.4;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
E77BD9D12F9E0740005AC11F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.4;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
E77BD9D32F9E0740005AC11F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = MultiTimer;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Haltenhopf47.MultiTimer;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
E77BD9D42F9E0740005AC11F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = MultiTimer;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = Haltenhopf47.MultiTimer;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
E77BD9C22F9E073E005AC11F /* Build configuration list for PBXProject "MultiTimer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E77BD9D02F9E0740005AC11F /* Debug */,
E77BD9D12F9E0740005AC11F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E77BD9D22F9E0740005AC11F /* Build configuration list for PBXNativeTarget "MultiTimer" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E77BD9D32F9E0740005AC11F /* Debug */,
E77BD9D42F9E0740005AC11F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = E77BD9BF2F9E073E005AC11F /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
+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
}
}