commit 5186ada84eee02c7b12f6faf20ef3fc2450c153b Author: Martins-Voron <155222091+Martins-Voron@users.noreply.github.com> Date: Wed Apr 29 19:22:13 2026 +0200 Initial MultiTimer macOS app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e97c8f3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/MultiTimer.xcodeproj/project.pbxproj b/MultiTimer.xcodeproj/project.pbxproj new file mode 100644 index 0000000..aac5c4a --- /dev/null +++ b/MultiTimer.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + E77BD9C82F9E073E005AC11F /* Products */ = { + isa = PBXGroup; + children = ( + E77BD9C72F9E073E005AC11F /* MultiTimer.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/MultiTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MultiTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/MultiTimer.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MultiTimer/AlarmManager.swift b/MultiTimer/AlarmManager.swift new file mode 100644 index 0000000..62f3e92 --- /dev/null +++ b/MultiTimer/AlarmManager.swift @@ -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) + } +} diff --git a/MultiTimer/AlarmWindowManager.swift b/MultiTimer/AlarmWindowManager.swift new file mode 100644 index 0000000..786bb24 --- /dev/null +++ b/MultiTimer/AlarmWindowManager.swift @@ -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) + } +} diff --git a/MultiTimer/Assets.xcassets/AccentColor.colorset/Contents.json b/MultiTimer/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/MultiTimer/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/Contents.json b/MultiTimer/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b548dd0 --- /dev/null +++ b/MultiTimer/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..28780b3 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..adbae97 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_256x256 1.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_256x256 1.png new file mode 100644 index 0000000..19a4ca9 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_256x256 1.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..19a4ca9 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png new file mode 100644 index 0000000..43d9ae8 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32 1.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..43d9ae8 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..4a3ecf8 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512 1.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512 1.png new file mode 100644 index 0000000..f0d5730 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512 1.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..f0d5730 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..00ac0c2 Binary files /dev/null and b/MultiTimer/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/MultiTimer/Assets.xcassets/Contents.json b/MultiTimer/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/MultiTimer/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MultiTimer/ContentView.swift b/MultiTimer/ContentView.swift new file mode 100644 index 0000000..48dc7c6 --- /dev/null +++ b/MultiTimer/ContentView.swift @@ -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) + } + } +} diff --git a/MultiTimer/MultiTimerApp.swift b/MultiTimer/MultiTimerApp.swift new file mode 100644 index 0000000..3c953f5 --- /dev/null +++ b/MultiTimer/MultiTimerApp.swift @@ -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) + } + } +} diff --git a/MultiTimer/TimerItem.swift b/MultiTimer/TimerItem.swift new file mode 100644 index 0000000..dfd7bb2 --- /dev/null +++ b/MultiTimer/TimerItem.swift @@ -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 + } +} diff --git a/MultiTimer/TimerStore.swift b/MultiTimer/TimerStore.swift new file mode 100644 index 0000000..8b3895f --- /dev/null +++ b/MultiTimer/TimerStore.swift @@ -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 + } +}