commit 22b8f5d806ccd7ffa438aac8ab30b1308fa76a28 Author: rene Date: Fri May 29 21:12:45 2026 +0200 Initiales HabitTracker-Projekt: SwiftUI + SwiftData Gewohnheiten-Tracker Natives iOS-App-Gerüst (Xcode 26, synchronisierte Ordner, iOS 18+). Features: - Gewohnheiten anlegen (Name, SF-Symbol, Farbe), heute abhaken, Streaks, Löschen - Detailansicht mit Monatskalender (Tage nachtragbar) und Statistiken - Tägliche Erinnerungen via lokale Notifications - Home-Screen-Widget (klein/mittel) mit App-Group-Datenaustausch diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a015bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Xcode +build/ +DerivedData/ +*.xcuserstate +*.xcscmblueprint + +# Xcode user-specific project settings +xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# macOS +.DS_Store diff --git a/HabitTracker.entitlements b/HabitTracker.entitlements new file mode 100644 index 0000000..b8ca11b --- /dev/null +++ b/HabitTracker.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.motocamp.HabitTracker + + + diff --git a/HabitTracker.xcodeproj/project.pbxproj b/HabitTracker.xcodeproj/project.pbxproj new file mode 100644 index 0000000..013ddda --- /dev/null +++ b/HabitTracker.xcodeproj/project.pbxproj @@ -0,0 +1,491 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + BB0000000000000000000010 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* Shared.swift */; }; + BB0000000000000000000011 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* Shared.swift */; }; + BB0000000000000000000012 /* HabitTrackerWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000007 /* HabitTrackerWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + BB0000000000000000000041 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA0000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BB0000000000000000000020; + remoteInfo = HabitTrackerWidgetExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + BB0000000000000000000024 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BB0000000000000000000012 /* HabitTrackerWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + AA0000000000000000000005 /* HabitTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HabitTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; + BB0000000000000000000001 /* Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = ""; }; + BB0000000000000000000003 /* HabitTrackerWidget-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "HabitTrackerWidget-Info.plist"; sourceTree = ""; }; + BB0000000000000000000004 /* HabitTracker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HabitTracker.entitlements; sourceTree = ""; }; + BB0000000000000000000005 /* HabitTrackerWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HabitTrackerWidget.entitlements; sourceTree = ""; }; + BB0000000000000000000007 /* HabitTrackerWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = HabitTrackerWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + AA0000000000000000000006 /* HabitTracker */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HabitTracker; + sourceTree = ""; + }; + BB0000000000000000000002 /* HabitTrackerWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HabitTrackerWidget; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA000000000000000000000E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BB0000000000000000000022 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA0000000000000000000002 = { + isa = PBXGroup; + children = ( + BB0000000000000000000006 /* Shared */, + AA0000000000000000000006 /* HabitTracker */, + BB0000000000000000000002 /* HabitTrackerWidget */, + BB0000000000000000000004 /* HabitTracker.entitlements */, + BB0000000000000000000005 /* HabitTrackerWidget.entitlements */, + BB0000000000000000000003 /* HabitTrackerWidget-Info.plist */, + AA0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + AA0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + AA0000000000000000000005 /* HabitTracker.app */, + BB0000000000000000000007 /* HabitTrackerWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + BB0000000000000000000006 /* Shared */ = { + isa = PBXGroup; + children = ( + BB0000000000000000000001 /* Shared.swift */, + ); + path = Shared; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA0000000000000000000004 /* HabitTracker */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA0000000000000000000008 /* Build configuration list for PBXNativeTarget "HabitTracker" */; + buildPhases = ( + AA000000000000000000000D /* Sources */, + AA000000000000000000000E /* Frameworks */, + AA000000000000000000000F /* Resources */, + BB0000000000000000000024 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + BB0000000000000000000040 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000006 /* HabitTracker */, + ); + name = HabitTracker; + productName = HabitTracker; + productReference = AA0000000000000000000005 /* HabitTracker.app */; + productType = "com.apple.product-type.application"; + }; + BB0000000000000000000020 /* HabitTrackerWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = BB0000000000000000000030 /* Build configuration list for PBXNativeTarget "HabitTrackerWidgetExtension" */; + buildPhases = ( + BB0000000000000000000021 /* Sources */, + BB0000000000000000000022 /* Frameworks */, + BB0000000000000000000023 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + BB0000000000000000000002 /* HabitTrackerWidget */, + ); + name = HabitTrackerWidgetExtension; + productName = HabitTrackerWidgetExtension; + productReference = BB0000000000000000000007 /* HabitTrackerWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + AA0000000000000000000004 = { + CreatedOnToolsVersion = 26.5; + }; + BB0000000000000000000020 = { + CreatedOnToolsVersion = 26.5; + }; + }; + }; + buildConfigurationList = AA0000000000000000000007 /* Build configuration list for PBXProject "HabitTracker" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA0000000000000000000002; + productRefGroup = AA0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA0000000000000000000004 /* HabitTracker */, + BB0000000000000000000020 /* HabitTrackerWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA000000000000000000000F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BB0000000000000000000023 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA000000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB0000000000000000000010 /* Shared.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BB0000000000000000000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB0000000000000000000011 /* Shared.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + BB0000000000000000000040 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BB0000000000000000000020 /* HabitTrackerWidgetExtension */; + targetProxy = BB0000000000000000000041 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + AA0000000000000000000009 /* 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_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_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 = YES; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AA000000000000000000000A /* 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_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_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_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; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AA000000000000000000000B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = HabitTracker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.motocamp.HabitTracker; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AA000000000000000000000C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = HabitTracker.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = de.motocamp.HabitTracker; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + BB0000000000000000000031 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = HabitTrackerWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "HabitTrackerWidget-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "de.motocamp.HabitTracker.HabitTrackerWidget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + BB0000000000000000000032 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = HabitTrackerWidget.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "HabitTrackerWidget-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "de.motocamp.HabitTracker.HabitTrackerWidget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA0000000000000000000007 /* Build configuration list for PBXProject "HabitTracker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA0000000000000000000009 /* Debug */, + AA000000000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA0000000000000000000008 /* Build configuration list for PBXNativeTarget "HabitTracker" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA000000000000000000000B /* Debug */, + AA000000000000000000000C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BB0000000000000000000030 /* Build configuration list for PBXNativeTarget "HabitTrackerWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB0000000000000000000031 /* Debug */, + BB0000000000000000000032 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA0000000000000000000001 /* Project object */; +} diff --git a/HabitTracker/Assets.xcassets/AccentColor.colorset/Contents.json b/HabitTracker/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..ccf9e4f --- /dev/null +++ b/HabitTracker/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x59", + "green" : "0xC7", + "red" : "0x34" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/HabitTracker/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/HabitTracker/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..8b2f92d Binary files /dev/null and b/HabitTracker/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/HabitTracker/Assets.xcassets/AppIcon.appiconset/Contents.json b/HabitTracker/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/HabitTracker/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/HabitTracker/Assets.xcassets/Contents.json b/HabitTracker/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/HabitTracker/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/HabitTracker/HabitTrackerApp.swift b/HabitTracker/HabitTrackerApp.swift new file mode 100644 index 0000000..1c89fdf --- /dev/null +++ b/HabitTracker/HabitTrackerApp.swift @@ -0,0 +1,12 @@ +import SwiftUI +import SwiftData + +@main +struct HabitTrackerApp: App { + var body: some Scene { + WindowGroup { + HabitListView() + } + .modelContainer(for: [Habit.self, HabitEntry.self]) + } +} diff --git a/HabitTracker/Models/Habit.swift b/HabitTracker/Models/Habit.swift new file mode 100644 index 0000000..bfeb4c0 --- /dev/null +++ b/HabitTracker/Models/Habit.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftData + +@Model +final class Habit { + var uuid: UUID = UUID() + var name: String + var symbolName: String + var colorHex: String + var createdAt: Date + /// Time of day for the daily reminder; nil means no reminder. + var reminderTime: Date? + + @Relationship(deleteRule: .cascade, inverse: \HabitEntry.habit) + var entries: [HabitEntry] = [] + + init( + name: String, + symbolName: String = "star.fill", + colorHex: String = "#34C759", + createdAt: Date = .now, + reminderTime: Date? = nil + ) { + self.name = name + self.symbolName = symbolName + self.colorHex = colorHex + self.createdAt = createdAt + self.reminderTime = reminderTime + } +} + +extension Habit { + /// Whether this habit was checked off on the given day. + func isCompleted(on day: Date, calendar: Calendar = .current) -> Bool { + entries.contains { calendar.isDate($0.date, inSameDayAs: day) } + } + + var isCompletedToday: Bool { + isCompleted(on: .now) + } + + /// Number of consecutive days (counting back from today) the habit was done. + var currentStreak: Int { + let calendar = Calendar.current + let doneDays = Set(entries.map { calendar.startOfDay(for: $0.date) }) + var day = calendar.startOfDay(for: .now) + + // A streak stays alive if today isn't done yet but yesterday was. + if !doneDays.contains(day) { + day = calendar.date(byAdding: .day, value: -1, to: day)! + } + + var streak = 0 + while doneDays.contains(day) { + streak += 1 + day = calendar.date(byAdding: .day, value: -1, to: day)! + } + return streak + } + + /// Longest run of consecutive completed days, ever. + var longestStreak: Int { + let calendar = Calendar.current + let days = Set(entries.map { calendar.startOfDay(for: $0.date) }).sorted() + guard !days.isEmpty else { return 0 } + + var longest = 1 + var run = 1 + for i in 1.. Bool { + do { + return try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + } catch { + return false + } + } + + /// Cancels any existing reminder for the habit and, if it has a reminder + /// time, schedules a new daily notification. + static func reschedule(for habit: Habit) { + let center = UNUserNotificationCenter.current() + let id = identifier(for: habit) + center.removePendingNotificationRequests(withIdentifiers: [id]) + + guard let time = habit.reminderTime else { return } + + let content = UNMutableNotificationContent() + content.title = habit.name + content.body = "Zeit für deine Gewohnheit" + content.sound = .default + + let components = Calendar.current.dateComponents([.hour, .minute], from: time) + let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true) + let request = UNNotificationRequest(identifier: id, content: content, trigger: trigger) + center.add(request) + } + + static func cancel(for habit: Habit) { + UNUserNotificationCenter.current() + .removePendingNotificationRequests(withIdentifiers: [identifier(for: habit)]) + } + + private static func identifier(for habit: Habit) -> String { + "habit-\(habit.uuid.uuidString)" + } +} diff --git a/HabitTracker/Support/WidgetSync.swift b/HabitTracker/Support/WidgetSync.swift new file mode 100644 index 0000000..18c4948 --- /dev/null +++ b/HabitTracker/Support/WidgetSync.swift @@ -0,0 +1,24 @@ +import Foundation +import SwiftData +import WidgetKit + +enum WidgetSync { + /// Writes the current habits to the shared store and reloads widget timelines. + static func refresh(_ context: ModelContext) { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) + let habits = (try? context.fetch(descriptor)) ?? [] + + let items = habits.map { habit in + WidgetSnapshot.Item( + id: habit.uuid, + name: habit.name, + symbolName: habit.symbolName, + colorHex: habit.colorHex, + isDoneToday: habit.isCompletedToday + ) + } + + SharedStore.save(WidgetSnapshot(generatedAt: .now, items: items)) + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/HabitTracker/Views/AddHabitView.swift b/HabitTracker/Views/AddHabitView.swift new file mode 100644 index 0000000..d05028a --- /dev/null +++ b/HabitTracker/Views/AddHabitView.swift @@ -0,0 +1,101 @@ +import SwiftUI +import SwiftData + +struct AddHabitView: View { + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var symbolName = "star.fill" + @State private var colorHex = "#34C759" + @State private var reminderTime: Date? + + private let symbols = [ + "star.fill", "drop.fill", "flame.fill", "book.fill", "dumbbell.fill", + "leaf.fill", "heart.fill", "moon.fill", "cup.and.saucer.fill", "figure.run" + ] + private let colors = ["#34C759", "#007AFF", "#FF9500", "#FF2D55", "#AF52DE", "#5AC8FA"] + + private let columns = [GridItem(.adaptive(minimum: 44), spacing: 12)] + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("z. B. Wasser trinken", text: $name) + } + + Section("Symbol") { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(symbols, id: \.self) { symbol in + Image(systemName: symbol) + .font(.title2) + .frame(width: 44, height: 44) + .background(symbol == symbolName ? Color(hex: colorHex).opacity(0.2) : Color.clear) + .foregroundStyle(symbol == symbolName ? Color(hex: colorHex) : Color.secondary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .onTapGesture { symbolName = symbol } + } + } + .padding(.vertical, 4) + } + + Section("Farbe") { + HStack(spacing: 12) { + ForEach(colors, id: \.self) { hex in + Circle() + .fill(Color(hex: hex)) + .frame(width: 32, height: 32) + .overlay { + if hex == colorHex { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + } + } + .onTapGesture { colorHex = hex } + } + } + .padding(.vertical, 4) + } + + Section("Erinnerung") { + ReminderEditor(reminderTime: $reminderTime) + } + } + .navigationTitle("Neue Gewohnheit") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Sichern") { save() } + .disabled(trimmedName.isEmpty) + } + } + } + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func save() { + let habit = Habit( + name: trimmedName, + symbolName: symbolName, + colorHex: colorHex, + reminderTime: reminderTime + ) + context.insert(habit) + NotificationManager.reschedule(for: habit) + WidgetSync.refresh(context) + dismiss() + } +} + +#Preview { + AddHabitView() + .modelContainer(for: [Habit.self, HabitEntry.self], inMemory: true) +} diff --git a/HabitTracker/Views/HabitDetailView.swift b/HabitTracker/Views/HabitDetailView.swift new file mode 100644 index 0000000..cc414cf --- /dev/null +++ b/HabitTracker/Views/HabitDetailView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import SwiftData + +struct HabitDetailView: View { + @Environment(\.modelContext) private var context + @Bindable var habit: Habit + + var body: some View { + ScrollView { + VStack(spacing: 24) { + header + + HStack(spacing: 12) { + StatTile(value: "\(habit.currentStreak)", label: "Aktuell", systemImage: "flame.fill", color: color) + StatTile(value: "\(habit.longestStreak)", label: "Rekord", systemImage: "trophy.fill", color: color) + StatTile(value: "\(habit.totalCompletions)", label: "Gesamt", systemImage: "checkmark.seal.fill", color: color) + } + + MonthCalendarView(habit: habit) { day in + context.toggleCompletion(for: habit, on: day) + } + .padding() + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16)) + + VStack(spacing: 12) { + ReminderEditor(reminderTime: $habit.reminderTime) + } + .padding() + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16)) + .onChange(of: habit.reminderTime) { + NotificationManager.reschedule(for: habit) + } + } + .padding() + } + .navigationTitle(habit.name) + .navigationBarTitleDisplayMode(.inline) + } + + private var color: Color { Color(hex: habit.colorHex) } + + private var header: some View { + VStack(spacing: 8) { + Image(systemName: habit.symbolName) + .font(.system(size: 44)) + .foregroundStyle(color) + .frame(width: 88, height: 88) + .background(color.opacity(0.15), in: Circle()) + Text(habit.name) + .font(.title2.bold()) + } + } +} + +private struct StatTile: View { + let value: String + let label: String + let systemImage: String + let color: Color + + var body: some View { + VStack(spacing: 6) { + Image(systemName: systemImage) + .foregroundStyle(color) + Text(value) + .font(.title3.bold()) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 16)) + } +} + +#Preview { + NavigationStack { + HabitDetailView(habit: Habit(name: "Wasser trinken", symbolName: "drop.fill", colorHex: "#007AFF")) + } + .modelContainer(for: [Habit.self, HabitEntry.self], inMemory: true) +} diff --git a/HabitTracker/Views/HabitListView.swift b/HabitTracker/Views/HabitListView.swift new file mode 100644 index 0000000..6643d18 --- /dev/null +++ b/HabitTracker/Views/HabitListView.swift @@ -0,0 +1,69 @@ +import SwiftUI +import SwiftData + +struct HabitListView: View { + @Environment(\.modelContext) private var context + @Environment(\.scenePhase) private var scenePhase + @Query(sort: \Habit.createdAt) private var habits: [Habit] + @State private var showingAdd = false + + var body: some View { + NavigationStack { + Group { + if habits.isEmpty { + ContentUnavailableView( + "Keine Gewohnheiten", + systemImage: "checklist", + description: Text("Tippe auf +, um deine erste Gewohnheit anzulegen.") + ) + } else { + List { + ForEach(habits) { habit in + NavigationLink { + HabitDetailView(habit: habit) + } label: { + HabitRowView(habit: habit) + } + } + .onDelete(perform: delete) + } + } + } + .navigationTitle("Heute") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAdd = true + } label: { + Label("Hinzufügen", systemImage: "plus") + } + } + } + .sheet(isPresented: $showingAdd) { + AddHabitView() + } + } + .task { + WidgetSync.refresh(context) + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + WidgetSync.refresh(context) + } + } + } + + private func delete(at offsets: IndexSet) { + for index in offsets { + let habit = habits[index] + NotificationManager.cancel(for: habit) + context.delete(habit) + } + WidgetSync.refresh(context) + } +} + +#Preview { + HabitListView() + .modelContainer(for: [Habit.self, HabitEntry.self], inMemory: true) +} diff --git a/HabitTracker/Views/HabitRowView.swift b/HabitTracker/Views/HabitRowView.swift new file mode 100644 index 0000000..42606a7 --- /dev/null +++ b/HabitTracker/Views/HabitRowView.swift @@ -0,0 +1,44 @@ +import SwiftUI +import SwiftData + +struct HabitRowView: View { + @Environment(\.modelContext) private var context + @Bindable var habit: Habit + + var body: some View { + HStack(spacing: 12) { + Image(systemName: habit.symbolName) + .font(.title2) + .foregroundStyle(Color(hex: habit.colorHex)) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(habit.name) + .font(.headline) + if habit.currentStreak > 0 { + Text(streakText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + Button { + context.toggleCompletion(for: habit, on: .now) + } label: { + Image(systemName: habit.isCompletedToday ? "checkmark.circle.fill" : "circle") + .font(.title) + .foregroundStyle(habit.isCompletedToday ? Color(hex: habit.colorHex) : Color.secondary) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + } + + private var streakText: String { + let days = habit.currentStreak + let unit = days == 1 ? "Tag" : "Tage" + return "\(days) \(unit) Streak 🔥" + } +} diff --git a/HabitTracker/Views/MonthCalendarView.swift b/HabitTracker/Views/MonthCalendarView.swift new file mode 100644 index 0000000..795fa8e --- /dev/null +++ b/HabitTracker/Views/MonthCalendarView.swift @@ -0,0 +1,129 @@ +import SwiftUI + +struct MonthCalendarView: View { + let habit: Habit + let onToggle: (Date) -> Void + + @State private var monthStart = Calendar.german.startOfMonth(for: .now) + + private let weekdaySymbols = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + private let columns = Array(repeating: GridItem(.flexible()), count: 7) + + private var calendar: Calendar { .german } + private var today: Date { calendar.startOfDay(for: .now) } + + var body: some View { + VStack(spacing: 12) { + header + + LazyVGrid(columns: columns, spacing: 8) { + ForEach(weekdaySymbols, id: \.self) { symbol in + Text(symbol) + .font(.caption2) + .foregroundStyle(.secondary) + } + + ForEach(Array(dayCells.enumerated()), id: \.offset) { _, date in + if let date { + dayCell(for: date) + } else { + Color.clear.frame(height: 36) + } + } + } + } + } + + private var header: some View { + HStack { + Button { + shiftMonth(by: -1) + } label: { + Image(systemName: "chevron.left") + } + + Spacer() + + Text(monthTitle) + .font(.headline) + + Spacer() + + Button { + shiftMonth(by: 1) + } label: { + Image(systemName: "chevron.right") + } + .disabled(isCurrentMonthOrLater) + } + .buttonStyle(.borderless) + } + + private func dayCell(for date: Date) -> some View { + let isDone = habit.isCompleted(on: date, calendar: calendar) + let isToday = calendar.isDate(date, inSameDayAs: today) + let isFuture = date > today + let color = Color(hex: habit.colorHex) + + return Text("\(calendar.component(.day, from: date))") + .font(.callout) + .frame(width: 36, height: 36) + .background { + if isDone { + Circle().fill(color) + } else if isToday { + Circle().strokeBorder(color, lineWidth: 1.5) + } + } + .foregroundStyle(isDone ? .white : (isFuture ? Color.secondary.opacity(0.4) : .primary)) + .contentShape(Circle()) + .onTapGesture { + if !isFuture { onToggle(date) } + } + } + + private var monthTitle: String { + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = Locale(identifier: "de_DE") + formatter.dateFormat = "LLLL yyyy" + return formatter.string(from: monthStart) + } + + /// Leading nil padding for the weekday offset, then one date per day in the month. + private var dayCells: [Date?] { + guard let range = calendar.range(of: .day, in: .month, for: monthStart) else { return [] } + let weekday = calendar.component(.weekday, from: monthStart) + let leadingBlanks = (weekday - calendar.firstWeekday + 7) % 7 + + var cells: [Date?] = Array(repeating: nil, count: leadingBlanks) + for day in range { + cells.append(calendar.date(byAdding: .day, value: day - 1, to: monthStart)) + } + return cells + } + + private var isCurrentMonthOrLater: Bool { + monthStart >= calendar.startOfMonth(for: .now) + } + + private func shiftMonth(by value: Int) { + if let next = calendar.date(byAdding: .month, value: value, to: monthStart) { + monthStart = next + } + } +} + +extension Calendar { + /// Gregorian calendar with Monday as the first weekday (German convention). + static var german: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.firstWeekday = 2 + return calendar + } + + func startOfMonth(for date: Date) -> Date { + let components = dateComponents([.year, .month], from: date) + return self.date(from: components) ?? startOfDay(for: date) + } +} diff --git a/HabitTracker/Views/ReminderEditor.swift b/HabitTracker/Views/ReminderEditor.swift new file mode 100644 index 0000000..8cc80f7 --- /dev/null +++ b/HabitTracker/Views/ReminderEditor.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Toggle + time picker that drives an optional reminder time. Designed to sit +/// inside a Form Section or a VStack. Requests notification permission when the +/// user first switches the reminder on. Scheduling is the caller's job. +struct ReminderEditor: View { + @Binding var reminderTime: Date? + + @State private var enabled: Bool + @State private var time: Date + + init(reminderTime: Binding) { + _reminderTime = reminderTime + _enabled = State(initialValue: reminderTime.wrappedValue != nil) + _time = State(initialValue: reminderTime.wrappedValue ?? NotificationManager.defaultTime) + } + + var body: some View { + Group { + Toggle(isOn: $enabled) { + Label("Tägliche Erinnerung", systemImage: "bell.fill") + } + .onChange(of: enabled) { _, isOn in + reminderTime = isOn ? time : nil + if isOn { + Task { await NotificationManager.requestAuthorization() } + } + } + + if enabled { + DatePicker("Uhrzeit", selection: $time, displayedComponents: .hourAndMinute) + .onChange(of: time) { _, newValue in + reminderTime = newValue + } + } + } + } +} diff --git a/HabitTrackerWidget-Info.plist b/HabitTrackerWidget-Info.plist new file mode 100644 index 0000000..f121948 --- /dev/null +++ b/HabitTrackerWidget-Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + HabitTracker + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/HabitTrackerWidget.entitlements b/HabitTrackerWidget.entitlements new file mode 100644 index 0000000..b8ca11b --- /dev/null +++ b/HabitTrackerWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.de.motocamp.HabitTracker + + + diff --git a/HabitTrackerWidget/HabitTrackerWidget.swift b/HabitTrackerWidget/HabitTrackerWidget.swift new file mode 100644 index 0000000..8f03ef8 --- /dev/null +++ b/HabitTrackerWidget/HabitTrackerWidget.swift @@ -0,0 +1,121 @@ +import WidgetKit +import SwiftUI + +struct HabitTimelineEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> HabitTimelineEntry { + HabitTimelineEntry(date: .now, snapshot: .empty) + } + + func getSnapshot(in context: Context, completion: @escaping (HabitTimelineEntry) -> Void) { + completion(HabitTimelineEntry(date: .now, snapshot: SharedStore.load())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = HabitTimelineEntry(date: .now, snapshot: SharedStore.load()) + // Reset "today" at the next midnight. + let nextMidnight = Calendar.current.startOfDay(for: .now.addingTimeInterval(86_400)) + completion(Timeline(entries: [entry], policy: .after(nextMidnight))) + } +} + +struct HabitTrackerWidgetEntryView: View { + var entry: Provider.Entry + @Environment(\.widgetFamily) private var family + + private var items: [WidgetSnapshot.Item] { entry.snapshot.items } + private var doneCount: Int { items.filter(\.isDoneToday).count } + + var body: some View { + if items.isEmpty { + emptyState + } else { + switch family { + case .systemSmall: + smallView + default: + mediumView + } + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "checklist") + .font(.title) + .foregroundStyle(.secondary) + Text("Keine Gewohnheiten") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var header: some View { + HStack { + Text("Heute") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Spacer() + Text("\(doneCount)/\(items.count)") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + } + + private var smallView: some View { + VStack(alignment: .leading, spacing: 8) { + header + Spacer(minLength: 0) + Text("\(doneCount)") + .font(.system(size: 44, weight: .bold)) + + Text(" / \(items.count)") + .font(.title3.weight(.semibold)) + .foregroundColor(.secondary) + Text(doneCount == items.count ? "Alles erledigt 🎉" : "erledigt") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private var mediumView: some View { + VStack(alignment: .leading, spacing: 8) { + header + ForEach(items.prefix(3)) { item in + HStack(spacing: 10) { + Image(systemName: item.symbolName) + .foregroundStyle(Color(hex: item.colorHex)) + .frame(width: 22) + Text(item.name) + .font(.subheadline) + .lineLimit(1) + Spacer() + Image(systemName: item.isDoneToday ? "checkmark.circle.fill" : "circle") + .foregroundStyle(item.isDoneToday ? Color(hex: item.colorHex) : .secondary) + } + } + if items.count > 3 { + Text("+ \(items.count - 3) weitere") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } +} + +struct HabitTrackerWidget: Widget { + let kind = "HabitTrackerWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + HabitTrackerWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Gewohnheiten") + .description("Dein Fortschritt von heute.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} diff --git a/HabitTrackerWidget/HabitTrackerWidgetBundle.swift b/HabitTrackerWidget/HabitTrackerWidgetBundle.swift new file mode 100644 index 0000000..2852a56 --- /dev/null +++ b/HabitTrackerWidget/HabitTrackerWidgetBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct HabitTrackerWidgetBundle: WidgetBundle { + var body: some Widget { + HabitTrackerWidget() + } +} diff --git a/Shared/Shared.swift b/Shared/Shared.swift new file mode 100644 index 0000000..9d6fa0f --- /dev/null +++ b/Shared/Shared.swift @@ -0,0 +1,61 @@ +import SwiftUI + +// MARK: - Snapshot shared between app and widget + +struct WidgetSnapshot: Codable { + struct Item: Codable, Identifiable { + var id: UUID + var name: String + var symbolName: String + var colorHex: String + var isDoneToday: Bool + } + + var generatedAt: Date + var items: [Item] + + static let empty = WidgetSnapshot(generatedAt: .now, items: []) +} + +// MARK: - App Group backed store + +enum SharedStore { + static let appGroupID = "group.de.motocamp.HabitTracker" + private static let key = "widgetSnapshot" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: appGroupID) + } + + static func save(_ snapshot: WidgetSnapshot) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults?.set(data, forKey: key) + } + + static func load() -> WidgetSnapshot { + guard let data = defaults?.data(forKey: key), + let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + else { return .empty } + return snapshot + } +} + +// MARK: - Color from hex (used by both targets) + +extension Color { + /// Creates a color from a "#RRGGBB" hex string. Falls back to system green on bad input. + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + var value: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&value) + + if cleaned.count == 6 { + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + self.init(.sRGB, red: r, green: g, blue: b) + } else { + self.init(.sRGB, red: 52 / 255, green: 199 / 255, blue: 89 / 255) + } + } +}