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
This commit is contained in:
commit
22b8f5d806
24 changed files with 1448 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Xcode
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
*.xcscmblueprint
|
||||||
|
|
||||||
|
# Xcode user-specific project settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
10
HabitTracker.entitlements
Normal file
10
HabitTracker.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.de.motocamp.HabitTracker</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
491
HabitTracker.xcodeproj/project.pbxproj
Normal file
491
HabitTracker.xcodeproj/project.pbxproj
Normal file
|
|
@ -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 = "<group>"; };
|
||||||
|
BB0000000000000000000003 /* HabitTrackerWidget-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "HabitTrackerWidget-Info.plist"; sourceTree = "<group>"; };
|
||||||
|
BB0000000000000000000004 /* HabitTracker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HabitTracker.entitlements; sourceTree = "<group>"; };
|
||||||
|
BB0000000000000000000005 /* HabitTrackerWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HabitTrackerWidget.entitlements; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
BB0000000000000000000002 /* HabitTrackerWidget */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = HabitTrackerWidget;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
AA0000000000000000000003 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AA0000000000000000000005 /* HabitTracker.app */,
|
||||||
|
BB0000000000000000000007 /* HabitTrackerWidgetExtension.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BB0000000000000000000006 /* Shared */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
BB0000000000000000000001 /* Shared.swift */,
|
||||||
|
);
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
HabitTracker/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
HabitTracker/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
HabitTracker/Assets.xcassets/Contents.json
Normal file
6
HabitTracker/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
12
HabitTracker/HabitTrackerApp.swift
Normal file
12
HabitTracker/HabitTrackerApp.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HabitTrackerApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
HabitListView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [Habit.self, HabitEntry.self])
|
||||||
|
}
|
||||||
|
}
|
||||||
84
HabitTracker/Models/Habit.swift
Normal file
84
HabitTracker/Models/Habit.swift
Normal file
|
|
@ -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..<days.count {
|
||||||
|
let expectedPrev = calendar.date(byAdding: .day, value: -1, to: days[i])!
|
||||||
|
if calendar.isDate(expectedPrev, inSameDayAs: days[i - 1]) {
|
||||||
|
run += 1
|
||||||
|
} else {
|
||||||
|
run = 1
|
||||||
|
}
|
||||||
|
longest = max(longest, run)
|
||||||
|
}
|
||||||
|
return longest
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCompletions: Int {
|
||||||
|
entries.count
|
||||||
|
}
|
||||||
|
}
|
||||||
13
HabitTracker/Models/HabitEntry.swift
Normal file
13
HabitTracker/Models/HabitEntry.swift
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class HabitEntry {
|
||||||
|
var date: Date
|
||||||
|
var habit: Habit?
|
||||||
|
|
||||||
|
init(date: Date = .now, habit: Habit? = nil) {
|
||||||
|
self.date = date
|
||||||
|
self.habit = habit
|
||||||
|
}
|
||||||
|
}
|
||||||
17
HabitTracker/Support/ModelContext+Habit.swift
Normal file
17
HabitTracker/Support/ModelContext+Habit.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
extension ModelContext {
|
||||||
|
/// Toggles completion for a habit on a given day: removes the entry if it
|
||||||
|
/// exists, otherwise inserts one normalized to the start of that day.
|
||||||
|
func toggleCompletion(for habit: Habit, on day: Date) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if let entry = habit.entries.first(where: { calendar.isDate($0.date, inSameDayAs: day) }) {
|
||||||
|
delete(entry)
|
||||||
|
} else {
|
||||||
|
let entry = HabitEntry(date: calendar.startOfDay(for: day), habit: habit)
|
||||||
|
insert(entry)
|
||||||
|
}
|
||||||
|
WidgetSync.refresh(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
HabitTracker/Support/NotificationManager.swift
Normal file
49
HabitTracker/Support/NotificationManager.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum NotificationManager {
|
||||||
|
/// Default reminder time (09:00) used when the user first enables a reminder.
|
||||||
|
static var defaultTime: Date {
|
||||||
|
Calendar.current.date(bySettingHour: 9, minute: 0, second: 0, of: .now) ?? .now
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func requestAuthorization() async -> 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
HabitTracker/Support/WidgetSync.swift
Normal file
24
HabitTracker/Support/WidgetSync.swift
Normal file
|
|
@ -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<Habit>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
101
HabitTracker/Views/AddHabitView.swift
Normal file
101
HabitTracker/Views/AddHabitView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
82
HabitTracker/Views/HabitDetailView.swift
Normal file
82
HabitTracker/Views/HabitDetailView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
69
HabitTracker/Views/HabitListView.swift
Normal file
69
HabitTracker/Views/HabitListView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
44
HabitTracker/Views/HabitRowView.swift
Normal file
44
HabitTracker/Views/HabitRowView.swift
Normal file
|
|
@ -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 🔥"
|
||||||
|
}
|
||||||
|
}
|
||||||
129
HabitTracker/Views/MonthCalendarView.swift
Normal file
129
HabitTracker/Views/MonthCalendarView.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
HabitTracker/Views/ReminderEditor.swift
Normal file
38
HabitTracker/Views/ReminderEditor.swift
Normal file
|
|
@ -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<Date?>) {
|
||||||
|
_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
HabitTrackerWidget-Info.plist
Normal file
29
HabitTrackerWidget-Info.plist
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>HabitTracker</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
10
HabitTrackerWidget.entitlements
Normal file
10
HabitTrackerWidget.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.de.motocamp.HabitTracker</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
121
HabitTrackerWidget/HabitTrackerWidget.swift
Normal file
121
HabitTrackerWidget/HabitTrackerWidget.swift
Normal file
|
|
@ -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<HabitTimelineEntry>) -> 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
9
HabitTrackerWidget/HabitTrackerWidgetBundle.swift
Normal file
9
HabitTrackerWidget/HabitTrackerWidgetBundle.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct HabitTrackerWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
HabitTrackerWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Shared/Shared.swift
Normal file
61
Shared/Shared.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue